diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml index a51a96c0..b6ba18dc 100644 --- a/.github/workflows/ci-macos-compat.yml +++ b/.github/workflows/ci-macos-compat.yml @@ -100,9 +100,6 @@ jobs: sleep $((attempt * 5)) done - - name: Run issue #952 regression guard - run: python3 tests/test_issue_952_socket_listener_recovery.py - - name: Run unit tests run: | set -euo pipefail diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f67c3533..7315d8ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,9 +108,6 @@ jobs: sleep $((attempt * 5)) done - - name: Run issue #952 regression guard - run: python3 tests/test_issue_952_socket_listener_recovery.py - - name: Run unit tests run: | set -euo pipefail @@ -216,9 +213,6 @@ jobs: sleep $((attempt * 5)) done - - name: Run issue #952 regression guard - run: python3 tests/test_issue_952_socket_listener_recovery.py - - name: Create virtual display run: | set -euo pipefail diff --git a/.github/workflows/test-depot.yml b/.github/workflows/test-depot.yml index 867479ad..ca636bf6 100644 --- a/.github/workflows/test-depot.yml +++ b/.github/workflows/test-depot.yml @@ -122,9 +122,6 @@ jobs: sleep $((attempt * 5)) done - - name: Run issue #952 regression guard - run: python3 tests/test_issue_952_socket_listener_recovery.py - - name: Run unit tests if: ${{ !inputs.skip_unit_tests }} run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index a8eeae97..7662a1ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,103 @@ All notable changes to cmux are documented here. +## [0.62.0] - 2026-03-07 + +### Added +- Markdown viewer panel with live file watching ([#883](https://github.com/manaflow-ai/cmux/pull/883)) +- Find-in-page (Cmd+F) for browser panels ([#837](https://github.com/manaflow-ai/cmux/issues/837), [#875](https://github.com/manaflow-ai/cmux/pull/875)) +- Keyboard copy mode for terminal scrollback with vi-style navigation ([#792](https://github.com/manaflow-ai/cmux/pull/792)) +- Custom notification sounds with file picker support ([#839](https://github.com/manaflow-ai/cmux/pull/839), [#869](https://github.com/manaflow-ai/cmux/pull/869)) +- Browser camera and microphone permission support ([#760](https://github.com/manaflow-ai/cmux/issues/760), [#913](https://github.com/manaflow-ai/cmux/pull/913)) +- Language setting for per-app locale override ([#886](https://github.com/manaflow-ai/cmux/pull/886)) +- Japanese localization ([#819](https://github.com/manaflow-ai/cmux/pull/819)) +- 16 new languages added to localization ([#895](https://github.com/manaflow-ai/cmux/pull/895)) +- Kagi as a search provider option ([#561](https://github.com/manaflow-ai/cmux/pull/561)) +- Open Folder command (Cmd+O) ([#656](https://github.com/manaflow-ai/cmux/pull/656)) +- Dark mode app icon for macOS Sequoia ([#702](https://github.com/manaflow-ai/cmux/pull/702)) +- Close other pane tabs with confirmation ([#475](https://github.com/manaflow-ai/cmux/pull/475)) +- Flash Focused Panel command palette action ([#638](https://github.com/manaflow-ai/cmux/pull/638)) +- Zoom/maximize focused pane in splits ([#634](https://github.com/manaflow-ai/cmux/pull/634)) +- `cmux tree` command for full CLI hierarchy view ([#592](https://github.com/manaflow-ai/cmux/pull/592)) +- Install or uninstall the `cmux` CLI from the command palette ([#626](https://github.com/manaflow-ai/cmux/pull/626)) +- Clipboard image paste in terminal with Cmd+V ([#562](https://github.com/manaflow-ai/cmux/pull/562), [#853](https://github.com/manaflow-ai/cmux/pull/853)) +- Middle-click X11-style selection paste in terminal ([#369](https://github.com/manaflow-ai/cmux/pull/369)) +- Honor Ghostty `background-opacity` across all cmux chrome ([#667](https://github.com/manaflow-ai/cmux/pull/667)) +- Setting to hide Cmd-hold shortcut hints ([#765](https://github.com/manaflow-ai/cmux/pull/765)) +- Focus-follows-mouse on terminal hover ([#519](https://github.com/manaflow-ai/cmux/pull/519)) +- Sidebar help menu in the footer ([#958](https://github.com/manaflow-ai/cmux/pull/958)) +- External URL bypass rules for the embedded browser ([#768](https://github.com/manaflow-ai/cmux/pull/768)) +- Telemetry opt-out setting ([#610](https://github.com/manaflow-ai/cmux/pull/610)) +- Browser automation docs page ([#622](https://github.com/manaflow-ai/cmux/pull/622)) + +### Changed +- Command palette search is now async and decoupled from typing for reduced lag +- Fuzzy matching improved with single-edit and omitted-character word matches +- Replaced keychain password storage with file-based storage ([#576](https://github.com/manaflow-ai/cmux/pull/576)) +- Fullscreen shortcut changed to Cmd+Ctrl+F, and Cmd+Enter also toggles fullscreen ([#530](https://github.com/manaflow-ai/cmux/pull/530)) +- Workspace rename shortcut Cmd+Shift+R now uses the command palette flow +- Renamed tab color to workspace color in user-facing strings ([#637](https://github.com/manaflow-ai/cmux/pull/637)) +- Feedback recipient changed to `feedback@manaflow.com` ([#1007](https://github.com/manaflow-ai/cmux/pull/1007)) +- Regenerated app icons from Icon Composer ([#1005](https://github.com/manaflow-ai/cmux/pull/1005)) +- Moved update logs into the Debug menu ([#1008](https://github.com/manaflow-ai/cmux/pull/1008)) + +### Fixed +- Frozen blank launch from session restore race condition ([#399](https://github.com/manaflow-ai/cmux/issues/399), [#565](https://github.com/manaflow-ai/cmux/pull/565)) +- Crash on launch from an exclusive access violation in drag-handle hit testing ([#490](https://github.com/manaflow-ai/cmux/issues/490)) +- Use-after-free in `ghostty_surface_refresh` after sleep/wake ([#432](https://github.com/manaflow-ai/cmux/issues/432), [#619](https://github.com/manaflow-ai/cmux/pull/619)) +- Startup SIGSEGV by pre-warming locale before `SentrySDK.start` ([#927](https://github.com/manaflow-ai/cmux/pull/927)) +- IME issues: Shift+Space toggle inserting a space ([#641](https://github.com/manaflow-ai/cmux/issues/641), [#670](https://github.com/manaflow-ai/cmux/pull/670)), Ctrl fast path blocking IME events, browser address bar Japanese IME ([#789](https://github.com/manaflow-ai/cmux/issues/789), [#867](https://github.com/manaflow-ai/cmux/pull/867)), and Cmd shortcuts during IME composition +- CLI socket autodiscovery for tagged sockets ([#832](https://github.com/manaflow-ai/cmux/pull/832)) +- Flaky CLI socket listener recovery ([#952](https://github.com/manaflow-ai/cmux/issues/952), [#954](https://github.com/manaflow-ai/cmux/pull/954)) +- Side-docked dev tools resize ([#712](https://github.com/manaflow-ai/cmux/pull/712)) +- Dvorak Cmd+C colliding with the notifications shortcut ([#762](https://github.com/manaflow-ai/cmux/pull/762)) +- Terminal drag hover overlay flicker +- Titlebar controls clipped at the bottom edge ([#1016](https://github.com/manaflow-ai/cmux/pull/1016)) +- Sidebar git branch recovery after sleep/wake and agent checkout ([#494](https://github.com/manaflow-ai/cmux/issues/494), [#671](https://github.com/manaflow-ai/cmux/pull/671), [#905](https://github.com/manaflow-ai/cmux/pull/905)) +- Browser portal routing, uploads, and click focus regressions ([#908](https://github.com/manaflow-ai/cmux/pull/908), [#961](https://github.com/manaflow-ai/cmux/pull/961)) +- Notification unread persistence on workspace focus +- Escape propagation when the command palette is visible ([#847](https://github.com/manaflow-ai/cmux/pull/847)) +- Cmd+Shift+Enter pane zoom regression in browser focus ([#826](https://github.com/manaflow-ai/cmux/pull/826)) +- Cross-window theme background after jump-to-unread ([#861](https://github.com/manaflow-ai/cmux/pull/861)) +- `window.open()` and `target=_blank` not opening in a new tab ([#693](https://github.com/manaflow-ai/cmux/pull/693)) +- Terminal wrap width for the overlay scrollbar ([#522](https://github.com/manaflow-ai/cmux/pull/522)) +- Orphaned child processes when closing workspace tabs ([#889](https://github.com/manaflow-ai/cmux/pull/889)) +- Cmd+F Escape passthrough into terminal ([#918](https://github.com/manaflow-ai/cmux/pull/918)) +- Terminal link opens staying in the source workspace ([#912](https://github.com/manaflow-ai/cmux/pull/912)) +- Ghost terminal surface rebind after close ([#808](https://github.com/manaflow-ai/cmux/pull/808)) +- Cmd+plus zoom handling on non-US keyboard layouts ([#680](https://github.com/manaflow-ai/cmux/pull/680)) +- Menubar icon invisible in light mode ([#741](https://github.com/manaflow-ai/cmux/pull/741)) +- Various drag-handle crash fixes and reentrancy guards +- Background workspace git metadata refresh after external checkout +- Markdown panel text click focus ([#991](https://github.com/manaflow-ai/cmux/pull/991)) +- Browser Cmd+F overlay clipping in portal mode ([#916](https://github.com/manaflow-ai/cmux/pull/916)) +- Voice dictation text insertion ([#857](https://github.com/manaflow-ai/cmux/pull/857)) +- Browser panel lifecycle after WebContent process termination ([#892](https://github.com/manaflow-ai/cmux/pull/892)) +- Typing lag reduction by hiding invisible views from the accessibility tree ([#862](https://github.com/manaflow-ai/cmux/pull/862)) + +### Thanks to 21 contributors! +- [@afxjzs](https://github.com/afxjzs) +- [@AI-per](https://github.com/AI-per) +- [@atani](https://github.com/atani) +- [@austinywang](https://github.com/austinywang) +- [@cheulyop](https://github.com/cheulyop) +- [@ConnorCallison](https://github.com/ConnorCallison) +- [@harukitosa](https://github.com/harukitosa) +- [@homanp](https://github.com/homanp) +- [@JLeeChan](https://github.com/JLeeChan) +- [@josemasri](https://github.com/josemasri) +- [@lawrencecchen](https://github.com/lawrencecchen) +- [@novarii](https://github.com/novarii) +- [@orkhanrz](https://github.com/orkhanrz) +- [@qianwan](https://github.com/qianwan) +- [@rjwittams](https://github.com/rjwittams) +- [@sminamot](https://github.com/sminamot) +- [@tmcarr](https://github.com/tmcarr) +- [@trydis](https://github.com/trydis) +- [@ukoasis](https://github.com/ukoasis) +- [@y-agatsuma](https://github.com/y-agatsuma) +- [@yasunogithub](https://github.com/yasunogithub) + ## [0.61.0] - 2026-02-25 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index d2cba8c8..2bc991b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,8 +133,11 @@ This makes it visible in the GitHub PR UI (Commits tab, check statuses) that the ## Test quality policy - Do not add tests that only verify source code text, method signatures, AST fragments, or grep-style patterns. +- Do not add tests that read checked-in metadata or project files such as `Resources/Info.plist`, `project.pbxproj`, `.xcconfig`, or source files only to assert that a key, string, plist entry, or snippet exists. - Tests must verify observable runtime behavior through executable paths (unit/integration/e2e/CLI), not implementation shape. +- For metadata changes, prefer verifying the built app bundle or the runtime behavior that depends on that metadata, not the checked-in source file. - If a behavior cannot be exercised end-to-end yet, add a small runtime seam or harness first, then test through that seam. +- If no meaningful behavioral or artifact-level test is practical, skip the fake regression test and state that explicitly. ## Socket command threading policy diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 44989796..02896822 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -6720,15 +6720,12 @@ struct CMUXCLI { } private func versionInfoFromProjectFile() -> [String: String]? { - guard let executable = currentExecutablePath(), !executable.isEmpty else { + guard let executableURL = resolvedExecutableURL() else { return nil } let fileManager = FileManager.default - var current = URL(fileURLWithPath: executable) - .resolvingSymlinksInPath() - .standardizedFileURL - .deletingLastPathComponent() + var current = executableURL.deletingLastPathComponent() while true { let projectFile = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj") @@ -6820,23 +6817,29 @@ struct CMUXCLI { } private func candidateInfoPlistURLs() -> [URL] { - guard let executable = currentExecutablePath(), !executable.isEmpty else { + guard let executableURL = resolvedExecutableURL() else { return [] } let fileManager = FileManager.default - let executableURL = URL(fileURLWithPath: executable) - .resolvingSymlinksInPath() - .standardizedFileURL var candidates: [URL] = [] + var seen: Set = [] + func appendIfExisting(_ url: URL) { + let path = url.path + guard !path.isEmpty else { return } + guard seen.insert(path).inserted else { return } + guard fileManager.fileExists(atPath: path) else { return } + candidates.append(url) + } + var current = executableURL.deletingLastPathComponent() while true { if current.pathExtension == "app" { - candidates.append(current.appendingPathComponent("Contents/Info.plist")) + appendIfExisting(current.appendingPathComponent("Contents/Info.plist")) } if current.lastPathComponent == "Contents" { - candidates.append(current.appendingPathComponent("Info.plist")) + appendIfExisting(current.appendingPathComponent("Info.plist")) } // Local dev fallback: resolve version from the repo's app Info.plist @@ -6845,7 +6848,7 @@ struct CMUXCLI { let repoInfo = current.appendingPathComponent("Resources/Info.plist") if fileManager.fileExists(atPath: projectMarker.path), fileManager.fileExists(atPath: repoInfo.path) { - candidates.append(repoInfo) + appendIfExisting(repoInfo) break } @@ -6856,30 +6859,31 @@ struct CMUXCLI { current = parent } + // If we already found an ancestor bundle or repo Info.plist, avoid scanning + // sibling app bundles. Large Resources directories can otherwise balloon RSS. + guard candidates.isEmpty else { + return candidates + } + let searchRoots = [ executableURL.deletingLastPathComponent(), executableURL.deletingLastPathComponent().deletingLastPathComponent() ] for root in searchRoots { - guard let entries = try? fileManager.contentsOfDirectory( + guard let entries = fileManager.enumerator( at: root, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants], + errorHandler: { _, _ in true } ) else { continue } - for entry in entries where entry.pathExtension == "app" { - candidates.append(entry.appendingPathComponent("Contents/Info.plist")) + for case let entry as URL in entries where entry.pathExtension == "app" { + appendIfExisting(entry.appendingPathComponent("Contents/Info.plist")) } } - var seen: Set = [] - return candidates.filter { url in - let path = url.path - guard !path.isEmpty else { return false } - guard seen.insert(path).inserted else { return false } - return fileManager.fileExists(atPath: path) - } + return candidates } private func currentExecutablePath() -> String? { @@ -6897,6 +6901,20 @@ struct CMUXCLI { return Bundle.main.executableURL?.path ?? args.first } + private func resolvedExecutableURL() -> URL? { + guard let executable = currentExecutablePath(), !executable.isEmpty else { + return nil + } + + let expanded = (executable as NSString).expandingTildeInPath + if let resolvedPath = realpath(expanded, nil) { + defer { free(resolvedPath) } + return URL(fileURLWithPath: String(cString: resolvedPath)).standardizedFileURL + } + + return URL(fileURLWithPath: expanded).standardizedFileURL + } + private func usage() -> String { return """ cmux - control cmux via Unix socket diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 444419f2..a975a0e7 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -798,7 +798,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 73; + CURRENT_PROJECT_VERSION = 74; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -807,7 +807,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.61.0; + MARKETING_VERSION = 0.62.0; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -837,7 +837,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 73; + CURRENT_PROJECT_VERSION = 74; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -846,7 +846,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.61.0; + MARKETING_VERSION = 0.62.0; OTHER_LDFLAGS = ( "-lc++", "-framework", @@ -913,10 +913,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 73; + CURRENT_PROJECT_VERSION = 74; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.61.0; + MARKETING_VERSION = 0.62.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -930,10 +930,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 73; + CURRENT_PROJECT_VERSION = 74; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.61.0; + MARKETING_VERSION = 0.62.0; ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -947,10 +947,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 73; + CURRENT_PROJECT_VERSION = 74; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.61.0; + MARKETING_VERSION = 0.62.0; ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -966,10 +966,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 73; + CURRENT_PROJECT_VERSION = 74; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 0.61.0; + MARKETING_VERSION = 0.62.0; ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Resources/Info.plist b/Resources/Info.plist index c2badb5d..00d9fa86 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -30,6 +30,22 @@ A program running within cmux would like to use your microphone. NSCameraUsageDescription A program running within cmux would like to use your camera. + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + $(PRODUCT_BUNDLE_IDENTIFIER).web + LSHandlerRank + Default + CFBundleURLSchemes + + http + https + + + NSPrincipalClass NSApplication NSServices diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 97d90f6a..97151a08 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -1350,6 +1350,8 @@ final class WindowBrowserSlotView: NSView { private let paneDropTargetView = BrowserPaneDropTargetView(frame: .zero) private let dropZoneOverlayView = BrowserDropZoneOverlayView(frame: .zero) private var searchOverlayHostingView: NSHostingView? + private weak var hostedWebView: WKWebView? + private var hostedWebViewConstraints: [NSLayoutConstraint] = [] private var forwardedDropZone: DropZone? private var portalDragDropZone: DropZone? private var displayedDropZone: DropZone? @@ -1460,6 +1462,34 @@ final class WindowBrowserSlotView: NSView { searchOverlayHostingView = overlay } + func pinHostedWebView(_ webView: WKWebView) { + guard webView.superview === self else { return } + + let needsNewConstraints = + hostedWebView !== webView || + hostedWebViewConstraints.isEmpty || + webView.translatesAutoresizingMaskIntoConstraints + guard needsNewConstraints else { + needsLayout = true + layoutSubtreeIfNeeded() + return + } + + NSLayoutConstraint.deactivate(hostedWebViewConstraints) + hostedWebView = webView + webView.translatesAutoresizingMaskIntoConstraints = false + webView.autoresizingMask = [] + hostedWebViewConstraints = [ + webView.topAnchor.constraint(equalTo: topAnchor), + webView.bottomAnchor.constraint(equalTo: bottomAnchor), + webView.leadingAnchor.constraint(equalTo: leadingAnchor), + webView.trailingAnchor.constraint(equalTo: trailingAnchor), + ] + NSLayoutConstraint.activate(hostedWebViewConstraints) + needsLayout = true + layoutSubtreeIfNeeded() + } + func effectivePaneTopChromeHeight() -> CGFloat { paneTopChromeHeight } @@ -2241,11 +2271,11 @@ final class WindowBrowserPortal: NSObject { } else { containerView.addSubview(webView, positioned: .above, relativeTo: nil) } - webView.translatesAutoresizingMaskIntoConstraints = true - webView.autoresizingMask = [.width, .height] - webView.frame = containerView.bounds + containerView.pinHostedWebView(webView) webView.needsLayout = true webView.layoutSubtreeIfNeeded() + } else { + containerView.pinHostedWebView(webView) } if containerView.superview !== hostView { @@ -2496,10 +2526,10 @@ final class WindowBrowserPortal: NSObject { } else { containerView.addSubview(webView, positioned: .above, relativeTo: nil) } - webView.translatesAutoresizingMaskIntoConstraints = true - webView.autoresizingMask = [.width, .height] - webView.frame = containerView.bounds + containerView.pinHostedWebView(webView) refreshReasons.append("syncAttachWebView") + } else { + containerView.pinHostedWebView(webView) } _ = synchronizeHostFrameToReference() @@ -2626,12 +2656,23 @@ final class WindowBrowserPortal: NSObject { } #endif if shouldPreserveVisibleOnTransientGeometry { + let hasExistingVisibleFrame = + oldFrame.width > 1 && + oldFrame.height > 1 && + containerView.bounds.width > 1 && + containerView.bounds.height > 1 #if DEBUG dlog( "browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " + - "reason=\(transientRecoveryReason ?? "unknown") frame=\(browserPortalDebugFrame(containerView.frame))" + "reason=\(transientRecoveryReason ?? "unknown") frame=\(browserPortalDebugFrame(containerView.frame)) " + + "keepFrame=\(hasExistingVisibleFrame ? 1 : 0)" ) #endif + if hasExistingVisibleFrame { + containerView.setDropZoneOverlay(zone: nil) + containerView.setPaneDropContext(nil) + return + } } if !Self.rectApproximatelyEqual(oldFrame, targetFrame) { CATransaction.begin() @@ -2760,7 +2801,10 @@ final class WindowBrowserPortal: NSObject { guard entry.webView != nil else { return webViewId } guard let container = entry.containerView else { return webViewId } guard let anchor = entry.anchorView else { - return entry.visibleInUI ? nil : webViewId + // Workspace switching hides retiring browser portals before SwiftUI unmounts + // their anchor views. Keep the hidden WKWebView/slot alive so switching back + // can rebind the existing view instead of forcing a full WebKit reload. + return nil } if container.superview == nil || !container.isDescendant(of: hostView) { return webViewId @@ -2770,7 +2814,10 @@ final class WindowBrowserPortal: NSObject { anchor.superview == nil || (installedReferenceView.map { !anchor.isDescendant(of: $0) } ?? false) if anchorInvalidForCurrentHost { - return entry.visibleInUI ? nil : webViewId + // Hidden browser portals can legitimately be off-tree between workspace + // deactivation and the next rebind. Preserve them until an explicit detach + // (panel close, window teardown, or web view replacement) says otherwise. + return nil } return nil } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index a8852325..6248cd0d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9816,6 +9816,7 @@ private struct TabItemView: View { let modifiers = NSEvent.modifierFlags let isCommand = modifiers.contains(.command) let isShift = modifiers.contains(.shift) + let wasSelected = tabManager.selectedTabId == tab.id if isShift, let lastIndex = lastSidebarSelectionIndex { let lower = min(lastIndex, index) @@ -9838,6 +9839,12 @@ private struct TabItemView: View { lastSidebarSelectionIndex = index tabManager.selectTab(tab) + if wasSelected, !isCommand, !isShift { + tabManager.dismissNotificationOnDirectInteraction( + tabId: tab.id, + surfaceId: tabManager.focusedSurfaceId(for: tab.id) + ) + } selection = .tabs } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 146d9a74..88a7f314 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1028,9 +1028,197 @@ class GhosttyApp { loadReleaseAppSupportGhosttyConfigIfNeeded(config) loadLegacyGhosttyConfigIfNeeded(config) ghostty_config_load_recursive_files(config) + loadCJKFontFallbackIfNeeded(config) ghostty_config_finalize(config) } + /// When the user has not configured `font-codepoint-map` for CJK ranges, + /// Ghostty's `CTFontCollection` scoring may pick an inappropriate fallback + /// font for Hiragana, Katakana, and CJK symbols. The scoring prioritizes + /// monospace fonts, so decorative fonts with monospace attributes (e.g. + /// AB_appare from Adobe CC, or LingWai) can be selected depending on what + /// is installed. This injects a sensible default based on the system's + /// preferred languages. + /// + /// See: https://github.com/manaflow-ai/cmux/pull/1017 + private func loadCJKFontFallbackIfNeeded(_ config: ghostty_config_t) { + if Self.userConfigContainsCJKCodepointMap() { return } + + guard let mappings = Self.cjkFontMappings() else { return } + + let lines = mappings.map { range, font in + "font-codepoint-map = \(range)=\(font)" + }.joined(separator: "\n") + + let tmpURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-cjk-font-fallback-\(UUID().uuidString).conf") + do { + try lines.write(to: tmpURL, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: tmpURL) } + tmpURL.path.withCString { path in + ghostty_config_load_file(config, path) + } + } catch { + #if DEBUG + Self.initLog("failed to write CJK font fallback config: \(error)") + #endif + } + } + + /// Unicode ranges shared by all CJK languages (Han ideographs, symbols, fullwidth forms). + private static let sharedCJKRanges = [ + "U+3000-U+303F", // CJK Symbols and Punctuation + "U+4E00-U+9FFF", // CJK Unified Ideographs + "U+F900-U+FAFF", // CJK Compatibility Ideographs + "U+FF00-U+FFEF", // Halfwidth and Fullwidth Forms + "U+3400-U+4DBF", // CJK Unified Ideographs Extension A + ] + + /// Unicode ranges specific to Japanese (kana). + private static let japaneseRanges = [ + "U+3040-U+309F", // Hiragana + "U+30A0-U+30FF", // Katakana + ] + + /// Unicode ranges specific to Korean (Hangul). + private static let koreanRanges = [ + "U+AC00-U+D7AF", // Hangul Syllables + "U+1100-U+11FF", // Hangul Jamo + ] + + /// Returns (range, font) pairs for CJK font fallback based on the system's + /// preferred languages, or nil if no CJK language is detected. Each language + /// only maps its own script ranges to avoid assigning glyphs to a font that + /// lacks coverage (e.g. Hangul to Hiragino Sans). + static func cjkFontMappings( + preferredLanguages: [String] = Locale.preferredLanguages + ) -> [(String, String)]? { + var mappings: [(String, String)] = [] + var coveredShared = false + + for lang in preferredLanguages { + let lower = lang.lowercased() + let font: String + var langRanges: [String] = [] + + if lower.hasPrefix("ja") { + font = "Hiragino Sans" + langRanges = japaneseRanges + } else if lower.hasPrefix("ko") { + font = "Apple SD Gothic Neo" + langRanges = koreanRanges + } else if lower.hasPrefix("zh-hant") || lower.hasPrefix("zh-tw") || lower.hasPrefix("zh-hk") { + font = "PingFang TC" + } else if lower.hasPrefix("zh") { + font = "PingFang SC" + } else { + continue + } + + if !coveredShared { + for range in sharedCJKRanges { + mappings.append((range, font)) + } + coveredShared = true + } + + for range in langRanges { + mappings.append((range, font)) + } + } + + return mappings.isEmpty ? nil : mappings + } + + /// Checks whether the user's Ghostty config files already contain + /// a `font-codepoint-map` entry covering CJK ranges. Also checks + /// application-support config paths that cmux may load at runtime. + static func userConfigContainsCJKCodepointMap( + configPaths: [String] = defaultCJKScanPaths() + ) -> Bool { + var visited = Set() + for rawPath in configPaths { + let path = NSString(string: rawPath).expandingTildeInPath + if Self.configFileContainsCodepointMap(atPath: path, visited: &visited) { + return true + } + } + return false + } + + /// Returns the default set of config paths to scan for existing + /// `font-codepoint-map` entries. Includes both the standard Ghostty + /// config locations and any app-support paths that cmux may load. + private static func defaultCJKScanPaths() -> [String] { + var paths = [ + "~/.config/ghostty/config", + "~/.config/ghostty/config.ghostty", + "~/Library/Application Support/com.mitchellh.ghostty/config", + "~/Library/Application Support/com.mitchellh.ghostty/config.ghostty", + ] + if let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first { + let releaseDir = appSupport.appendingPathComponent(releaseBundleIdentifier) + paths.append(releaseDir.appendingPathComponent("config").path) + paths.append(releaseDir.appendingPathComponent("config.ghostty").path) + + if let bundleId = Bundle.main.bundleIdentifier, bundleId != releaseBundleIdentifier { + let currentDir = appSupport.appendingPathComponent(bundleId) + paths.append(currentDir.appendingPathComponent("config").path) + paths.append(currentDir.appendingPathComponent("config.ghostty").path) + } + } + return paths + } + + /// Scans a single config file (and any files it includes) for + /// `font-codepoint-map` entries. Tracks visited paths to prevent + /// infinite recursion on cyclic includes. + private static func configFileContainsCodepointMap( + atPath path: String, + visited: inout Set + ) -> Bool { + let resolved = (path as NSString).standardizingPath + guard !visited.contains(resolved) else { return false } + visited.insert(resolved) + + guard let contents = try? String(contentsOfFile: resolved, encoding: .utf8) else { + return false + } + let parentDir = (resolved as NSString).deletingLastPathComponent + + for line in contents.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("#") { continue } + if trimmed.hasPrefix("font-codepoint-map") { + return true + } + if trimmed.hasPrefix("config-file") { + let parts = trimmed.split(separator: "=", maxSplits: 1) + if parts.count == 2 { + var includePath = parts[1] + .trimmingCharacters(in: .whitespaces) + // Ghostty supports optional includes with a trailing '?' + if includePath.hasSuffix("?") { + includePath.removeLast() + } + includePath = includePath + .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + let expanded = NSString(string: includePath).expandingTildeInPath + let absolute = (expanded as NSString).isAbsolutePath + ? expanded + : (parentDir as NSString).appendingPathComponent(expanded) + if configFileContainsCodepointMap(atPath: absolute, visited: &visited) { + return true + } + } + } + } + return false + } + static func shouldLoadLegacyGhosttyConfig( newConfigFileSize: Int?, legacyConfigFileSize: Int? @@ -2080,8 +2268,14 @@ final class TerminalSurface: Identifiable, ObservableObject { case closing case closed } + private struct PortalHostLease { + let hostId: ObjectIdentifier + let inWindow: Bool + let area: CGFloat + } private var portalLifecycleState: PortalLifecycleState = .live private var portalLifecycleGeneration: UInt64 = 1 + private var activePortalHostLease: PortalHostLease? @Published var searchState: SearchState? = nil { didSet { if let searchState { @@ -2173,6 +2367,90 @@ final class TerminalSurface: Identifiable, ObservableObject { return true } + private static let portalHostAreaThreshold: CGFloat = 4 + private static let portalHostReplacementAreaGainRatio: CGFloat = 1.2 + + private static func portalHostArea(for bounds: CGRect) -> CGFloat { + max(0, bounds.width) * max(0, bounds.height) + } + + private static func portalHostIsUsable(_ lease: PortalHostLease) -> Bool { + lease.inWindow && lease.area > portalHostAreaThreshold + } + + func claimPortalHost( + hostId: ObjectIdentifier, + inWindow: Bool, + bounds: CGRect, + reason: String + ) -> Bool { + let next = PortalHostLease( + hostId: hostId, + inWindow: inWindow, + area: Self.portalHostArea(for: bounds) + ) + + if let current = activePortalHostLease { + if current.hostId == hostId { + activePortalHostLease = next + return true + } + + let currentUsable = Self.portalHostIsUsable(current) + let nextUsable = Self.portalHostIsUsable(next) + let shouldReplace = + !currentUsable || + (nextUsable && next.area > (current.area * Self.portalHostReplacementAreaGainRatio)) + + if shouldReplace { +#if DEBUG + dlog( + "terminal.portal.host.claim surface=\(id.uuidString.prefix(5)) " + + "reason=\(reason) host=\(hostId) inWin=\(inWindow ? 1 : 0) " + + "size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + + "replacingHost=\(current.hostId) replacingInWin=\(current.inWindow ? 1 : 0) " + + "replacingArea=\(String(format: "%.1f", current.area))" + ) +#endif + activePortalHostLease = next + return true + } + +#if DEBUG + dlog( + "terminal.portal.host.skip surface=\(id.uuidString.prefix(5)) " + + "reason=\(reason) host=\(hostId) inWin=\(inWindow ? 1 : 0) " + + "size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + + "ownerHost=\(current.hostId) ownerInWin=\(current.inWindow ? 1 : 0) " + + "ownerArea=\(String(format: "%.1f", current.area))" + ) +#endif + return false + } + + activePortalHostLease = next +#if DEBUG + dlog( + "terminal.portal.host.claim surface=\(id.uuidString.prefix(5)) " + + "reason=\(reason) host=\(hostId) inWin=\(inWindow ? 1 : 0) " + + "size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) replacingHost=nil" + ) +#endif + return true + } + + func releasePortalHostIfOwned(hostId: ObjectIdentifier, reason: String) { + guard let current = activePortalHostLease, current.hostId == hostId else { return } + activePortalHostLease = nil +#if DEBUG + dlog( + "terminal.portal.host.release surface=\(id.uuidString.prefix(5)) " + + "reason=\(reason) host=\(hostId) inWin=\(current.inWindow ? 1 : 0) " + + "area=\(String(format: "%.1f", current.area))" + ) +#endif + } + func beginPortalCloseLifecycle(reason: String) { guard portalLifecycleState != .closed else { return } guard portalLifecycleState != .closing else { return } @@ -2931,6 +3209,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { .fileURL, .URL ] + private static let tabTransferPasteboardType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") + private static let sidebarTabReorderPasteboardType = NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder") private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t" fileprivate static func focusLog(_ message: String) { @@ -3286,6 +3566,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return currentBounds } + private static func hasActiveTabDragPasteboard() -> Bool { + let types = NSPasteboard(name: .drag).types ?? [] + return types.contains(tabTransferPasteboardType) || types.contains(sidebarTabReorderPasteboardType) + } + @discardableResult private func updateSurfaceSize(size: CGSize? = nil) -> Bool { guard let terminalSurface = terminalSurface else { return false } @@ -3305,6 +3590,20 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return false } pendingSurfaceSize = size + guard !Self.hasActiveTabDragPasteboard() else { +#if DEBUG + let signature = "tabDrag-\(Int(size.width.rounded()))x\(Int(size.height.rounded()))" + if lastSizeSkipSignature != signature { + dlog( + "surface.size.defer surface=\(terminalSurface.id.uuidString.prefix(5)) reason=tabDrag " + + "size=\(String(format: "%.1fx%.1f", size.width, size.height)) " + + "inWindow=\(window != nil ? 1 : 0)" + ) + lastSizeSkipSignature = signature + } +#endif + return false + } guard let window else { #if DEBUG let signature = "noWindow-\(Int(size.width))x\(Int(size.height))" @@ -4480,6 +4779,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { dlog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))] clickCount=\(event.clickCount) point=(\(String(format: "%.0f", debugPoint.x)),\(String(format: "%.0f", debugPoint.y)))") #endif window?.makeFirstResponder(self) + if let terminalSurface { + AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction( + tabId: terminalSurface.tabId, + surfaceId: terminalSurface.id + ) + } guard let surface = surface else { return } let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) @@ -4950,6 +5255,16 @@ private final class GhosttyPassthroughVisualEffectView: NSVisualEffectView { } final class GhosttySurfaceScrollView: NSView { + enum FlashStyle { + case standardFocus + case notificationDismiss + } + + private enum NotificationRingMetrics { + static let inset: CGFloat = 2 + static let cornerRadius: CGFloat = 6 + } + private let backgroundView: NSView private let scrollView: GhosttyScrollView private let documentView: NSView @@ -5120,6 +5435,13 @@ final class GhosttySurfaceScrollView: NSView { ) } + func releaseOwnedPortalHost(hostId: ObjectIdentifier, reason: String) { + surfaceView.terminalSurface?.releasePortalHostIfOwned( + hostId: hostId, + reason: reason + ) + } + init(surfaceView: GhosttyNSView) { self.surfaceView = surfaceView backgroundView = NSView(frame: .zero) @@ -5419,7 +5741,7 @@ final class GhosttySurfaceScrollView: NSView { _ = setFrameIfNeeded(notificationRingOverlayView, to: bounds) _ = setFrameIfNeeded(flashOverlayView, to: bounds) updateNotificationRingPath() - updateFlashPath() + updateFlashPath(style: .standardFocus) synchronizeScrollView() synchronizeSurfaceView() let didCoreSurfaceChange = synchronizeCoreSurface() @@ -5865,7 +6187,7 @@ final class GhosttySurfaceScrollView: NSView { } #endif - func triggerFlash() { + func triggerFlash(style: FlashStyle = .standardFocus) { DispatchQueue.main.async { [weak self] in guard let self else { return } #if DEBUG @@ -5873,7 +6195,7 @@ final class GhosttySurfaceScrollView: NSView { Self.recordFlash(for: surfaceId) } #endif - self.updateFlashPath() + self.updateFlashPath(style: style) self.flashLayer.removeAllAnimations() self.flashLayer.opacity = 0 let animation = CAKeyframeAnimation(keyPath: "opacity") @@ -6615,17 +6937,27 @@ final class GhosttySurfaceScrollView: NSView { updateOverlayRingPath( layer: notificationRingLayer, bounds: notificationRingOverlayView.bounds, - inset: 2, - radius: 6 + inset: NotificationRingMetrics.inset, + radius: NotificationRingMetrics.cornerRadius ) } - private func updateFlashPath() { + private func updateFlashPath(style: FlashStyle) { + let inset: CGFloat + let radius: CGFloat + switch style { + case .standardFocus: + inset = CGFloat(FocusFlashPattern.ringInset) + radius = CGFloat(FocusFlashPattern.ringCornerRadius) + case .notificationDismiss: + inset = NotificationRingMetrics.inset + radius = NotificationRingMetrics.cornerRadius + } updateOverlayRingPath( layer: flashLayer, bounds: flashOverlayView.bounds, - inset: CGFloat(FocusFlashPattern.ringInset), - radius: CGFloat(FocusFlashPattern.ringCornerRadius) + inset: inset, + radius: radius ) } @@ -7029,18 +7361,30 @@ struct GhosttyTerminalView: NSViewRepresentable { } #endif + let hostContainer = nsView as? HostContainerView + let hostOwnsPortalNow = hostContainer.map { host in + terminalSurface.claimPortalHost( + hostId: ObjectIdentifier(host), + inWindow: host.window != nil, + bounds: host.bounds, + reason: "update" + ) + } ?? true + // Keep the surface lifecycle and handlers updated even if we defer re-parenting. hostedView.attachSurface(terminalSurface) - hostedView.setInactiveOverlay( - color: inactiveOverlayColor, - opacity: CGFloat(inactiveOverlayOpacity), - visible: showsInactiveOverlay - ) - hostedView.setNotificationRing(visible: showsUnreadNotificationRing) - hostedView.setSearchOverlay(searchState: searchState) - hostedView.syncKeyStateIndicator(text: terminalSurface.currentKeyStateIndicatorText) hostedView.setFocusHandler { onFocus?(terminalSurface.id) } hostedView.setTriggerFlashHandler(onTriggerFlash) + if hostOwnsPortalNow { + hostedView.setInactiveOverlay( + color: inactiveOverlayColor, + opacity: CGFloat(inactiveOverlayOpacity), + visible: showsInactiveOverlay + ) + hostedView.setNotificationRing(visible: showsUnreadNotificationRing) + hostedView.setSearchOverlay(searchState: searchState) + hostedView.syncKeyStateIndicator(text: terminalSurface.currentKeyStateIndicatorText) + } let portalExpectedSurfaceId = terminalSurface.id let portalExpectedGeneration = terminalSurface.portalBindingGeneration() let forwardedDropZone = isVisibleInUI ? paneDropZone : nil @@ -7063,16 +7407,23 @@ struct GhosttyTerminalView: NSViewRepresentable { ) } #endif - hostedView.setDropZoneOverlay(zone: forwardedDropZone) + if hostOwnsPortalNow { + hostedView.setDropZoneOverlay(zone: forwardedDropZone) + } coordinator.attachGeneration += 1 let generation = coordinator.attachGeneration - let hostContainer = nsView as? HostContainerView if let host = hostContainer { host.onDidMoveToWindow = { [weak host, weak hostedView, weak coordinator] in guard let host, let hostedView, let coordinator else { return } guard coordinator.attachGeneration == generation else { return } + guard terminalSurface.claimPortalHost( + hostId: ObjectIdentifier(host), + inWindow: host.window != nil, + bounds: host.bounds, + reason: "didMoveToWindow" + ) else { return } guard host.window != nil else { return } TerminalWindowPortalRegistry.bind( hostedView: hostedView, @@ -7091,9 +7442,16 @@ struct GhosttyTerminalView: NSViewRepresentable { host.onGeometryChanged = { [weak host, weak hostedView, weak coordinator] in guard let host, let hostedView, let coordinator else { return } guard coordinator.attachGeneration == generation else { return } - guard coordinator.lastBoundHostId == ObjectIdentifier(host) else { return } + guard terminalSurface.claimPortalHost( + hostId: ObjectIdentifier(host), + inWindow: host.window != nil, + bounds: host.bounds, + reason: "geometryChanged" + ) else { return } + let hostId = ObjectIdentifier(host) if host.window != nil, - !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) { + (coordinator.lastBoundHostId != hostId || + !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)) { #if DEBUG dlog( "ws.hostState.rebindOnGeometry surface=\(terminalSurface.id.uuidString.prefix(5)) " + @@ -7109,7 +7467,7 @@ struct GhosttyTerminalView: NSViewRepresentable { expectedSurfaceId: portalExpectedSurfaceId, expectedGeneration: portalExpectedGeneration ) - coordinator.lastBoundHostId = ObjectIdentifier(host) + coordinator.lastBoundHostId = hostId hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) hostedView.setActive(coordinator.desiredIsActive) hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) @@ -7118,7 +7476,7 @@ struct GhosttyTerminalView: NSViewRepresentable { coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision } - if host.window != nil { + if host.window != nil, hostOwnsPortalNow { let hostId = ObjectIdentifier(host) let geometryRevision = host.geometryRevision let portalEntryMissing = !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) @@ -7153,7 +7511,7 @@ struct GhosttyTerminalView: NSViewRepresentable { TerminalWindowPortalRegistry.synchronizeForAnchor(host) coordinator.lastSynchronizedHostGeometryRevision = geometryRevision } - } else { + } else if hostOwnsPortalNow { // Bind is deferred until host moves into a window. Update the // existing portal entry's visibleInUI now so that any portal sync // that runs before the deferred bind completes won't hide the view. @@ -7178,7 +7536,7 @@ struct GhosttyTerminalView: NSViewRepresentable { let isBoundToCurrentHost = hostContainer.map { host in TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) } ?? true - let shouldApplyImmediateHostedState = Self.shouldApplyImmediateHostedStateUpdate( + let shouldApplyImmediateHostedState = hostOwnsPortalNow && Self.shouldApplyImmediateHostedStateUpdate( hostedViewHasSuperview: hostedView.superview != nil, isBoundToCurrentHost: isBoundToCurrentHost ) @@ -7193,7 +7551,8 @@ struct GhosttyTerminalView: NSViewRepresentable { if desiredStateChanged { dlog( "ws.hostState.deferApply surface=\(terminalSurface.id.uuidString.prefix(5)) " + - "reason=staleHostBinding hostWindow=\(hostWindowAttached ? 1 : 0) " + + "reason=\(hostOwnsPortalNow ? "staleHostBinding" : "hostOwnershipRejected") " + + "hostWindow=\(hostWindowAttached ? 1 : 0) " + "boundToCurrent=\(isBoundToCurrentHost ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0) " + "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)" ) @@ -7231,6 +7590,10 @@ struct GhosttyTerminalView: NSViewRepresentable { if let host = nsView as? HostContainerView { host.onDidMoveToWindow = nil host.onGeometryChanged = nil + hostedView?.releaseOwnedPortalHost( + hostId: ObjectIdentifier(host), + reason: "dismantle" + ) } // SwiftUI can transiently dismantle/rebuild NSViewRepresentable instances during split diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 9a8d76c2..aaad9f23 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1714,6 +1714,13 @@ final class BrowserPanel: Panel, ObservableObject { } private var searchNeedleCancellable: AnyCancellable? let portalAnchorView = BrowserPortalAnchorView(frame: .zero) + private struct PortalHostLease { + let hostId: ObjectIdentifier + let paneId: UUID + let inWindow: Bool + let area: CGFloat + } + private var activePortalHostLease: PortalHostLease? private var webViewCancellables = Set() private var navigationDelegate: BrowserNavigationDelegate? private var uiDelegate: BrowserUIDelegate? @@ -1755,6 +1762,96 @@ final class BrowserPanel: Panel, ObservableObject { return String(localized: "browser.newTab", defaultValue: "New tab") } + private static let portalHostAreaThreshold: CGFloat = 4 + private static let portalHostReplacementAreaGainRatio: CGFloat = 1.2 + + private static func portalHostArea(for bounds: CGRect) -> CGFloat { + max(0, bounds.width) * max(0, bounds.height) + } + + private static func portalHostIsUsable(_ lease: PortalHostLease) -> Bool { + lease.inWindow && lease.area > portalHostAreaThreshold + } + + func claimPortalHost( + hostId: ObjectIdentifier, + paneId: PaneID, + inWindow: Bool, + bounds: CGRect, + reason: String + ) -> Bool { + let next = PortalHostLease( + hostId: hostId, + paneId: paneId.id, + inWindow: inWindow, + area: Self.portalHostArea(for: bounds) + ) + + if let current = activePortalHostLease { + if current.hostId == hostId { + activePortalHostLease = next + return true + } + + let currentUsable = Self.portalHostIsUsable(current) + let nextUsable = Self.portalHostIsUsable(next) + let shouldReplace = + current.paneId != paneId.id || + !currentUsable || + (nextUsable && next.area > (current.area * Self.portalHostReplacementAreaGainRatio)) + + if shouldReplace { +#if DEBUG + dlog( + "browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + + "inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + + "replacingHost=\(current.hostId) replacingPane=\(current.paneId.uuidString.prefix(5)) " + + "replacingInWin=\(current.inWindow ? 1 : 0) replacingArea=\(String(format: "%.1f", current.area))" + ) +#endif + activePortalHostLease = next + return true + } + +#if DEBUG + dlog( + "browser.portal.host.skip panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + + "inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + + "ownerHost=\(current.hostId) ownerPane=\(current.paneId.uuidString.prefix(5)) " + + "ownerInWin=\(current.inWindow ? 1 : 0) ownerArea=\(String(format: "%.1f", current.area))" + ) +#endif + return false + } + + activePortalHostLease = next +#if DEBUG + dlog( + "browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " + + "inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + + "replacingHost=nil" + ) +#endif + return true + } + + @discardableResult + func releasePortalHostIfOwned(hostId: ObjectIdentifier, reason: String) -> Bool { + guard let current = activePortalHostLease, current.hostId == hostId else { return false } + activePortalHostLease = nil +#if DEBUG + dlog( + "browser.portal.host.release panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) host=\(hostId) pane=\(current.paneId.uuidString.prefix(5)) " + + "inWin=\(current.inWindow ? 1 : 0) area=\(String(format: "%.1f", current.area))" + ) +#endif + return true + } + var displayIcon: String? { "globe" } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 05dfaf2e..98ae7485 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -206,6 +206,7 @@ func resolvedBrowserOmnibarPillBackgroundColor( /// View for rendering a browser panel with address bar struct BrowserPanelView: View { @ObservedObject var panel: BrowserPanel + let paneId: PaneID let isFocused: Bool let isVisibleInUI: Bool let portalPriority: Int @@ -312,13 +313,23 @@ struct BrowserPanelView: View { ) } + private var isCurrentPaneOwner: Bool { + guard let workspace = AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == panel.workspaceId }), + let currentPaneId = workspace.paneId(forPanelId: panel.id) else { + return false + } + return currentPaneId.id == paneId.id + } + var body: some View { // Layering contract: browser Cmd+F UI is mounted in the portal-hosted AppKit // container. Rendering it here can hide it behind the portal-hosted WKWebView. VStack(spacing: 0) { addressBar + .fixedSize(horizontal: false, vertical: true) webView } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .overlay { // Keep Cmd+F usable when the browser is still in the empty new-tab // state (no WKWebView mounted yet). WebView-backed cases are hosted @@ -795,7 +806,8 @@ struct BrowserPanelView: View { if panel.shouldRenderWebView { WebViewRepresentable( panel: panel, - shouldAttachWebView: isVisibleInUI, + paneId: paneId, + shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner, shouldFocusWebView: isFocused && !addressBarFocused, isPanelFocused: isFocused, portalZPriority: portalPriority, @@ -813,8 +825,9 @@ struct BrowserPanelView: View { ) .accessibilityIdentifier("BrowserWebViewSurface") // Keep the host stable for normal pane churn, but force a remount when - // BrowserPanel replaces its underlying WKWebView after process termination. - .id(panel.webViewInstanceID) + // BrowserPanel replaces its underlying WKWebView after process termination + // or when the browser moves to a different Bonsplit pane host. + .id("\(panel.webViewInstanceID.uuidString)-\(paneId.id.uuidString)") .contentShape(Rectangle()) .accessibilityIdentifier(browserContentAccessibilityIdentifier) .simultaneousGesture(TapGesture().onEnded { @@ -839,6 +852,8 @@ struct BrowserPanelView: View { } } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .layoutPriority(1) .zIndex(0) } @@ -3502,6 +3517,7 @@ private struct OmnibarSuggestionsView: View { /// NSViewRepresentable wrapper for WKWebView struct WebViewRepresentable: NSViewRepresentable { let panel: BrowserPanel + let paneId: PaneID let shouldAttachWebView: Bool let shouldFocusWebView: Bool let isPanelFocused: Bool @@ -4271,35 +4287,96 @@ struct WebViewRepresentable: NSViewRepresentable { guard host.window != nil else { return } if anchorView.superview !== host { anchorView.removeFromSuperview() - anchorView.frame = host.bounds - anchorView.translatesAutoresizingMaskIntoConstraints = true - anchorView.autoresizingMask = [.width, .height] + anchorView.translatesAutoresizingMaskIntoConstraints = false host.addSubview(anchorView) - } else if anchorView.frame != host.bounds { - anchorView.frame = host.bounds + NSLayoutConstraint.activate([ + anchorView.topAnchor.constraint(equalTo: host.topAnchor), + anchorView.bottomAnchor.constraint(equalTo: host.bottomAnchor), + anchorView.leadingAnchor.constraint(equalTo: host.leadingAnchor), + anchorView.trailingAnchor.constraint(equalTo: host.trailingAnchor), + ]) + } else if anchorView.translatesAutoresizingMaskIntoConstraints { + anchorView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + anchorView.topAnchor.constraint(equalTo: host.topAnchor), + anchorView.bottomAnchor.constraint(equalTo: host.bottomAnchor), + anchorView.leadingAnchor.constraint(equalTo: host.leadingAnchor), + anchorView.trailingAnchor.constraint(equalTo: host.trailingAnchor), + ]) } + host.layoutSubtreeIfNeeded() } - private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) { - guard let host = nsView as? HostContainerView else { return } + private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool { + guard let host = nsView as? HostContainerView else { return false } let coordinator = context.coordinator + let paneDropContext = currentPaneDropContext() + let isCurrentPaneOwner = paneDropContext?.paneId.id == paneId.id + let hostId = ObjectIdentifier(host) let previousVisible = coordinator.desiredPortalVisibleInUI let previousZPriority = coordinator.desiredPortalZPriority - coordinator.desiredPortalVisibleInUI = shouldAttachWebView + coordinator.desiredPortalVisibleInUI = shouldAttachWebView && isCurrentPaneOwner coordinator.desiredPortalZPriority = portalZPriority coordinator.attachGeneration += 1 let generation = coordinator.attachGeneration - let paneDropContext = shouldAttachWebView ? currentPaneDropContext() : nil - let activeSearchOverlay = shouldAttachWebView ? searchOverlay : nil + let activePaneDropContext = coordinator.desiredPortalVisibleInUI ? paneDropContext : nil + let activeSearchOverlay = coordinator.desiredPortalVisibleInUI ? searchOverlay : nil let portalAnchorView = panel.portalAnchorView - if host.window != nil { + let portalHideReason = !isCurrentPaneOwner ? "lostPaneOwnership" : "hidden" + let didReleasePortalHost: Bool + if !shouldAttachWebView || !isCurrentPaneOwner { + didReleasePortalHost = panel.releasePortalHostIfOwned( + hostId: hostId, + reason: portalHideReason + ) + // Only the host that currently owns the portal is allowed to hide it. + // Older keep-alive hosts can still receive updates after a new owner binds. + if didReleasePortalHost { + BrowserWindowPortalRegistry.hide( + webView: webView, + source: "viewStateChanged.\(portalHideReason)" + ) + } + } else { + didReleasePortalHost = false + } + let portalHostAccepted = + shouldAttachWebView && + isCurrentPaneOwner && + panel.claimPortalHost( + hostId: hostId, + paneId: paneId, + inWindow: host.window != nil, + bounds: host.bounds, + reason: "update" + ) +#if DEBUG + if !isCurrentPaneOwner && (shouldAttachWebView || host.window != nil) { + dlog( + "browser.portal.owner.skip panel=\(panel.id.uuidString.prefix(5)) " + + "viewPane=\(paneId.id.uuidString.prefix(5)) " + + "currentPane=\(paneDropContext?.paneId.id.uuidString.prefix(5) ?? "nil") " + + "host=\(Self.objectID(host)) hostInWin=\(host.window != nil ? 1 : 0) " + + "released=\(didReleasePortalHost ? 1 : 0)" + ) + } +#endif + if host.window != nil, portalHostAccepted { Self.installPortalAnchorView(portalAnchorView, in: host) } - host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator, weak portalAnchorView] in - guard let host, let webView, let coordinator, let portalAnchorView else { return } + host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator, weak portalAnchorView, weak browserPanel = panel] in + guard let host, let webView, let coordinator, let portalAnchorView, let browserPanel else { return } guard coordinator.attachGeneration == generation else { return } + guard currentPaneDropContext()?.paneId.id == paneId.id else { return } + guard browserPanel.claimPortalHost( + hostId: ObjectIdentifier(host), + paneId: paneId, + inWindow: host.window != nil, + bounds: host.bounds, + reason: "didMoveToWindow" + ) else { return } guard host.window != nil else { return } Self.installPortalAnchorView(portalAnchorView, in: host) BrowserWindowPortalRegistry.bind( @@ -4312,18 +4389,26 @@ struct WebViewRepresentable: NSViewRepresentable { for: webView, height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0 ) - BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: paneDropContext) + BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: activePaneDropContext) BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) coordinator.lastPortalHostId = ObjectIdentifier(host) coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision } - host.onGeometryChanged = { [weak host, weak webView, weak coordinator, weak portalAnchorView] in - guard let host, let webView, let coordinator, let portalAnchorView else { return } + host.onGeometryChanged = { [weak host, weak webView, weak coordinator, weak portalAnchorView, weak browserPanel = panel] in + guard let host, let webView, let coordinator, let portalAnchorView, let browserPanel else { return } guard coordinator.attachGeneration == generation else { return } - guard coordinator.lastPortalHostId == ObjectIdentifier(host) else { return } + guard currentPaneDropContext()?.paneId.id == paneId.id else { return } + guard browserPanel.claimPortalHost( + hostId: ObjectIdentifier(host), + paneId: paneId, + inWindow: host.window != nil, + bounds: host.bounds, + reason: "geometryChanged" + ) else { return } guard host.window != nil else { return } + let hostId = ObjectIdentifier(host) Self.installPortalAnchorView(portalAnchorView, in: host) - if host.window != nil, + if coordinator.lastPortalHostId != hostId || !BrowserWindowPortalRegistry.isWebView(webView, boundTo: portalAnchorView) { BrowserWindowPortalRegistry.bind( webView: webView, @@ -4335,9 +4420,9 @@ struct WebViewRepresentable: NSViewRepresentable { for: webView, height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0 ) - BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: paneDropContext) + BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: activePaneDropContext) BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) - coordinator.lastPortalHostId = ObjectIdentifier(host) + coordinator.lastPortalHostId = hostId } BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView) coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision @@ -4349,8 +4434,7 @@ struct WebViewRepresentable: NSViewRepresentable { panel.syncDeveloperToolsPreferenceFromInspector() } - if host.window != nil { - let hostId = ObjectIdentifier(host) + if host.window != nil, portalHostAccepted { let geometryRevision = host.geometryRevision let portalEntryMissing = !BrowserWindowPortalRegistry.isWebView(webView, boundTo: portalAnchorView) let shouldBindNow = @@ -4372,7 +4456,7 @@ struct WebViewRepresentable: NSViewRepresentable { } BrowserWindowPortalRegistry.updatePaneTopChromeHeight( for: webView, - height: shouldAttachWebView ? paneTopChromeHeight : 0 + height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0 ) BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) if !shouldBindNow, @@ -4380,7 +4464,7 @@ struct WebViewRepresentable: NSViewRepresentable { BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView) coordinator.lastSynchronizedHostGeometryRevision = geometryRevision } - } else { + } else if portalHostAccepted { // Bind is deferred until host moves into a window. Keep the current // portal entry's desired state in sync so stale callbacks cannot keep // the previous anchor visible while this host is temporarily off-window. @@ -4391,19 +4475,21 @@ struct WebViewRepresentable: NSViewRepresentable { ) } - BrowserWindowPortalRegistry.updateDropZoneOverlay( - for: webView, - zone: shouldAttachWebView ? paneDropZone : nil - ) - BrowserWindowPortalRegistry.updatePaneTopChromeHeight( - for: webView, - height: shouldAttachWebView ? paneTopChromeHeight : 0 - ) - BrowserWindowPortalRegistry.updatePaneDropContext( - for: webView, - context: paneDropContext - ) - BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) + if portalHostAccepted { + BrowserWindowPortalRegistry.updateDropZoneOverlay( + for: webView, + zone: coordinator.desiredPortalVisibleInUI ? paneDropZone : nil + ) + BrowserWindowPortalRegistry.updatePaneTopChromeHeight( + for: webView, + height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0 + ) + BrowserWindowPortalRegistry.updatePaneDropContext( + for: webView, + context: activePaneDropContext + ) + BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) + } panel.restoreDeveloperToolsAfterAttachIfNeeded() @@ -4416,11 +4502,13 @@ struct WebViewRepresentable: NSViewRepresentable { details: Self.attachContext(webView: webView, host: host) ) #endif + return portalHostAccepted } func updateNSView(_ nsView: NSView, context: Context) { let webView = panel.webView let coordinator = context.coordinator + let isCurrentPaneOwner = currentPaneDropContext()?.paneId.id == paneId.id if let previousWebView = coordinator.webView, previousWebView !== webView { BrowserWindowPortalRegistry.detach(webView: previousWebView) coordinator.lastPortalHostId = nil @@ -4428,21 +4516,21 @@ struct WebViewRepresentable: NSViewRepresentable { } coordinator.panel = panel coordinator.webView = webView + + Self.clearPortalCallbacks(for: nsView) + let hostOwnsPortal = updateUsingWindowPortal(nsView, context: context, webView: webView) Self.applyWebViewFirstResponderPolicy( panel: panel, webView: webView, - isPanelFocused: isPanelFocused + isPanelFocused: isPanelFocused && isCurrentPaneOwner && hostOwnsPortal ) - Self.clearPortalCallbacks(for: nsView) - updateUsingWindowPortal(nsView, context: context, webView: webView) - Self.applyFocus( panel: panel, webView: webView, nsView: nsView, - shouldFocusWebView: shouldFocusWebView, - isPanelFocused: isPanelFocused + shouldFocusWebView: shouldFocusWebView && isCurrentPaneOwner && hostOwnsPortal, + isPanelFocused: isPanelFocused && isCurrentPaneOwner && hostOwnsPortal ) } @@ -4527,6 +4615,12 @@ struct WebViewRepresentable: NSViewRepresentable { static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.attachGeneration += 1 clearPortalCallbacks(for: nsView) + if let panel = coordinator.panel, let host = nsView as? HostContainerView { + panel.releasePortalHostIfOwned( + hostId: ObjectIdentifier(host), + reason: "dismantle" + ) + } guard let webView = coordinator.webView else { return } let panel = coordinator.panel diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index adec500f..fe5d87cf 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -1,9 +1,11 @@ import SwiftUI import Foundation +import Bonsplit /// View that renders the appropriate panel view based on panel type struct PanelContentView: View { let panel: any Panel + let paneId: PaneID let isFocused: Bool let isSelectedInPane: Bool let isVisibleInUI: Bool @@ -35,6 +37,7 @@ struct PanelContentView: View { if let browserPanel = panel as? BrowserPanel { BrowserPanelView( panel: browserPanel, + paneId: paneId, isFocused: isFocused, isVisibleInUI: isVisibleInUI, portalPriority: portalPriority, diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index d4bb68b3..7e863f5d 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -190,6 +190,10 @@ final class TerminalPanel: Panel, ObservableObject { hostedView.triggerFlash() } + func triggerNotificationDismissFlash() { + hostedView.triggerFlash(style: .notificationDismiss) + } + func applyWindowBackgroundIfActive() { surface.applyWindowBackgroundIfActive() } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 0920d588..07b13a75 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1121,6 +1121,16 @@ class TabManager: ObservableObject { tabs.insert(tab, at: insertIndex) } + func moveTabToTopForNotification(_ tabId: UUID) { + guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } + let pinnedCount = tabs.filter { $0.isPinned }.count + guard index != pinnedCount else { return } + let tab = tabs[index] + guard !tab.isPinned else { return } + tabs.remove(at: index) + tabs.insert(tab, at: pinnedCount) + } + func moveTabsToTop(_ tabIds: Set) { guard !tabIds.isEmpty else { return } let selectedTabs = tabs.filter { tabIds.contains($0.id) } @@ -1809,19 +1819,37 @@ class TabManager: ObservableObject { guard !shouldSuppressFlash else { return } guard AppFocusState.isAppActive() else { return } guard let panelId = focusedPanelId(for: tabId) else { return } - markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId) + _ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true) } private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) { guard selectedTabId == tabId else { return } guard !suppressFocusFlash else { return } - guard AppFocusState.isAppActive() else { return } - guard let notificationStore = AppDelegate.shared?.notificationStore else { return } - guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: panelId) else { return } - if let tab = tabs.first(where: { $0.id == tabId }) { + _ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true) + } + + @discardableResult + func dismissNotificationOnDirectInteraction(tabId: UUID, surfaceId: UUID?) -> Bool { + dismissNotificationIfActive(tabId: tabId, surfaceId: surfaceId, triggerFlash: true) + } + + @discardableResult + private func dismissNotificationIfActive( + tabId: UUID, + surfaceId: UUID?, + triggerFlash: Bool + ) -> Bool { + guard selectedTabId == tabId else { return false } + guard AppFocusState.isAppActive() else { return false } + guard let notificationStore = AppDelegate.shared?.notificationStore else { return false } + guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return false } + if triggerFlash, + let panelId = surfaceId, + let tab = tabs.first(where: { $0.id == tabId }) { tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false) } - notificationStore.markRead(forTabId: tabId, surfaceId: panelId) + notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) + return true } private func enqueuePanelTitleUpdate(tabId: UUID, panelId: UUID, title: String) { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 1f134353..917c56ce 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -13626,6 +13626,7 @@ class TerminalController { var lines: [String] = [] lines.append("tab=\(tab.id.uuidString)") + lines.append("color=\(tab.customColor ?? "none")") lines.append("cwd=\(tab.currentDirectory)") if let focused = tab.focusedPanelId, diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 5bb768cb..fb1f0b90 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -671,6 +671,11 @@ final class TerminalNotificationStore: ObservableObject { private var notificationSettingsURLOpener: (URL) -> Void = { url in NSWorkspace.shared.open(url) } + private var notificationDeliveryHandler: (TerminalNotificationStore, TerminalNotification) -> Void = { + store, + notification in + store.scheduleUserNotification(notification) + } private var indexes = NotificationIndexes() private init() { @@ -833,17 +838,10 @@ final class TerminalNotificationStore: ObservableObject { let isFocusedSurface = surfaceId == nil || focusedSurfaceId == surfaceId let isFocusedPanel = isActiveTab && isFocusedSurface let isAppFocused = AppFocusState.isAppFocused() - if isAppFocused && isFocusedPanel { - if !idsToClear.isEmpty { - notifications = updated - center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) - center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) - } - return - } + let shouldSuppressExternalDelivery = isAppFocused && isFocusedPanel if WorkspaceAutoReorderSettings.isEnabled() { - AppDelegate.shared?.tabManager?.moveTabToTop(tabId) + AppDelegate.shared?.tabManager?.moveTabToTopForNotification(tabId) } let notification = TerminalNotification( @@ -862,7 +860,9 @@ final class TerminalNotificationStore: ObservableObject { center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } - scheduleUserNotification(notification) + if !shouldSuppressExternalDelivery { + notificationDeliveryHandler(self, notification) + } } func markRead(id: UUID) { @@ -1233,6 +1233,18 @@ final class TerminalNotificationStore: ObservableObject { hasPromptedForSettings = false } + func configureNotificationDeliveryHandlerForTesting( + _ handler: @escaping (TerminalNotificationStore, TerminalNotification) -> Void + ) { + notificationDeliveryHandler = handler + } + + func resetNotificationDeliveryHandlerForTesting() { + notificationDeliveryHandler = { store, notification in + store.scheduleUserNotification(notification) + } + } + func promptToEnableNotificationsForTesting() { promptToEnableNotifications() } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 1b649937..4993be85 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -2924,7 +2924,6 @@ final class Workspace: Identifiable, ObservableObject { scheduleFocusReconcile() } scheduleTerminalGeometryReconcile() - scheduleMovedBrowserRefresh(panelId: detached.panelId) #if DEBUG dlog( @@ -3261,7 +3260,7 @@ final class Workspace: Identifiable, ObservableObject { if requiresSplit && !isSplit { return } - terminalPanel.triggerFlash() + terminalPanel.triggerNotificationDismissFlash() } func triggerDebugFlash(panelId: UUID) { @@ -3509,25 +3508,6 @@ final class Workspace: Identifiable, ObservableObject { runRefreshPass(0.03) } - private func scheduleMovedBrowserRefresh(panelId: UUID) { - guard browserPanel(for: panelId) != nil else { return } - - let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - guard let self, let browser = self.browserPanel(for: panelId) else { return } - BrowserWindowPortalRegistry.refresh( - webView: browser.webView, - reason: "workspace.movedBrowserRefresh" - ) - } - } - - // Mirror terminal moved-surface refreshes so round-trip pane drags get - // another render pass after bonsplit has settled its reparenting. - runRefreshPass(0) - runRefreshPass(0.03) - } - private func closeTabs(_ tabIds: [TabID], skipPinned: Bool = true) { for tabId in tabIds { if skipPinned, @@ -4195,7 +4175,6 @@ extension Workspace: BonsplitDelegate { #endif if let movedPanelId = panelIdFromSurfaceId(tab.id) { scheduleMovedTerminalRefresh(panelId: movedPanelId) - scheduleMovedBrowserRefresh(panelId: movedPanelId) } #if DEBUG let selectedAfter = controller.selectedTab(inPane: destination) diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index e8c087ac..edb26258 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -69,6 +69,7 @@ struct WorkspaceContentView: View { ) PanelContentView( panel: panel, + paneId: paneId, isFocused: isFocused, isSelectedInPane: isSelectedInPane, isVisibleInUI: isVisibleInUI, diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 8526ceba..3216a55d 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2513,6 +2513,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsVisible() { let (panel, _) = makePanelWithInspector() + let paneId = PaneID(id: UUID()) XCTAssertTrue(panel.showDeveloperTools()) let window = NSWindow( @@ -2534,6 +2535,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { let representable = WebViewRepresentable( panel: panel, + paneId: paneId, shouldAttachWebView: true, shouldFocusWebView: false, isPanelFocused: true, @@ -2552,6 +2554,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsHidden() { let (panel, _) = makePanelWithInspector() + let paneId = PaneID(id: UUID()) XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) let window = NSWindow( @@ -2573,6 +2576,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { let representable = WebViewRepresentable( panel: panel, + paneId: paneId, shouldAttachWebView: true, shouldFocusWebView: false, isPanelFocused: true, @@ -4229,6 +4233,58 @@ final class WorkspaceReorderTests: XCTestCase { } } +@MainActor +final class WorkspaceNotificationReorderTests: XCTestCase { + func testNotificationAutoReorderDoesNotMovePinnedWorkspace() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let notificationStore = TerminalNotificationStore.shared + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let defaults = UserDefaults.standard + let originalAutoReorderSetting = defaults.object(forKey: WorkspaceAutoReorderSettings.key) + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + notificationStore.replaceNotificationsForTesting([]) + notificationStore.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = notificationStore + defaults.set(true, forKey: WorkspaceAutoReorderSettings.key) + AppFocusState.overrideIsFocused = false + + defer { + notificationStore.replaceNotificationsForTesting([]) + notificationStore.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + if let originalAutoReorderSetting { + defaults.set(originalAutoReorderSetting, forKey: WorkspaceAutoReorderSettings.key) + } else { + defaults.removeObject(forKey: WorkspaceAutoReorderSettings.key) + } + } + + let firstPinned = manager.tabs[0] + manager.setPinned(firstPinned, pinned: true) + let secondPinned = manager.addWorkspace() + manager.setPinned(secondPinned, pinned: true) + let unpinned = manager.addWorkspace() + let expectedOrder = [firstPinned.id, secondPinned.id, unpinned.id] + + notificationStore.addNotification( + tabId: secondPinned.id, + surfaceId: nil, + title: "Build finished", + subtitle: "", + body: "Pinned workspaces should stay put" + ) + + XCTAssertEqual(manager.tabs.map(\.id), expectedOrder) + } +} + @MainActor final class TabManagerChildExitCloseTests: XCTestCase { func testChildExitOnLastPanelClosesSelectedWorkspaceAndKeepsIndexStable() { @@ -7390,6 +7446,118 @@ final class NotificationDockBadgeTests: XCTestCase { } } +@MainActor +final class TerminalNotificationDirectInteractionTests: XCTestCase { + private func makeWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.contentView = NSView(frame: window.contentRect(forFrameRect: window.frame)) + return window + } + + private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Failed to create \(type) mouse event") + } + return event + } + + private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> NSView? { + hostedView.subviews + .compactMap { $0 as? NSScrollView } + .first? + .documentView? + .subviews + .first + } + + func testTerminalMouseDownDismissesUnreadWhenSurfaceIsAlreadyFirstResponder() { + let appDelegate = AppDelegate.shared ?? AppDelegate() + let manager = TabManager() + let store = TerminalNotificationStore.shared + let window = makeWindow() + + let originalTabManager = appDelegate.tabManager + let originalNotificationStore = appDelegate.notificationStore + let originalAppFocusOverride = AppFocusState.overrideIsFocused + + store.replaceNotificationsForTesting([]) + store.configureNotificationDeliveryHandlerForTesting { _, _ in } + appDelegate.tabManager = manager + appDelegate.notificationStore = store + + defer { + store.replaceNotificationsForTesting([]) + store.resetNotificationDeliveryHandlerForTesting() + appDelegate.tabManager = originalTabManager + appDelegate.notificationStore = originalNotificationStore + AppFocusState.overrideIsFocused = originalAppFocusOverride + window.orderOut(nil) + } + + guard let workspace = manager.selectedWorkspace, + let terminalPanel = workspace.focusedTerminalPanel else { + XCTFail("Expected an initial focused terminal panel") + return + } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let hostedView = terminalPanel.hostedView + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + contentView.layoutSubtreeIfNeeded() + hostedView.layoutSubtreeIfNeeded() + + guard let surfaceView = surfaceView(in: hostedView) else { + XCTFail("Expected terminal surface view") + return + } + + GhosttySurfaceScrollView.resetFlashCounts() + AppFocusState.overrideIsFocused = true + XCTAssertTrue(window.makeFirstResponder(surfaceView)) + + store.addNotification( + tabId: workspace.id, + surfaceId: terminalPanel.id, + title: "Unread", + subtitle: "", + body: "" + ) + XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) + + AppFocusState.overrideIsFocused = true + let pointInWindow = surfaceView.convert(NSPoint(x: 20, y: 20), to: nil) + let event = makeMouseEvent(type: .leftMouseDown, location: pointInWindow, window: window) + surfaceView.mouseDown(with: event) + let drained = expectation(description: "flash drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 1.0) + + XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id)) + XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1) + } +} + final class MenuBarBadgeLabelFormatterTests: XCTestCase { func testBadgeLabelFormatting() { @@ -10859,6 +11027,72 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { XCTAssertTrue(webView.superview === slot, "Hiding should preserve the hosted WKWebView attachment") XCTAssertTrue(slot.isHidden, "Hiding should immediately hide the existing portal slot") } + + func testHiddenPortalEntrySurvivesAnchorRemovalUntilWorkspaceRebind() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, 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 anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160) + let oldAnchor = NSView(frame: anchorFrame) + contentView.addSubview(oldAnchor) + + let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: oldAnchor, visibleInUI: true) + portal.synchronizeWebViewForAnchor(oldAnchor) + advanceAnimations() + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0) + portal.synchronizeWebViewForAnchor(oldAnchor) + advanceAnimations() + XCTAssertTrue(slot.isHidden, "Workspace handoff should hide the retiring browser before unmount") + + oldAnchor.removeFromSuperview() + portal.synchronizeWebViewForAnchor(oldAnchor) + advanceAnimations() + + XCTAssertTrue( + webView.superview === slot, + "Hidden workspace browsers should stay attached while their SwiftUI anchor is temporarily unmounted" + ) + XCTAssertTrue(slot.isHidden, "Unmounted hidden workspace browser should remain hidden until rebound") + XCTAssertEqual(portal.debugEntryCount(), 1, "Workspace handoff should keep the hidden browser portal entry alive") + + let displayCountBeforeRebind = webView.displayIfNeededCount + let newAnchor = NSView(frame: anchorFrame) + contentView.addSubview(newAnchor) + portal.bind(webView: webView, to: newAnchor, visibleInUI: true) + portal.synchronizeWebViewForAnchor(newAnchor) + advanceAnimations() + + XCTAssertTrue( + webView.superview === slot, + "Selecting the workspace again should reuse the existing hidden browser portal slot" + ) + XCTAssertFalse(slot.isHidden, "Rebinding the workspace browser should reveal the existing portal slot") + XCTAssertEqual(portal.debugEntryCount(), 1) + XCTAssertGreaterThan( + webView.displayIfNeededCount, + displayCountBeforeRebind, + "Workspace rebind should refresh the preserved browser without recreating its portal slot" + ) + } } @MainActor diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 53f988aa..e229b761 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -939,52 +939,6 @@ final class RecentlyClosedBrowserStackTests: XCTestCase { } } -final class TabManagerNotificationOrderingSourceTests: XCTestCase { - func testGhosttyDidSetTitleObserverDoesNotHopThroughTask() throws { - let projectRoot = findProjectRoot() - let tabManagerURL = projectRoot.appendingPathComponent("Sources/TabManager.swift") - let source = try String(contentsOf: tabManagerURL, encoding: .utf8) - - guard let titleObserverStart = source.range(of: "forName: .ghosttyDidSetTitle"), - let focusObserverStart = source.range( - of: "forName: .ghosttyDidFocusSurface", - range: titleObserverStart.upperBound.. URL { - var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent() - for _ in 0..<10 { - let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj") - if FileManager.default.fileExists(atPath: marker.path) { - return dir - } - dir = dir.deletingLastPathComponent() - } - return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - } -} - final class SocketControlSettingsTests: XCTestCase { func testMigrateModeSupportsExpandedSocketModes() { XCTAssertEqual(SocketControlSettings.migrateMode("off"), .off) @@ -1339,4 +1293,169 @@ final class GhosttyMouseFocusTests: XCTestCase { ) ) } + + // MARK: - CJK Font Fallback + + private func withTempConfig( + _ contents: String, + body: (String) -> Void + ) throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-cjk-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let file = dir.appendingPathComponent("config") + try contents.write(to: file, atomically: true, encoding: .utf8) + body(file.path) + } + + // MARK: cjkFontMappings + + func testCJKFontMappingsReturnsHiraginoWithKanaForJapanese() { + let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "en-US"])! + let fonts = Set(mappings.map(\.1)) + let ranges = mappings.map(\.0) + + XCTAssertTrue(fonts.contains("Hiragino Sans")) + XCTAssertTrue(ranges.contains("U+3040-U+309F"), "Should include Hiragana") + XCTAssertTrue(ranges.contains("U+30A0-U+30FF"), "Should include Katakana") + XCTAssertTrue(ranges.contains("U+4E00-U+9FFF"), "Should include CJK Ideographs") + XCTAssertFalse(ranges.contains("U+AC00-U+D7AF"), "Should NOT include Hangul") + } + + func testCJKFontMappingsReturnsAppleSDGothicNeoWithHangulForKorean() { + let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ko-KR"])! + let fonts = Set(mappings.map(\.1)) + let ranges = mappings.map(\.0) + + XCTAssertTrue(fonts.contains("Apple SD Gothic Neo")) + XCTAssertTrue(ranges.contains("U+AC00-U+D7AF"), "Should include Hangul Syllables") + XCTAssertTrue(ranges.contains("U+1100-U+11FF"), "Should include Hangul Jamo") + XCTAssertTrue(ranges.contains("U+4E00-U+9FFF"), "Should include CJK Ideographs") + XCTAssertFalse(ranges.contains("U+3040-U+309F"), "Should NOT include Hiragana") + } + + func testCJKFontMappingsReturnsPingFangForChinese() { + let mappingsTW = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hant-TW"])! + XCTAssertTrue(mappingsTW.contains { $0.1 == "PingFang TC" }) + + let mappingsCN = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hans-CN"])! + XCTAssertTrue(mappingsCN.contains { $0.1 == "PingFang SC" }) + + let mappingsHK = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-HK"])! + XCTAssertTrue(mappingsHK.contains { $0.1 == "PingFang TC" }) + } + + func testCJKFontMappingsReturnsNilForNonCJKLanguages() { + XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: ["en-US", "fr-FR"])) + XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: [])) + } + + func testCJKFontMappingsMultiLanguageMapsScriptSpecificRanges() { + let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "ko-KR"])! + + let hiraginoRanges = mappings.filter { $0.1 == "Hiragino Sans" }.map(\.0) + let sdGothicRanges = mappings.filter { $0.1 == "Apple SD Gothic Neo" }.map(\.0) + + XCTAssertTrue(hiraginoRanges.contains("U+3040-U+309F"), "Hiragana → Hiragino") + XCTAssertTrue(hiraginoRanges.contains("U+4E00-U+9FFF"), "Shared CJK → first lang font") + XCTAssertTrue(sdGothicRanges.contains("U+AC00-U+D7AF"), "Hangul → Apple SD Gothic Neo") + XCTAssertFalse(hiraginoRanges.contains("U+AC00-U+D7AF"), "Hangul NOT in Hiragino") + } + + // MARK: userConfigContainsCJKCodepointMap + + func testUserConfigContainsCJKCodepointMapDetectsPresence() throws { + try withTempConfig("font-family = Menlo\nfont-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in + XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])) + } + } + + func testUserConfigContainsCJKCodepointMapReturnsFalseWhenAbsent() throws { + try withTempConfig("font-family = Menlo\nfont-size = 14\n") { path in + XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])) + } + } + + func testUserConfigContainsCJKCodepointMapIgnoresComments() throws { + try withTempConfig("# font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in + XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])) + } + } + + func testUserConfigContainsCJKCodepointMapReturnsFalseForMissingFiles() { + let path = NSTemporaryDirectory() + "cmux-nonexistent-\(UUID().uuidString)/config" + XCTAssertFalse( + GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]) + ) + } + + func testUserConfigContainsCJKCodepointMapFollowsConfigFileIncludes() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-cjk-include-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let included = dir.appendingPathComponent("fonts.conf") + try "font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n" + .write(to: included, atomically: true, encoding: .utf8) + + let main = dir.appendingPathComponent("config") + try "font-family = Menlo\nconfig-file = \(included.path)\n" + .write(to: main, atomically: true, encoding: .utf8) + + XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path])) + } + + func testUserConfigContainsCJKCodepointMapFollowsRelativeIncludes() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-cjk-rel-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let included = dir.appendingPathComponent("fonts.conf") + try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n" + .write(to: included, atomically: true, encoding: .utf8) + + let main = dir.appendingPathComponent("config") + try "config-file = fonts.conf\n" + .write(to: main, atomically: true, encoding: .utf8) + + XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path])) + } + + func testUserConfigContainsCJKCodepointMapHandlesOptionalInclude() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-cjk-opt-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let included = dir.appendingPathComponent("fonts.conf") + try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n" + .write(to: included, atomically: true, encoding: .utf8) + + let main = dir.appendingPathComponent("config") + try "config-file = \(included.path)?\n" + .write(to: main, atomically: true, encoding: .utf8) + + XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path])) + } + + func testUserConfigContainsCJKCodepointMapHandlesCyclicIncludes() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-cjk-cycle-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let fileA = dir.appendingPathComponent("a.conf") + let fileB = dir.appendingPathComponent("b.conf") + try "config-file = \(fileB.path)\n" + .write(to: fileA, atomically: true, encoding: .utf8) + try "config-file = \(fileA.path)\n" + .write(to: fileB, atomically: true, encoding: .utf8) + + // Should not hang; should return false since neither file has font-codepoint-map + XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [fileA.path])) + } } diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 319c350f..96826edf 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -8,101 +8,6 @@ import AppKit @testable import cmux #endif -/// Regression test: ensures UpdatePill is never gated behind #if DEBUG in production code paths. -/// This prevents accidentally hiding the update UI in Release builds. -final class UpdatePillReleaseVisibilityTests: XCTestCase { - - /// Source files that must show UpdatePill without #if DEBUG guards. - private let filesToCheck = [ - "Sources/Update/UpdateTitlebarAccessory.swift", - "Sources/ContentView.swift", - ] - - func testUpdatePillNotGatedBehindDebug() throws { - let projectRoot = findProjectRoot() - - for relativePath in filesToCheck { - let url = projectRoot.appendingPathComponent(relativePath) - let source = try String(contentsOf: url, encoding: .utf8) - let lines = source.components(separatedBy: .newlines) - - // Track #if DEBUG nesting depth. - var debugDepth = 0 - - for (index, line) in lines.enumerated() { - let trimmed = line.trimmingCharacters(in: .whitespaces) - - if trimmed == "#if DEBUG" || trimmed.hasPrefix("#if DEBUG ") { - debugDepth += 1 - } else if trimmed == "#endif" && debugDepth > 0 { - debugDepth -= 1 - } else if trimmed == "#else" && debugDepth > 0 { - // #else inside #if DEBUG means we're in the non-debug branch — that's fine. - // But UpdatePill in the #if DEBUG branch (before #else) is the problem. - // We handle this by only flagging UpdatePill when debugDepth > 0 and we haven't - // hit #else yet. For simplicity, treat #else as flipping out of the guarded section. - debugDepth -= 1 - } - - if debugDepth > 0 && trimmed.contains("UpdatePill") { - XCTFail( - """ - \(relativePath):\(index + 1) — UpdatePill is inside #if DEBUG. \ - This hides the update UI in Release builds. Remove the #if DEBUG guard \ - or move UpdatePill to the #else branch. - """ - ) - } - } - } - } - - private func findProjectRoot() -> URL { - // Walk up from the test bundle to find the project root (contains GhosttyTabs.xcodeproj). - var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent() - for _ in 0..<10 { - let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj") - if FileManager.default.fileExists(atPath: marker.path) { - return dir - } - dir = dir.deletingLastPathComponent() - } - // Fallback: assume CWD is project root. - return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - } -} - -/// Regression test: ensure WKWebView can load HTTP development URLs (e.g. *.localtest.me). -final class AppTransportSecurityTests: XCTestCase { - func testInfoPlistAllowsArbitraryLoadsInWebContent() throws { - let projectRoot = findProjectRoot() - let infoPlistURL = projectRoot.appendingPathComponent("Resources/Info.plist") - let data = try Data(contentsOf: infoPlistURL) - var format = PropertyListSerialization.PropertyListFormat.xml - let plist = try XCTUnwrap( - PropertyListSerialization.propertyList(from: data, options: [], format: &format) as? [String: Any] - ) - let ats = try XCTUnwrap(plist["NSAppTransportSecurity"] as? [String: Any]) - XCTAssertEqual( - ats["NSAllowsArbitraryLoadsInWebContent"] as? Bool, - true, - "Resources/Info.plist must allow HTTP loads in WKWebView for local dev hostnames." - ) - } - - private func findProjectRoot() -> URL { - var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent() - for _ in 0..<10 { - let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj") - if FileManager.default.fileExists(atPath: marker.path) { - return dir - } - dir = dir.deletingLastPathComponent() - } - return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - } -} - final class BrowserInsecureHTTPSettingsTests: XCTestCase { func testDefaultAllowlistPatternsArePresent() { XCTAssertEqual( @@ -272,45 +177,3 @@ final class TitlebarControlsHoverPolicyTests: XCTestCase { XCTAssertFalse(titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyle.softButtons.config)) } } - -/// Regression test: ensure new terminal windows are born in full-size content mode so -/// titlebar/content offsets are correct before the first resize. -final class MainWindowLayoutStyleTests: XCTestCase { - func testCreateMainWindowUsesFullSizeContentViewStyleMask() throws { - let projectRoot = findProjectRoot() - let appDelegateURL = projectRoot.appendingPathComponent("Sources/AppDelegate.swift") - let source = try String(contentsOf: appDelegateURL, encoding: .utf8) - - guard let start = source.range(of: "func createMainWindow("), - let end = source.range(of: "@objc func checkForUpdates", range: start.upperBound.. URL { - var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent() - for _ in 0..<10 { - let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj") - if FileManager.default.fileExists(atPath: marker.path) { - return dir - } - dir = dir.deletingLastPathComponent() - } - return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) - } -} diff --git a/tests/test_browser_chrome_contrast_regression.py b/tests/test_browser_chrome_contrast_regression.py deleted file mode 100644 index a2552f2f..00000000 --- a/tests/test_browser_chrome_contrast_regression.py +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env python3 -"""Static regression guards for browser chrome contrast in mixed theme setups.""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - - -def repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path(__file__).resolve().parents[1] - - -def extract_block(source: str, signature: str) -> str: - start = source.find(signature) - if start < 0: - raise ValueError(f"Missing signature: {signature}") - - brace_start = source.find("{", start) - if brace_start < 0: - raise ValueError(f"Missing opening brace for: {signature}") - - depth = 0 - for idx in range(brace_start, len(source)): - char = source[idx] - if char == "{": - depth += 1 - elif char == "}": - depth -= 1 - if depth == 0: - return source[brace_start : idx + 1] - - raise ValueError(f"Unbalanced braces for: {signature}") - - -def main() -> int: - root = repo_root() - view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift" - source = view_path.read_text(encoding="utf-8") - failures: list[str] = [] - - try: - browser_panel_view_block = extract_block(source, "struct BrowserPanelView: View") - except ValueError as error: - failures.append(str(error)) - browser_panel_view_block = "" - - try: - resolver_block = extract_block(source, "func resolvedBrowserChromeColorScheme(") - except ValueError as error: - failures.append(str(error)) - resolver_block = "" - - if resolver_block: - if "backgroundColor.isLightColor ? .light : .dark" not in resolver_block: - failures.append( - "resolvedBrowserChromeColorScheme must map luminance to a light/dark ColorScheme" - ) - - try: - chrome_scheme_block = extract_block( - browser_panel_view_block, - "private var browserChromeColorScheme: ColorScheme", - ) - except ValueError as error: - failures.append(str(error)) - chrome_scheme_block = "" - - if chrome_scheme_block and "resolvedBrowserChromeColorScheme(" not in chrome_scheme_block: - failures.append("browserChromeColorScheme must use resolvedBrowserChromeColorScheme") - - try: - omnibar_background_block = extract_block( - browser_panel_view_block, - "private var omnibarPillBackgroundColor: NSColor", - ) - except ValueError as error: - failures.append(str(error)) - omnibar_background_block = "" - - if omnibar_background_block and "for: browserChromeColorScheme" not in omnibar_background_block: - failures.append("omnibar pill background must use browserChromeColorScheme") - - try: - address_bar_block = extract_block( - browser_panel_view_block, - "private var addressBar: some View", - ) - except ValueError as error: - failures.append(str(error)) - address_bar_block = "" - - if address_bar_block and ".environment(\\.colorScheme, browserChromeColorScheme)" not in address_bar_block: - failures.append("addressBar must apply browserChromeColorScheme via environment") - - try: - body_block = extract_block(browser_panel_view_block, "var body: some View") - except ValueError as error: - failures.append(str(error)) - body_block = "" - - if body_block: - if "OmnibarSuggestionsView(" not in body_block: - failures.append("Expected OmnibarSuggestionsView block in BrowserPanelView body") - elif ".environment(\\.colorScheme, browserChromeColorScheme)" not in body_block: - failures.append("Omnibar suggestions must apply browserChromeColorScheme via environment") - - if failures: - print("FAIL: browser chrome contrast regression guards failed") - for failure in failures: - print(f" - {failure}") - return 1 - - print("PASS: browser chrome contrast regression guards are in place") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_browser_console_errors_cli_output_regression.py b/tests/test_browser_console_errors_cli_output_regression.py deleted file mode 100644 index 40561356..00000000 --- a/tests/test_browser_console_errors_cli_output_regression.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python3 -"""Static regression guard for browser console/errors CLI output formatting. - -Ensures non-JSON `browser console list` and `browser errors list` do not fall -back to unconditional `OK` when logs exist. -""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - - -def repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path(__file__).resolve().parents[1] - - -def extract_block(source: str, signature: str) -> str: - start = source.find(signature) - if start < 0: - raise ValueError(f"Missing signature: {signature}") - brace_start = source.find("{", start) - if brace_start < 0: - raise ValueError(f"Missing opening brace for: {signature}") - depth = 0 - for idx in range(brace_start, len(source)): - char = source[idx] - if char == "{": - depth += 1 - elif char == "}": - depth -= 1 - if depth == 0: - return source[brace_start : idx + 1] - raise ValueError(f"Unbalanced braces for: {signature}") - - -def main() -> int: - root = repo_root() - failures: list[str] = [] - - cli_path = root / "CLI" / "cmux.swift" - cli_source = cli_path.read_text(encoding="utf-8") - browser_block = extract_block(cli_source, "private func runBrowserCommand(") - - if "func displayBrowserLogItems(_ value: Any?) -> String?" not in browser_block: - failures.append("runBrowserCommand() is missing displayBrowserLogItems() helper") - else: - helper_block = extract_block(browser_block, "func displayBrowserLogItems(_ value: Any?) -> String?") - if "return \"[\\(level)] \\(text)\"" not in helper_block: - failures.append("displayBrowserLogItems() no longer renders level-prefixed log lines") - if "return \"[error] \\(message)\"" not in helper_block: - failures.append("displayBrowserLogItems() no longer renders concise JS error messages") - if "return displayBrowserValue(dict)" not in helper_block: - failures.append("displayBrowserLogItems() no longer falls back to structured formatting") - - console_block = extract_block(browser_block, 'if subcommand == "console"') - if 'displayBrowserLogItems(payload["entries"])' not in console_block: - failures.append("browser console path no longer formats entries for non-JSON output") - if 'output(payload, fallback: "OK")' in console_block: - failures.append("browser console path regressed to unconditional OK output") - - errors_block = extract_block(browser_block, 'if subcommand == "errors"') - if 'displayBrowserLogItems(payload["errors"])' not in errors_block: - failures.append("browser errors path no longer formats errors for non-JSON output") - if 'output(payload, fallback: "OK")' in errors_block: - failures.append("browser errors path regressed to unconditional OK output") - - if failures: - print("FAIL: browser console/errors CLI output regression guard failed") - for item in failures: - print(f" - {item}") - return 1 - - print("PASS: browser console/errors CLI output regression guard is in place") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_browser_devtools_portal_regressions.py b/tests/test_browser_devtools_portal_regressions.py deleted file mode 100644 index 6ec27096..00000000 --- a/tests/test_browser_devtools_portal_regressions.py +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env python3 -"""Static regression checks for browser DevTools/portal review fixes. - -Guards two follow-up fixes: -1) DevTools toggle path must retry restore when inspector show is transiently ignored. -2) Browser portal visibility must propagate even if host is temporarily off-window. -""" - -from __future__ import annotations - -import re -import subprocess -from pathlib import Path - - -def repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path(__file__).resolve().parents[1] - - -def extract_block(source: str, signature: str) -> str: - start = source.find(signature) - if start < 0: - raise ValueError(f"Missing signature: {signature}") - brace_start = source.find("{", start) - if brace_start < 0: - raise ValueError(f"Missing opening brace for: {signature}") - depth = 0 - for idx in range(brace_start, len(source)): - char = source[idx] - if char == "{": - depth += 1 - elif char == "}": - depth -= 1 - if depth == 0: - return source[brace_start : idx + 1] - raise ValueError(f"Unbalanced braces for: {signature}") - - -def main() -> int: - root = repo_root() - failures: list[str] = [] - - panel_path = root / "Sources" / "Panels" / "BrowserPanel.swift" - panel_source = panel_path.read_text(encoding="utf-8") - toggle_block = extract_block(panel_source, "func toggleDeveloperTools() -> Bool") - if "visibleAfterToggle" not in toggle_block: - failures.append("toggleDeveloperTools() no longer re-checks inspector visibility") - if "scheduleDeveloperToolsRestoreRetry()" not in toggle_block: - failures.append("toggleDeveloperTools() no longer schedules a DevTools restore retry") - - view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift" - view_source = view_path.read_text(encoding="utf-8") - portal_update_block = extract_block(view_source, "private func updateUsingWindowPortal(") - if "BrowserWindowPortalRegistry.updateEntryVisibility(" not in portal_update_block: - failures.append("BrowserPanelView.updateUsingWindowPortal() is missing deferred portal visibility propagation") - if "zPriority: coordinator.desiredPortalZPriority" not in portal_update_block: - failures.append("BrowserPanelView deferred portal update no longer propagates zPriority") - - portal_path = root / "Sources" / "BrowserWindowPortal.swift" - portal_source = portal_path.read_text(encoding="utf-8") - if not re.search( - r"func\s+updateEntryVisibility\s*\(\s*forWebViewId\s+webViewId:\s*ObjectIdentifier,\s*visibleInUI:\s*Bool,\s*zPriority:\s*Int\s*\)", - portal_source, - flags=re.MULTILINE, - ): - failures.append("WindowBrowserPortal is missing updateEntryVisibility(forWebViewId:visibleInUI:zPriority:)") - if not re.search( - r"static\s+func\s+updateEntryVisibility\s*\(\s*for\s+webView:\s*WKWebView,\s*visibleInUI:\s*Bool,\s*zPriority:\s*Int\s*\)", - portal_source, - flags=re.MULTILINE, - ): - failures.append("BrowserWindowPortalRegistry is missing updateEntryVisibility(for:visibleInUI:zPriority:)") - - if failures: - print("FAIL: browser devtools/portal regression guards failed") - for item in failures: - print(f" - {item}") - return 1 - - print("PASS: browser devtools/portal regression guards are in place") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_browser_eval_async_wrapper_regression.py b/tests/test_browser_eval_async_wrapper_regression.py deleted file mode 100644 index 4d31948c..00000000 --- a/tests/test_browser_eval_async_wrapper_regression.py +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env python3 -"""Static regression guard for browser eval async wrapping + telemetry injection.""" - -from __future__ import annotations - -import subprocess -from pathlib import Path - - -def repo_root() -> Path: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - return Path(result.stdout.strip()) - return Path(__file__).resolve().parents[1] - - -def extract_block(source: str, signature: str) -> str: - start = source.find(signature) - if start < 0: - raise ValueError(f"Missing signature: {signature}") - brace_start = source.find("{", start) - if brace_start < 0: - raise ValueError(f"Missing opening brace for: {signature}") - depth = 0 - for idx in range(brace_start, len(source)): - char = source[idx] - if char == "{": - depth += 1 - elif char == "}": - depth -= 1 - if depth == 0: - return source[brace_start : idx + 1] - raise ValueError(f"Unbalanced braces for: {signature}") - - -def extract_span(source: str, start_marker: str, end_marker: str) -> str: - start = source.find(start_marker) - if start < 0: - raise ValueError(f"Missing start marker: {start_marker}") - end = source.find(end_marker, start) - if end < 0: - raise ValueError(f"Missing end marker: {end_marker}") - return source[start:end] - - -def main() -> int: - root = repo_root() - failures: list[str] = [] - - terminal_path = root / "Sources" / "TerminalController.swift" - panel_path = root / "Sources" / "Panels" / "BrowserPanel.swift" - terminal_source = terminal_path.read_text(encoding="utf-8") - panel_source = panel_path.read_text(encoding="utf-8") - - if "preferAsync: Bool = false" not in terminal_source: - failures.append("v2RunJavaScript() no longer exposes preferAsync toggle") - run_js_block = extract_block(terminal_source, "private func v2RunJavaScript(") - if "callAsyncJavaScript" not in run_js_block: - failures.append("v2RunJavaScript() no longer uses callAsyncJavaScript for async JS") - - run_browser_js_block = extract_block(terminal_source, "private func v2RunBrowserJavaScript(") - required_wrapper_tokens = [ - "let asyncFunctionBody =", - "__cmuxMaybeAwait", - "__cmux_t", - "__cmux_v", - "return await __cmuxEvalInFrame();", - "preferAsync: true", - ] - for token in required_wrapper_tokens: - if token not in run_browser_js_block: - failures.append(f"v2RunBrowserJavaScript() missing async eval wrapper token: {token}") - - if "v2BrowserUndefinedSentinel" not in terminal_source: - failures.append("TerminalController is missing undefined sentinel handling") - if "v2BrowserEvalEnvelopeTypeUndefined" not in terminal_source: - failures.append("TerminalController is missing undefined envelope decode constant") - - hook_block = extract_block(terminal_source, "private func v2BrowserEnsureTelemetryHooks(") - if "BrowserPanel.telemetryHookBootstrapScriptSource" not in hook_block: - failures.append("v2BrowserEnsureTelemetryHooks() no longer uses shared BrowserPanel telemetry source") - - if "static let telemetryHookBootstrapScriptSource" not in panel_source: - failures.append("BrowserPanel is missing telemetryHookBootstrapScriptSource") - if "static let dialogTelemetryHookBootstrapScriptSource" not in panel_source: - failures.append("BrowserPanel is missing dialogTelemetryHookBootstrapScriptSource") - - base_script_span = extract_span( - panel_source, - "static let telemetryHookBootstrapScriptSource =", - "static let dialogTelemetryHookBootstrapScriptSource =", - ) - if "window.alert = function(message)" in base_script_span: - failures.append("Document-start telemetry script should not override alert dialogs") - if "window.confirm = function(message)" in base_script_span: - failures.append("Document-start telemetry script should not override confirm dialogs") - if "window.prompt = function(message, defaultValue)" in base_script_span: - failures.append("Document-start telemetry script should not override prompt dialogs") - - panel_init_block = extract_block( - panel_source, - "init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil)", - ) - required_init_tokens = [ - "config.userContentController.addUserScript(", - "source: Self.telemetryHookBootstrapScriptSource", - "injectionTime: .atDocumentStart", - ] - for token in required_init_tokens: - if token not in panel_init_block: - failures.append(f"BrowserPanel init() missing telemetry user-script token: {token}") - - if failures: - print("FAIL: browser eval async wrapper / telemetry injection regression guard failed") - for item in failures: - print(f" - {item}") - return 1 - - print("PASS: browser eval async wrapper / telemetry injection regression guard is in place") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/test_browser_eval_cli_output_regression.py b/tests/test_browser_eval_cli_output_regression.py deleted file mode 100644 index 6c2e83da..00000000 --- a/tests/test_browser_eval_cli_output_regression.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python3 -"""Static regression guard for browser eval CLI output formatting. - -Ensures `cmux browser eval