From f5c2f2dd75c6a79a267ae619a0e408b9b530600a Mon Sep 17 00:00:00 2001 From: atani Date: Fri, 6 Mar 2026 22:52:26 +0900 Subject: [PATCH 01/21] fix: add CJK font fallback to prevent decorative font rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS, Core Text's CTFontCreateForString may pick an inappropriate fallback font (e.g. LingWai, a decorative calligraphic font) for CJK characters when the primary font (e.g. Menlo) does not cover them. This adds automatic CJK font fallback based on the system's preferred language: - ja → Hiragino Sans - ko → Apple SD Gothic Neo - zh-Hant/zh-TW/zh-HK → PingFang TC - zh → PingFang SC The fallback is only applied when: 1. The user has not set any font-codepoint-map in their Ghostty config 2. A CJK language is detected in the system's preferred languages This ensures CJK text renders with appropriate system fonts instead of relying on Core Text's unpredictable fallback chain. --- Sources/GhosttyTerminalView.swift | 90 +++++++++++++++++++++++++++ cmuxTests/GhosttyConfigTests.swift | 99 ++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 7855f1fc..945b0e40 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -999,10 +999,100 @@ class GhosttyApp { ghostty_config_load_default_files(config) loadReleaseAppSupportGhosttyConfigIfNeeded(config) loadLegacyGhosttyConfigIfNeeded(config) + loadCJKFontFallbackIfNeeded(config) ghostty_config_load_recursive_files(config) ghostty_config_finalize(config) } + /// When the user has not configured `font-codepoint-map` for CJK ranges, + /// macOS Core Text may pick an inappropriate fallback font (e.g. LingWai, + /// a decorative calligraphic font) for CJK characters. This injects a + /// sensible default based on the system's preferred languages. + /// + /// See: https://github.com/manaflow-ai/cmux/issues/XXX + private func loadCJKFontFallbackIfNeeded(_ config: ghostty_config_t) { + if Self.userConfigContainsCJKCodepointMap() { return } + + guard let fontFamily = Self.preferredCJKFontFamily() else { return } + + let lines = Self.cjkUnicodeRanges.map { range in + "font-codepoint-map = \(range)=\(fontFamily)" + }.joined(separator: "\n") + + let tmpPath = NSTemporaryDirectory() + "cmux-cjk-font-fallback.conf" + do { + try lines.write(toFile: tmpPath, atomically: true, encoding: .utf8) + tmpPath.withCString { path in + ghostty_config_load_file(config, path) + } + try? FileManager.default.removeItem(atPath: tmpPath) + } catch { + #if DEBUG + Self.initLog("failed to write CJK font fallback config: \(error)") + #endif + } + } + + /// Unicode ranges that cover CJK characters, kana, and fullwidth forms. + private static let cjkUnicodeRanges = [ + "U+3000-U+303F", // CJK Symbols and Punctuation + "U+3040-U+309F", // Hiragana + "U+30A0-U+30FF", // Katakana + "U+4E00-U+9FFF", // CJK Unified Ideographs + "U+F900-U+FAFF", // CJK Compatibility Ideographs + "U+FF00-U+FFEF", // Halfwidth and Fullwidth Forms + "U+AC00-U+D7AF", // Hangul Syllables + "U+1100-U+11FF", // Hangul Jamo + "U+3400-U+4DBF", // CJK Unified Ideographs Extension A + ] + + /// Returns a suitable CJK font family name based on the user's preferred + /// system language, or nil if no CJK language is detected. + static func preferredCJKFontFamily( + preferredLanguages: [String] = Locale.preferredLanguages + ) -> String? { + for lang in preferredLanguages { + let lower = lang.lowercased() + if lower.hasPrefix("ja") { + return "Hiragino Sans" + } + if lower.hasPrefix("ko") { + return "Apple SD Gothic Neo" + } + if lower.hasPrefix("zh-hant") || lower.hasPrefix("zh-tw") || lower.hasPrefix("zh-hk") { + return "PingFang TC" + } + if lower.hasPrefix("zh") { + return "PingFang SC" + } + } + return nil + } + + /// Checks whether the user's Ghostty config files already contain + /// a `font-codepoint-map` entry covering CJK ranges. + static func userConfigContainsCJKCodepointMap( + configPaths: [String] = [ + "~/.config/ghostty/config", + "~/.config/ghostty/config.ghostty", + "~/Library/Application Support/com.mitchellh.ghostty/config", + "~/Library/Application Support/com.mitchellh.ghostty/config.ghostty", + ] + ) -> Bool { + for rawPath in configPaths { + let path = NSString(string: rawPath).expandingTildeInPath + guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { continue } + 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 + } + } + } + return false + } + static func shouldLoadLegacyGhosttyConfig( newConfigFileSize: Int?, legacyConfigFileSize: Int? diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 53f988aa..dc8f1196 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1339,4 +1339,103 @@ final class GhosttyMouseFocusTests: XCTestCase { ) ) } + + // MARK: - CJK Font Fallback + + func testPreferredCJKFontFamilyReturnsHiraginoForJapanese() { + XCTAssertEqual( + GhosttyApp.preferredCJKFontFamily(preferredLanguages: ["ja-JP", "en-US"]), + "Hiragino Sans" + ) + XCTAssertEqual( + GhosttyApp.preferredCJKFontFamily(preferredLanguages: ["ja"]), + "Hiragino Sans" + ) + } + + func testPreferredCJKFontFamilyReturnsAppleSDGothicNeoForKorean() { + XCTAssertEqual( + GhosttyApp.preferredCJKFontFamily(preferredLanguages: ["ko-KR", "en-US"]), + "Apple SD Gothic Neo" + ) + } + + func testPreferredCJKFontFamilyReturnsPingFangForChinese() { + XCTAssertEqual( + GhosttyApp.preferredCJKFontFamily(preferredLanguages: ["zh-Hant-TW"]), + "PingFang TC" + ) + XCTAssertEqual( + GhosttyApp.preferredCJKFontFamily(preferredLanguages: ["zh-Hans-CN"]), + "PingFang SC" + ) + XCTAssertEqual( + GhosttyApp.preferredCJKFontFamily(preferredLanguages: ["zh-HK"]), + "PingFang TC" + ) + } + + func testPreferredCJKFontFamilyReturnsNilForNonCJKLanguages() { + XCTAssertNil( + GhosttyApp.preferredCJKFontFamily(preferredLanguages: ["en-US", "fr-FR"]) + ) + XCTAssertNil( + GhosttyApp.preferredCJKFontFamily(preferredLanguages: []) + ) + } + + func testPreferredCJKFontFamilyUsesFirstCJKLanguageInList() { + XCTAssertEqual( + GhosttyApp.preferredCJKFontFamily(preferredLanguages: ["en-US", "ko-KR", "ja-JP"]), + "Apple SD Gothic Neo" + ) + } + + func testUserConfigContainsCJKCodepointMapDetectsPresence() throws { + let tmpDir = NSTemporaryDirectory() + "cmux-test-cjk-\(UUID().uuidString)/" + try FileManager.default.createDirectory(atPath: tmpDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: tmpDir) } + + let configWithMap = tmpDir + "config-with-map" + try "font-family = Menlo\nfont-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n" + .write(toFile: configWithMap, atomically: true, encoding: .utf8) + + XCTAssertTrue( + GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [configWithMap]) + ) + } + + func testUserConfigContainsCJKCodepointMapReturnsFalseWhenAbsent() throws { + let tmpDir = NSTemporaryDirectory() + "cmux-test-cjk-\(UUID().uuidString)/" + try FileManager.default.createDirectory(atPath: tmpDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: tmpDir) } + + let configWithoutMap = tmpDir + "config-no-map" + try "font-family = Menlo\nfont-size = 14\n" + .write(toFile: configWithoutMap, atomically: true, encoding: .utf8) + + XCTAssertFalse( + GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [configWithoutMap]) + ) + } + + func testUserConfigContainsCJKCodepointMapIgnoresComments() throws { + let tmpDir = NSTemporaryDirectory() + "cmux-test-cjk-\(UUID().uuidString)/" + try FileManager.default.createDirectory(atPath: tmpDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: tmpDir) } + + let configCommented = tmpDir + "config-commented" + try "# font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n" + .write(toFile: configCommented, atomically: true, encoding: .utf8) + + XCTAssertFalse( + GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [configCommented]) + ) + } + + func testUserConfigContainsCJKCodepointMapReturnsFalseForMissingFiles() { + XCTAssertFalse( + GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: ["/nonexistent/path/config"]) + ) + } } From 12e91aa4fe0cdd87bb9dd402c411e4448fe418d0 Mon Sep 17 00:00:00 2001 From: atani Date: Fri, 6 Mar 2026 23:16:15 +0900 Subject: [PATCH 02/21] fix: address review feedback for CJK font fallback - Split Unicode ranges by language to avoid mapping Hangul to Hiragino Sans or Kana to Apple SD Gothic Neo. Shared CJK ranges (ideographs, symbols, fullwidth forms) use the first CJK language's font, while script-specific ranges (Kana, Hangul) only map to their own font. - Use UUID-based temp file path to prevent race conditions on concurrent launches. - Move fallback injection after ghostty_config_load_recursive_files so that config-file includes are already loaded when checking for existing font-codepoint-map entries. - Follow config-file directives when scanning for existing font-codepoint-map entries. - Extract test helper withTempConfig to reduce duplication. - Add tests for multi-language mappings and config-file includes. - Replace placeholder issue URL with actual PR link. --- Sources/GhosttyTerminalView.swift | 121 +++++++++++++++------- cmuxTests/GhosttyConfigTests.swift | 158 ++++++++++++++++------------- 2 files changed, 173 insertions(+), 106 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 945b0e40..b1ceee1f 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -999,8 +999,8 @@ class GhosttyApp { ghostty_config_load_default_files(config) loadReleaseAppSupportGhosttyConfigIfNeeded(config) loadLegacyGhosttyConfigIfNeeded(config) - loadCJKFontFallbackIfNeeded(config) ghostty_config_load_recursive_files(config) + loadCJKFontFallbackIfNeeded(config) ghostty_config_finalize(config) } @@ -1009,23 +1009,24 @@ class GhosttyApp { /// a decorative calligraphic font) for CJK characters. This injects a /// sensible default based on the system's preferred languages. /// - /// See: https://github.com/manaflow-ai/cmux/issues/XXX + /// See: https://github.com/manaflow-ai/cmux/pull/1017 private func loadCJKFontFallbackIfNeeded(_ config: ghostty_config_t) { if Self.userConfigContainsCJKCodepointMap() { return } - guard let fontFamily = Self.preferredCJKFontFamily() else { return } + guard let mappings = Self.cjkFontMappings() else { return } - let lines = Self.cjkUnicodeRanges.map { range in - "font-codepoint-map = \(range)=\(fontFamily)" + let lines = mappings.map { range, font in + "font-codepoint-map = \(range)=\(font)" }.joined(separator: "\n") - let tmpPath = NSTemporaryDirectory() + "cmux-cjk-font-fallback.conf" + let tmpURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-cjk-font-fallback-\(UUID().uuidString).conf") do { - try lines.write(toFile: tmpPath, atomically: true, encoding: .utf8) - tmpPath.withCString { path in + 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) } - try? FileManager.default.removeItem(atPath: tmpPath) } catch { #if DEBUG Self.initLog("failed to write CJK font fallback config: \(error)") @@ -1033,40 +1034,69 @@ class GhosttyApp { } } - /// Unicode ranges that cover CJK characters, kana, and fullwidth forms. - private static let cjkUnicodeRanges = [ + /// 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+3040-U+309F", // Hiragana - "U+30A0-U+30FF", // Katakana "U+4E00-U+9FFF", // CJK Unified Ideographs "U+F900-U+FAFF", // CJK Compatibility Ideographs "U+FF00-U+FFEF", // Halfwidth and Fullwidth Forms - "U+AC00-U+D7AF", // Hangul Syllables - "U+1100-U+11FF", // Hangul Jamo "U+3400-U+4DBF", // CJK Unified Ideographs Extension A ] - /// Returns a suitable CJK font family name based on the user's preferred - /// system language, or nil if no CJK language is detected. - static func preferredCJKFontFamily( + /// 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, 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") { - return "Hiragino Sans" + 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 lower.hasPrefix("ko") { - return "Apple SD Gothic Neo" + + if !coveredShared { + for range in sharedCJKRanges { + mappings.append((range, font)) + } + coveredShared = true } - if lower.hasPrefix("zh-hant") || lower.hasPrefix("zh-tw") || lower.hasPrefix("zh-hk") { - return "PingFang TC" - } - if lower.hasPrefix("zh") { - return "PingFang SC" + + for range in langRanges { + mappings.append((range, font)) } } - return nil + + return mappings.isEmpty ? nil : mappings } /// Checks whether the user's Ghostty config files already contain @@ -1081,12 +1111,35 @@ class GhosttyApp { ) -> Bool { for rawPath in configPaths { let path = NSString(string: rawPath).expandingTildeInPath - guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { continue } - 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 Self.configFileContainsCodepointMap(atPath: path) { + return true + } + } + return false + } + + /// Scans a single config file (and any files it includes) for + /// `font-codepoint-map` entries. + private static func configFileContainsCodepointMap(atPath path: String) -> Bool { + guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { + return false + } + 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 { + let includePath = parts[1] + .trimmingCharacters(in: .whitespaces) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + let resolved = NSString(string: includePath).expandingTildeInPath + if configFileContainsCodepointMap(atPath: resolved) { + return true + } } } } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index dc8f1196..6d45da72 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1342,95 +1342,92 @@ final class GhosttyMouseFocusTests: XCTestCase { // MARK: - CJK Font Fallback - func testPreferredCJKFontFamilyReturnsHiraginoForJapanese() { - XCTAssertEqual( - GhosttyApp.preferredCJKFontFamily(preferredLanguages: ["ja-JP", "en-US"]), - "Hiragino Sans" - ) - XCTAssertEqual( - GhosttyApp.preferredCJKFontFamily(preferredLanguages: ["ja"]), - "Hiragino Sans" - ) + 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) } - func testPreferredCJKFontFamilyReturnsAppleSDGothicNeoForKorean() { - XCTAssertEqual( - GhosttyApp.preferredCJKFontFamily(preferredLanguages: ["ko-KR", "en-US"]), - "Apple SD Gothic Neo" - ) + // 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 testPreferredCJKFontFamilyReturnsPingFangForChinese() { - XCTAssertEqual( - GhosttyApp.preferredCJKFontFamily(preferredLanguages: ["zh-Hant-TW"]), - "PingFang TC" - ) - XCTAssertEqual( - GhosttyApp.preferredCJKFontFamily(preferredLanguages: ["zh-Hans-CN"]), - "PingFang SC" - ) - XCTAssertEqual( - GhosttyApp.preferredCJKFontFamily(preferredLanguages: ["zh-HK"]), - "PingFang TC" - ) + 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 testPreferredCJKFontFamilyReturnsNilForNonCJKLanguages() { - XCTAssertNil( - GhosttyApp.preferredCJKFontFamily(preferredLanguages: ["en-US", "fr-FR"]) - ) - XCTAssertNil( - GhosttyApp.preferredCJKFontFamily(preferredLanguages: []) - ) + 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 testPreferredCJKFontFamilyUsesFirstCJKLanguageInList() { - XCTAssertEqual( - GhosttyApp.preferredCJKFontFamily(preferredLanguages: ["en-US", "ko-KR", "ja-JP"]), - "Apple SD Gothic Neo" - ) + 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 { - let tmpDir = NSTemporaryDirectory() + "cmux-test-cjk-\(UUID().uuidString)/" - try FileManager.default.createDirectory(atPath: tmpDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(atPath: tmpDir) } - - let configWithMap = tmpDir + "config-with-map" - try "font-family = Menlo\nfont-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n" - .write(toFile: configWithMap, atomically: true, encoding: .utf8) - - XCTAssertTrue( - GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [configWithMap]) - ) + 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 { - let tmpDir = NSTemporaryDirectory() + "cmux-test-cjk-\(UUID().uuidString)/" - try FileManager.default.createDirectory(atPath: tmpDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(atPath: tmpDir) } - - let configWithoutMap = tmpDir + "config-no-map" - try "font-family = Menlo\nfont-size = 14\n" - .write(toFile: configWithoutMap, atomically: true, encoding: .utf8) - - XCTAssertFalse( - GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [configWithoutMap]) - ) + try withTempConfig("font-family = Menlo\nfont-size = 14\n") { path in + XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])) + } } func testUserConfigContainsCJKCodepointMapIgnoresComments() throws { - let tmpDir = NSTemporaryDirectory() + "cmux-test-cjk-\(UUID().uuidString)/" - try FileManager.default.createDirectory(atPath: tmpDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(atPath: tmpDir) } - - let configCommented = tmpDir + "config-commented" - try "# font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n" - .write(toFile: configCommented, atomically: true, encoding: .utf8) - - XCTAssertFalse( - GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [configCommented]) - ) + try withTempConfig("# font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in + XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])) + } } func testUserConfigContainsCJKCodepointMapReturnsFalseForMissingFiles() { @@ -1438,4 +1435,21 @@ final class GhosttyMouseFocusTests: XCTestCase { GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: ["/nonexistent/path/config"]) ) } + + 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])) + } } From a0ba82f8be95e95bd243c49e506f20ac70d7186b Mon Sep 17 00:00:00 2001 From: atani Date: Fri, 6 Mar 2026 23:28:59 +0900 Subject: [PATCH 03/21] fix: harden config-file include scanning - Add cycle detection via visited path set to prevent infinite recursion on cyclic config-file includes. - Resolve relative include paths against the parent directory of the including config file. - Strip trailing '?' from optional include paths (Ghostty convention). - Use UUID-based path for missing file test. - Add tests for relative includes, optional includes, and cyclic includes. --- Sources/GhosttyTerminalView.swift | 32 ++++++++++++++---- cmuxTests/GhosttyConfigTests.swift | 54 +++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index b1ceee1f..4d608fa0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1109,9 +1109,10 @@ class GhosttyApp { "~/Library/Application Support/com.mitchellh.ghostty/config.ghostty", ] ) -> Bool { + var visited = Set() for rawPath in configPaths { let path = NSString(string: rawPath).expandingTildeInPath - if Self.configFileContainsCodepointMap(atPath: path) { + if Self.configFileContainsCodepointMap(atPath: path, visited: &visited) { return true } } @@ -1119,11 +1120,21 @@ class GhosttyApp { } /// Scans a single config file (and any files it includes) for - /// `font-codepoint-map` entries. - private static func configFileContainsCodepointMap(atPath path: String) -> Bool { - guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { + /// `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 } @@ -1133,11 +1144,18 @@ class GhosttyApp { if trimmed.hasPrefix("config-file") { let parts = trimmed.split(separator: "=", maxSplits: 1) if parts.count == 2 { - let includePath = parts[1] + var includePath = parts[1] .trimmingCharacters(in: .whitespaces) .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - let resolved = NSString(string: includePath).expandingTildeInPath - if configFileContainsCodepointMap(atPath: resolved) { + // Ghostty supports optional includes with a trailing '?' + if includePath.hasSuffix("?") { + includePath = String(includePath.dropLast()) + } + 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 } } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 6d45da72..9193685e 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1431,8 +1431,9 @@ final class GhosttyMouseFocusTests: XCTestCase { } func testUserConfigContainsCJKCodepointMapReturnsFalseForMissingFiles() { + let path = NSTemporaryDirectory() + "cmux-nonexistent-\(UUID().uuidString)/config" XCTAssertFalse( - GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: ["/nonexistent/path/config"]) + GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]) ) } @@ -1452,4 +1453,55 @@ final class GhosttyMouseFocusTests: XCTestCase { 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])) + } } From 23202990df97dbcd22e4f6274bdf1f21b0915b13 Mon Sep 17 00:00:00 2001 From: atani Date: Sat, 7 Mar 2026 01:08:51 +0900 Subject: [PATCH 04/21] fix: scan app-support config paths for existing font-codepoint-map Include ~/Library/Application Support//config(.ghostty) paths in the codepoint-map detection scan. This ensures that font-codepoint-map entries in the release app-support config (loaded by loadReleaseAppSupportGhosttyConfigIfNeeded for debug builds) are detected before injecting CJK font fallback defaults. --- Sources/GhosttyTerminalView.swift | 37 +++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4d608fa0..42c2fbeb 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1100,14 +1100,10 @@ class GhosttyApp { } /// Checks whether the user's Ghostty config files already contain - /// a `font-codepoint-map` entry covering CJK ranges. + /// 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] = [ - "~/.config/ghostty/config", - "~/.config/ghostty/config.ghostty", - "~/Library/Application Support/com.mitchellh.ghostty/config", - "~/Library/Application Support/com.mitchellh.ghostty/config.ghostty", - ] + configPaths: [String] = defaultCJKScanPaths() ) -> Bool { var visited = Set() for rawPath in configPaths { @@ -1119,6 +1115,33 @@ class GhosttyApp { 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. From 7236ba8d8a82dc323bf277349a18eaf7b51c364b Mon Sep 17 00:00:00 2001 From: atani Date: Sat, 7 Mar 2026 01:19:44 +0900 Subject: [PATCH 05/21] fix: strip trailing '?' before removing quotes in include path parsing Reorder the trimming so that the optional include marker '?' is removed before surrounding quotes are stripped. This prevents quoted paths like "foo"? from being misparsed as foo". --- Sources/GhosttyTerminalView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 42c2fbeb..7d0a4be9 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1169,11 +1169,12 @@ class GhosttyApp { if parts.count == 2 { var includePath = parts[1] .trimmingCharacters(in: .whitespaces) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) // Ghostty supports optional includes with a trailing '?' if includePath.hasSuffix("?") { - includePath = String(includePath.dropLast()) + includePath.removeLast() } + includePath = includePath + .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) let expanded = NSString(string: includePath).expandingTildeInPath let absolute = (expanded as NSString).isAbsolutePath ? expanded From 9676f393575fe5ed825429601e780e379b359a2e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 7 Mar 2026 13:32:17 -0800 Subject: [PATCH 06/21] docs: add changelog entries for 0.62.0 --- CHANGELOG.md | 97 +++++++++++++++++++++++ web/app/docs/changelog/changelog-media.ts | 30 +++++++ 2 files changed, 127 insertions(+) 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/web/app/docs/changelog/changelog-media.ts b/web/app/docs/changelog/changelog-media.ts index 77cf63fe..f180ca6f 100644 --- a/web/app/docs/changelog/changelog-media.ts +++ b/web/app/docs/changelog/changelog-media.ts @@ -26,6 +26,36 @@ export interface VersionMedia { } export const changelogMedia: Record = { + "0.62.0": { + title: "Markdown Viewer, Browser Find, Vi Copy Mode, and Localization", + features: [ + { + title: "Markdown Viewer", + description: + "Open Markdown files in their own panel and keep them live with file watching. Notes, READMEs, and docs refresh automatically as the file changes on disk.", + }, + { + title: "Find in Browser", + description: + "Browser panels now support Cmd+F with inline find controls, so you can search long docs, dashboards, and issue threads without leaving cmux.", + }, + { + title: "Vi Copy Mode", + description: + "Terminal scrollback now has a keyboard copy mode with vi-style navigation, making it much easier to inspect and copy from large output buffers.", + }, + { + title: "Custom Notification Sounds", + description: + "Choose from bundled sounds or pick your own audio file so background task notifications are easier to notice and easier to personalize.", + }, + { + title: "Expanded Localization", + description: + "cmux now includes Japanese plus 16 additional languages, and a per-app language override lets you change the UI language without changing macOS system settings.", + }, + ], + }, "0.61.0": { title: "Tab Colors, Command Palette, Pin Workspaces", features: [ From cee109bab01b2c803f157e5bd00686a5ac9f53ac Mon Sep 17 00:00:00 2001 From: atani Date: Sun, 8 Mar 2026 23:03:38 +0900 Subject: [PATCH 07/21] docs: clarify CJK font fallback comment with environment-dependent behavior The fallback issue is caused by CTFontCollection scoring prioritizing monospace fonts, not just CTFontCreateForString. The selected decorative font varies by environment (e.g. AB_appare from Adobe CC, or LingWai). --- Sources/GhosttyTerminalView.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 7d0a4be9..5889315c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1005,9 +1005,12 @@ class GhosttyApp { } /// When the user has not configured `font-codepoint-map` for CJK ranges, - /// macOS Core Text may pick an inappropriate fallback font (e.g. LingWai, - /// a decorative calligraphic font) for CJK characters. This injects a - /// sensible default based on the system's preferred languages. + /// 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) { From a636104fb92a59655a658d3b22909f0ae4f2d8e2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 8 Mar 2026 21:51:03 -0700 Subject: [PATCH 08/21] Bump version to 0.62.0 (build 74) --- GhosttyTabs.xcodeproj/project.pbxproj | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) 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)"; From 3a3a2da6dbe609b3f6761859c73730aa109f17be Mon Sep 17 00:00:00 2001 From: Gonzalo Serrano Date: Mon, 9 Mar 2026 15:01:02 +0100 Subject: [PATCH 09/21] Add color field to sidebar_state output Expose workspace customColor in sidebar_state so external tools can read it without a new API endpoint. --- Sources/TerminalController.swift | 1 + 1 file changed, 1 insertion(+) 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, From c447bee6026a88b6ea68b83ee42ad170d2ba597c Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 9 Mar 2026 13:28:05 -0700 Subject: [PATCH 10/21] Fix browser pane portal anchor sizing (#1094) * Fix browser pane webview sizing * Guard browser portal by pane ownership * Keep browser portal frame during transient zero geometry * Guard portal surfaces against duplicate hosts * Defer terminal surface reflow during tab drags * Fix browser panel pane ID unit tests --- Sources/BrowserWindowPortal.swift | 55 +++++- Sources/GhosttyTerminalView.swift | 183 ++++++++++++++++-- Sources/Panels/BrowserPanel.swift | 95 +++++++++ Sources/Panels/BrowserPanelView.swift | 173 ++++++++++++----- Sources/Panels/PanelContentView.swift | 3 + Sources/Workspace.swift | 21 -- Sources/WorkspaceContentView.swift | 1 + cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 4 + 8 files changed, 444 insertions(+), 91 deletions(-) diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 97d90f6a..3b73b683 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() diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4e2139e4..5f3c1826 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2052,8 +2052,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 { @@ -2144,6 +2150,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 } @@ -2902,6 +2992,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) { @@ -3246,6 +3338,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 } @@ -3265,6 +3362,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))" @@ -5074,6 +5185,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) @@ -6938,18 +7056,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.setKeyboardCopyModeIndicator(visible: terminalSurface.keyboardCopyModeActive) 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.setKeyboardCopyModeIndicator(visible: terminalSurface.keyboardCopyModeActive) + } let portalExpectedSurfaceId = terminalSurface.id let portalExpectedGeneration = terminalSurface.portalBindingGeneration() let forwardedDropZone = isVisibleInUI ? paneDropZone : nil @@ -6972,16 +7102,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, @@ -7000,9 +7137,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)) " + @@ -7018,7 +7162,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) @@ -7027,7 +7171,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) @@ -7062,7 +7206,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. @@ -7087,7 +7231,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 ) @@ -7102,7 +7246,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)" ) @@ -7140,6 +7285,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..31961ce2 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,94 @@ 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 + } + + func releasePortalHostIfOwned(hostId: ObjectIdentifier, reason: String) { + guard let current = activePortalHostLease, current.hostId == hostId else { return } + 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 + } + var displayIcon: String? { "globe" } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 05dfaf2e..4f8ff9f5 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,83 @@ 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 { + if !shouldAttachWebView || !isCurrentPaneOwner { + panel.releasePortalHostIfOwned( + hostId: hostId, + reason: !isCurrentPaneOwner ? "lostPaneOwnership" : "hidden" + ) + } + 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)" + ) + } +#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 +4376,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 +4407,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 +4421,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 +4443,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 +4451,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 +4462,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 +4489,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 +4503,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 +4602,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/Workspace.swift b/Sources/Workspace.swift index 1b649937..7a4bb58a 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( @@ -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..1f8981b6 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, From fc8142d61d6490307315ac94da91724dc752fbfd Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 9 Mar 2026 16:05:24 -0700 Subject: [PATCH 11/21] Fix pinned workspace notification reordering (#1116) * test: cover pinned workspace notification reorder * fix: keep pinned workspace order on notification --- Sources/TabManager.swift | 10 ++++ Sources/TerminalNotificationStore.swift | 21 +++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 52 +++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 0920d588..7afd48ae 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) } diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 5bb768cb..9cdcf1f9 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() { @@ -843,7 +848,7 @@ final class TerminalNotificationStore: ObservableObject { } if WorkspaceAutoReorderSettings.isEnabled() { - AppDelegate.shared?.tabManager?.moveTabToTop(tabId) + AppDelegate.shared?.tabManager?.moveTabToTopForNotification(tabId) } let notification = TerminalNotification( @@ -862,7 +867,7 @@ final class TerminalNotificationStore: ObservableObject { center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } - scheduleUserNotification(notification) + notificationDeliveryHandler(self, notification) } func markRead(id: UUID) { @@ -1233,6 +1238,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/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 1f8981b6..724d1fa0 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -4233,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() { From 4ba967556ec972f5de9bde1bc07d179a8138c1c8 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 9 Mar 2026 16:35:05 -0700 Subject: [PATCH 12/21] Fix cmux --version memory blowup (#1121) * Add version memory guard regression test * Fix cmux --version plist lookup blowup --- CLI/cmux.swift | 66 ++++++--- tests/test_cli_version_memory_guard.py | 196 +++++++++++++++++++++++++ 2 files changed, 238 insertions(+), 24 deletions(-) create mode 100644 tests/test_cli_version_memory_guard.py 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/tests/test_cli_version_memory_guard.py b/tests/test_cli_version_memory_guard.py new file mode 100644 index 00000000..0a1c5bd1 --- /dev/null +++ b/tests/test_cli_version_memory_guard.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +Regression test: `cmux --version` must not scan huge sibling app lists just to +resolve optional version metadata. +""" + +from __future__ import annotations + +import glob +import os +import plistlib +import shutil +import subprocess +import tempfile +import time + + +JUNK_APP_COUNT = 40000 +RSS_LIMIT_KB = 64 * 1024 +TIMEOUT_SECONDS = 10.0 +EXPECTED_STDOUT = "cmux 9.9.9 (999)" + + +def resolve_cmux_cli() -> str: + explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI") + if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK): + return explicit + + candidates: list[str] = [] + candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux"))) + candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")) + candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)] + if candidates: + candidates.sort(key=os.path.getmtime, reverse=True) + return candidates[0] + + in_path = shutil.which("cmux") + if in_path: + return in_path + + raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.") + + +def copy_runtime_frameworks(cli_path: str, fixture_contents: str) -> None: + frameworks_dir = os.path.join(fixture_contents, "Frameworks") + os.makedirs(frameworks_dir, exist_ok=True) + + search_roots: list[str] = [] + current = os.path.dirname(cli_path) + for _ in range(4): + search_roots.append(os.path.join(current, "Frameworks")) + search_roots.append(os.path.join(current, "PackageFrameworks")) + parent = os.path.dirname(current) + if parent == current: + break + current = parent + + for search_root in search_roots: + sentry_framework = os.path.join(search_root, "Sentry.framework") + if os.path.isdir(sentry_framework): + shutil.copytree(sentry_framework, os.path.join(frameworks_dir, "Sentry.framework")) + return + + +def build_fixture(root: str, cli_path: str) -> str: + app_path = os.path.join(root, "cmux.app") + contents_path = os.path.join(app_path, "Contents") + resources_path = os.path.join(contents_path, "Resources") + bin_path = os.path.join(resources_path, "bin") + os.makedirs(bin_path, exist_ok=True) + + fixture_cli = os.path.join(bin_path, "cmux") + shutil.copy2(cli_path, fixture_cli) + copy_runtime_frameworks(cli_path, contents_path) + + info = { + "CFBundleExecutable": "cmux", + "CFBundleIdentifier": "test.cmux.version-memory-guard", + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": "9.9.9", + "CFBundleVersion": "999", + } + with open(os.path.join(contents_path, "Info.plist"), "wb") as handle: + plistlib.dump(info, handle) + + for index in range(JUNK_APP_COUNT): + open(os.path.join(resources_path, f"junk-{index:05d}.app"), "wb").close() + + return fixture_cli + + +def run_with_limits(cli_path: str, *args: str) -> dict[str, object]: + env = dict(os.environ) + env.pop("CMUX_COMMIT", None) + + proc = subprocess.Popen( + [cli_path, *args], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + + started = time.time() + peak_rss_kb = 0 + failure_reason: str | None = None + + while True: + exit_code = proc.poll() + if exit_code is not None: + stdout, stderr = proc.communicate() + return { + "exit_code": exit_code, + "stdout": stdout.strip(), + "stderr": stderr.strip(), + "elapsed": time.time() - started, + "peak_rss_kb": peak_rss_kb, + "failure_reason": None, + } + + try: + rss_kb = int( + subprocess.check_output( + ["ps", "-o", "rss=", "-p", str(proc.pid)], + text=True, + ).strip() + or "0" + ) + except subprocess.CalledProcessError: + rss_kb = 0 + + peak_rss_kb = max(peak_rss_kb, rss_kb) + elapsed = time.time() - started + + if rss_kb > RSS_LIMIT_KB: + failure_reason = f"rss limit exceeded ({rss_kb} KB > {RSS_LIMIT_KB} KB)" + elif elapsed > TIMEOUT_SECONDS: + failure_reason = f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)" + + if failure_reason: + proc.kill() + stdout, stderr = proc.communicate() + return { + "exit_code": proc.returncode, + "stdout": stdout.strip(), + "stderr": stderr.strip(), + "elapsed": elapsed, + "peak_rss_kb": peak_rss_kb, + "failure_reason": failure_reason, + } + + time.sleep(0.05) + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + with tempfile.TemporaryDirectory(prefix="cmux-version-memory-guard-") as root: + fixture_cli = build_fixture(root, cli_path) + result = run_with_limits(fixture_cli, "--version") + + if result["failure_reason"]: + print("FAIL: `cmux --version` exceeded runtime guard") + print(f"reason={result['failure_reason']}") + print(f"elapsed={result['elapsed']:.2f}s") + print(f"peak_rss_kb={result['peak_rss_kb']}") + print(f"stdout={result['stdout']}") + print(f"stderr={result['stderr']}") + return 1 + + if result["exit_code"] != 0: + print("FAIL: `cmux --version` exited non-zero") + print(f"exit={result['exit_code']}") + print(f"stdout={result['stdout']}") + print(f"stderr={result['stderr']}") + return 1 + + if result["stdout"] != EXPECTED_STDOUT: + print("FAIL: unexpected version output") + print(f"stdout={result['stdout']!r}") + print(f"expected={EXPECTED_STDOUT!r}") + return 1 + + print( + "PASS: `cmux --version` exits within memory/time limits " + f"(peak_rss_kb={result['peak_rss_kb']}, elapsed={result['elapsed']:.2f}s)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From abbc300cd0e8343431ecee5fa512d52a67dc0f34 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:51:05 -0700 Subject: [PATCH 13/21] Add browser URL scheme regression test --- .../UpdatePillReleaseVisibilityTests.swift | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 319c350f..00984571 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -103,6 +103,68 @@ final class AppTransportSecurityTests: XCTestCase { } } +final class AppBrowserURLSchemeTests: XCTestCase { + func testInfoPlistRegistersHTTPAndHTTPSAsHandledSchemes() 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 urlTypes = try XCTUnwrap(plist["CFBundleURLTypes"] as? [[String: Any]]) + + let schemes = Set( + urlTypes + .flatMap { $0["CFBundleURLSchemes"] as? [String] ?? [] } + .map(\.lowercased) + ) + + XCTAssertTrue( + schemes.contains("http"), + "Resources/Info.plist must register the http URL scheme so macOS can treat cmux as a browser candidate." + ) + XCTAssertTrue( + schemes.contains("https"), + "Resources/Info.plist must register the https URL scheme so macOS can treat cmux as a browser candidate." + ) + + let browserURLTypes = urlTypes.filter { urlType in + let urlSchemes = Set((urlType["CFBundleURLSchemes"] as? [String] ?? []).map(\.lowercased)) + return !urlSchemes.isDisjoint(with: ["http", "https"]) + } + XCTAssertFalse( + browserURLTypes.isEmpty, + "Resources/Info.plist must include a browser URL type entry for http and https." + ) + + for urlType in browserURLTypes { + XCTAssertEqual( + urlType["CFBundleTypeRole"] as? String, + "Viewer", + "Browser URL schemes should be declared with the Viewer role." + ) + XCTAssertEqual( + urlType["LSHandlerRank"] as? String, + "Default", + "Browser URL schemes should advertise a default-handler rank." + ) + } + } + + 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( From 1a90a48bbb33ab9bf7c207d9088a390b1bc4470f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:51:08 -0700 Subject: [PATCH 14/21] Register http and https URL schemes --- Resources/Info.plist | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 From 17f89bc12e11d52e145642be424055bd4911c2aa Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:56:38 -0700 Subject: [PATCH 15/21] Fix browser scheme regression test compile --- cmuxTests/UpdatePillReleaseVisibilityTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 00984571..099237ef 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -117,7 +117,7 @@ final class AppBrowserURLSchemeTests: XCTestCase { let schemes = Set( urlTypes .flatMap { $0["CFBundleURLSchemes"] as? [String] ?? [] } - .map(\.lowercased) + .map { $0.lowercased() } ) XCTAssertTrue( @@ -130,7 +130,7 @@ final class AppBrowserURLSchemeTests: XCTestCase { ) let browserURLTypes = urlTypes.filter { urlType in - let urlSchemes = Set((urlType["CFBundleURLSchemes"] as? [String] ?? []).map(\.lowercased)) + let urlSchemes = Set((urlType["CFBundleURLSchemes"] as? [String] ?? []).map { $0.lowercased() }) return !urlSchemes.isDisjoint(with: ["http", "https"]) } XCTAssertFalse( From 879fb05969f11d2fed877594bf26ef1f9c1dd3d0 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:07:38 -0700 Subject: [PATCH 16/21] Clarify source-shape test policy --- CLAUDE.md | 3 +++ 1 file changed, 3 insertions(+) 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 From 8b6562775062f89603bef4936c85fe7fb1b1a8d2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:14:22 -0700 Subject: [PATCH 17/21] Remove source-shape regression tests --- cmuxTests/GhosttyConfigTests.swift | 46 ---- .../UpdatePillReleaseVisibilityTests.swift | 199 ----------------- ...test_browser_chrome_contrast_regression.py | 126 ----------- ...er_console_errors_cli_output_regression.py | 86 -------- ...est_browser_devtools_portal_regressions.py | 92 -------- ...t_browser_eval_async_wrapper_regression.py | 128 ----------- ...test_browser_eval_cli_output_regression.py | 89 -------- ...t_browser_favicon_navigation_regression.py | 84 -------- ..._browser_find_overlay_portal_regression.py | 203 ------------------ ...owser_omnibar_compact_layout_regression.py | 125 ----------- ...t_browser_portal_lifecycle_architecture.py | 94 -------- tests/test_cli_socket_sentry_scope.py | 115 ---------- tests/test_cli_subcommand_help_regressions.py | 147 ------------- tests/test_cli_tree_command.py | 149 ------------- tests/test_cli_version_commit_metadata.py | 85 -------- ..._command_palette_socket_restart_command.py | 118 ---------- tests/test_command_palette_update_commands.py | 126 ----------- tests/test_ctrl_enter_keybind.py | 159 -------------- ..._focus_panel_reentrant_guard_regression.py | 64 ------ ...ssue_494_sleep_wake_git_branch_recovery.py | 166 -------------- ..._issue_582_sidebar_git_branch_fast_path.py | 126 ----------- ...sue_666_sidebar_branch_checkout_refresh.py | 105 --------- ...test_issue_952_socket_listener_recovery.py | 162 -------------- tests/test_lint_swiftui_patterns.py | 190 ---------------- tests/test_markdown_open_regressions.py | 126 ----------- tests/test_microphone_access_metadata.py | 79 ------- tests/test_sidebar_indicator_default.py | 46 ---- ...test_terminal_resize_portal_regressions.py | 113 ---------- tests/test_update_timing.py | 54 ----- 29 files changed, 3402 deletions(-) delete mode 100644 tests/test_browser_chrome_contrast_regression.py delete mode 100644 tests/test_browser_console_errors_cli_output_regression.py delete mode 100644 tests/test_browser_devtools_portal_regressions.py delete mode 100644 tests/test_browser_eval_async_wrapper_regression.py delete mode 100644 tests/test_browser_eval_cli_output_regression.py delete mode 100644 tests/test_browser_favicon_navigation_regression.py delete mode 100644 tests/test_browser_find_overlay_portal_regression.py delete mode 100644 tests/test_browser_omnibar_compact_layout_regression.py delete mode 100755 tests/test_browser_portal_lifecycle_architecture.py delete mode 100644 tests/test_cli_socket_sentry_scope.py delete mode 100644 tests/test_cli_subcommand_help_regressions.py delete mode 100644 tests/test_cli_tree_command.py delete mode 100644 tests/test_cli_version_commit_metadata.py delete mode 100644 tests/test_command_palette_socket_restart_command.py delete mode 100755 tests/test_command_palette_update_commands.py delete mode 100644 tests/test_ctrl_enter_keybind.py delete mode 100644 tests/test_focus_panel_reentrant_guard_regression.py delete mode 100644 tests/test_issue_494_sleep_wake_git_branch_recovery.py delete mode 100644 tests/test_issue_582_sidebar_git_branch_fast_path.py delete mode 100644 tests/test_issue_666_sidebar_branch_checkout_refresh.py delete mode 100644 tests/test_issue_952_socket_listener_recovery.py delete mode 100644 tests/test_lint_swiftui_patterns.py delete mode 100644 tests/test_markdown_open_regressions.py delete mode 100644 tests/test_microphone_access_metadata.py delete mode 100644 tests/test_sidebar_indicator_default.py delete mode 100644 tests/test_terminal_resize_portal_regressions.py delete mode 100644 tests/test_update_timing.py diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 53f988aa..53119273 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) diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 099237ef..96826edf 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -8,163 +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 AppBrowserURLSchemeTests: XCTestCase { - func testInfoPlistRegistersHTTPAndHTTPSAsHandledSchemes() 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 urlTypes = try XCTUnwrap(plist["CFBundleURLTypes"] as? [[String: Any]]) - - let schemes = Set( - urlTypes - .flatMap { $0["CFBundleURLSchemes"] as? [String] ?? [] } - .map { $0.lowercased() } - ) - - XCTAssertTrue( - schemes.contains("http"), - "Resources/Info.plist must register the http URL scheme so macOS can treat cmux as a browser candidate." - ) - XCTAssertTrue( - schemes.contains("https"), - "Resources/Info.plist must register the https URL scheme so macOS can treat cmux as a browser candidate." - ) - - let browserURLTypes = urlTypes.filter { urlType in - let urlSchemes = Set((urlType["CFBundleURLSchemes"] as? [String] ?? []).map { $0.lowercased() }) - return !urlSchemes.isDisjoint(with: ["http", "https"]) - } - XCTAssertFalse( - browserURLTypes.isEmpty, - "Resources/Info.plist must include a browser URL type entry for http and https." - ) - - for urlType in browserURLTypes { - XCTAssertEqual( - urlType["CFBundleTypeRole"] as? String, - "Viewer", - "Browser URL schemes should be declared with the Viewer role." - ) - XCTAssertEqual( - urlType["LSHandlerRank"] as? String, - "Default", - "Browser URL schemes should advertise a default-handler rank." - ) - } - } - - 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( @@ -334,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