diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e5f08df..e7b821d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -172,6 +172,23 @@ jobs: fi fi + - name: Run CLI version memory guard regression + run: | + set -euo pipefail + + CLI_BIN="$( + find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Debug/cmux" -exec stat -f '%m %N' {} \; \ + | sort -nr \ + | head -1 \ + | cut -d' ' -f2- + )" + if [ -z "${CLI_BIN:-}" ] || [ ! -x "$CLI_BIN" ]; then + echo "cmux CLI binary not found in DerivedData" >&2 + exit 1 + fi + + CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_version_memory_guard.py + tests-depot: # Never run Depot jobs for fork pull requests (avoid billing on external PRs). if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 1fb75cf1..c175487d 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -199,6 +199,13 @@ jobs: [[ "$APP_ARCHS" == *arm64* && "$APP_ARCHS" == *x86_64* ]] [[ "$CLI_ARCHS" == *arm64* && "$CLI_ARCHS" == *x86_64* ]] + - name: Run CLI version memory guard regression + run: | + set -euo pipefail + CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux" + [ -x "$CLI_BINARY" ] || { echo "cmux CLI binary not found at $CLI_BINARY" >&2; exit 1; } + CMUX_CLI_BIN="$CLI_BINARY" python3 tests/test_cli_version_memory_guard.py + - name: Check whether build commit is still current main HEAD if: needs.decide.outputs.should_publish == 'true' id: current_head diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd59b8d1..bce4327c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -157,6 +157,14 @@ jobs: plutil -remove CMUXRemoteDaemonManifestJSON "$APP_PLIST" >/dev/null 2>&1 || true plutil -insert CMUXRemoteDaemonManifestJSON -string "$MANIFEST_JSON" "$APP_PLIST" + - name: Run CLI version memory guard regression + if: steps.guard_release_assets.outputs.skip_all != 'true' + run: | + set -euo pipefail + CLI_BINARY="build/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux" + [ -x "$CLI_BINARY" ] || { echo "cmux CLI binary not found at $CLI_BINARY" >&2; exit 1; } + CMUX_CLI_BIN="$CLI_BINARY" python3 tests/test_cli_version_memory_guard.py + - name: Inject Sparkle keys into Info.plist if: steps.guard_release_assets.outputs.skip_all != 'true' run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 7662a1ac..abae3664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to cmux are documented here. -## [0.62.0] - 2026-03-07 +## [0.62.0] - 2026-03-12 ### Added - Markdown viewer panel with live file watching ([#883](https://github.com/manaflow-ai/cmux/pull/883)) @@ -30,6 +30,12 @@ All notable changes to cmux are documented here. - 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)) +- Vim mode indicator badge on terminal panes ([#1092](https://github.com/manaflow-ai/cmux/pull/1092)) +- Sidebar workspace color in CLI sidebar_state output ([#1101](https://github.com/manaflow-ai/cmux/pull/1101)) +- Prompt before closing window with Cmd+Ctrl+W ([#1219](https://github.com/manaflow-ai/cmux/pull/1219)) +- Jump to Latest button in notifications popover ([#1167](https://github.com/manaflow-ai/cmux/pull/1167)) +- Khmer localization ([#1198](https://github.com/manaflow-ai/cmux/pull/1198)) +- cmux claude-teams launcher ([#1179](https://github.com/manaflow-ai/cmux/pull/1179)) ### Changed - Command palette search is now async and decoupled from typing for reduced lag @@ -41,6 +47,9 @@ All notable changes to cmux are documented here. - 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)) +- Updated Ghostty to v1.3.0 ([#1142](https://github.com/manaflow-ai/cmux/pull/1142)) +- Welcome screen colors adapted for light mode ([#1214](https://github.com/manaflow-ai/cmux/pull/1214)) +- Notification sound picker width constrained ([#1168](https://github.com/manaflow-ai/cmux/pull/1168)) ### 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)) @@ -75,14 +84,37 @@ All notable changes to cmux are documented here. - 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)) +- CJK font fallback preventing decorative font rendering for CJK characters ([#1017](https://github.com/manaflow-ai/cmux/pull/1017)) +- Inline VS Code serve-web token exposure via argv ([#1033](https://github.com/manaflow-ai/cmux/pull/1033)) +- Browser pane portal anchor sizing ([#1094](https://github.com/manaflow-ai/cmux/pull/1094)) +- Pinned workspace notification reordering ([#1116](https://github.com/manaflow-ai/cmux/pull/1116)) +- cmux --version memory blowup ([#1121](https://github.com/manaflow-ai/cmux/pull/1121)) +- Notification ring dismissal on direct terminal clicks ([#1126](https://github.com/manaflow-ai/cmux/pull/1126)) +- Browser portal visibility when terminal tab is active ([#1130](https://github.com/manaflow-ai/cmux/pull/1130)) +- Browser panes reloading when switching workspaces ([#1136](https://github.com/manaflow-ai/cmux/pull/1136)) +- Sidebar PR badge detection ([#1139](https://github.com/manaflow-ai/cmux/pull/1139)) +- Browser address bar disappearing during pane zoom ([#1145](https://github.com/manaflow-ai/cmux/pull/1145)) +- Ghost terminal surface focus after split close ([#1148](https://github.com/manaflow-ai/cmux/pull/1148)) +- Browser DevTools resize loop and layout stability ([#1170](https://github.com/manaflow-ai/cmux/pull/1170), [#1173](https://github.com/manaflow-ai/cmux/pull/1173), [#1189](https://github.com/manaflow-ai/cmux/pull/1189)) +- Typing lag from sidebar re-evaluation and hitTest overhead ([#1204](https://github.com/manaflow-ai/cmux/issues/1204)) +- Browser pane stale content after drag splits ([#1215](https://github.com/manaflow-ai/cmux/pull/1215)) +- Terminal drop overlay misplacement during drag hover ([#1213](https://github.com/manaflow-ai/cmux/pull/1213)) +- Hidden browser slot inspector focus crash ([#1211](https://github.com/manaflow-ai/cmux/pull/1211)) +- Browser devtools hide fallback ([#1220](https://github.com/manaflow-ai/cmux/pull/1220)) +- Browser portal refresh on geometry churn ([#1224](https://github.com/manaflow-ai/cmux/pull/1224)) +- Browser tab switch triggering unnecessary reload ([#1228](https://github.com/manaflow-ai/cmux/pull/1228)) +- Devtools side dock guard for attached devtools ([#1230](https://github.com/manaflow-ai/cmux/pull/1230)) -### Thanks to 21 contributors! +### Thanks to 24 contributors! +- [@0xble](https://github.com/0xble) - [@afxjzs](https://github.com/afxjzs) - [@AI-per](https://github.com/AI-per) - [@atani](https://github.com/atani) +- [@atmigtnca](https://github.com/atmigtnca) - [@austinywang](https://github.com/austinywang) - [@cheulyop](https://github.com/cheulyop) - [@ConnorCallison](https://github.com/ConnorCallison) +- [@gonzaloserrano](https://github.com/gonzaloserrano) - [@harukitosa](https://github.com/harukitosa) - [@homanp](https://github.com/homanp) - [@JLeeChan](https://github.com/JLeeChan) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 543db4b5..6329c5d4 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -8925,6 +8925,7 @@ struct CMUXCLI { \(bold)Shortcuts\(reset) \(bold)\u{2318}N\(reset)\(subdued) New workspace\(reset) + \(bold)\u{2318}T\(reset)\(subdued) New tab\(reset) \(bold)\u{2318}P\(reset)\(subdued) Go to workspace\(reset) \(bold)\u{2318}D\(reset)\(subdued) Split right\(reset) \(bold)\u{2318}\u{21E7}D\(reset)\(subdued) Split down\(reset) @@ -9023,7 +9024,7 @@ struct CMUXCLI { } let fileManager = FileManager.default - var current = executableURL.deletingLastPathComponent() + var current = executableURL.deletingLastPathComponent().standardizedFileURL while true { let projectFile = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj") @@ -9044,8 +9045,7 @@ struct CMUXCLI { } } - let parent = current.deletingLastPathComponent() - if parent.path == current.path { + guard let parent = parentSearchURL(for: current) else { break } current = parent @@ -9114,6 +9114,22 @@ struct CMUXCLI { return String(normalized.prefix(12)) } + // Foundation can walk past "/" into "/.." when repeatedly deleting path + // components, so stop once the canonical root is reached. + private func parentSearchURL(for url: URL) -> URL? { + let standardized = url.standardizedFileURL + let path = standardized.path + guard !path.isEmpty, path != "/" else { + return nil + } + + let parent = standardized.deletingLastPathComponent().standardizedFileURL + guard parent.path != path else { + return nil + } + return parent + } + private func candidateInfoPlistURLs() -> [URL] { guard let executableURL = resolvedExecutableURL() else { return [] @@ -9131,7 +9147,7 @@ struct CMUXCLI { candidates.append(url) } - var current = executableURL.deletingLastPathComponent() + var current = executableURL.deletingLastPathComponent().standardizedFileURL while true { if current.pathExtension == "app" { appendIfExisting(current.appendingPathComponent("Contents/Info.plist")) @@ -9148,8 +9164,7 @@ struct CMUXCLI { break } - let parent = current.deletingLastPathComponent() - if parent.path == current.path { + guard let parent = parentSearchURL(for: current) else { break } current = parent @@ -9162,8 +9177,8 @@ struct CMUXCLI { } let searchRoots = [ - executableURL.deletingLastPathComponent(), - executableURL.deletingLastPathComponent().deletingLastPathComponent() + executableURL.deletingLastPathComponent().standardizedFileURL, + executableURL.deletingLastPathComponent().deletingLastPathComponent().standardizedFileURL ] for root in searchRoots { guard let entries = fileManager.enumerator( diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index f86d33cd..67318d7f 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -92,6 +92,7 @@ F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; + F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; }; A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; }; A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; }; DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; }; @@ -235,8 +236,9 @@ F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; }; F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; - F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; - A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; }; + F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; + F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = ""; }; + A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; }; A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; }; DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; @@ -469,6 +471,7 @@ F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, + F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */, A5008380 /* BrowserFindJavaScriptTests.swift */, A5008382 /* CommandPaletteSearchEngineTests.swift */, ); @@ -707,6 +710,7 @@ F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, + F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */, A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */, ); @@ -810,7 +814,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 74; + CURRENT_PROJECT_VERSION = 75; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -849,7 +853,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 74; + CURRENT_PROJECT_VERSION = 75; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = NO; @@ -925,7 +929,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 74; + CURRENT_PROJECT_VERSION = 75; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.62.0; @@ -942,7 +946,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 74; + CURRENT_PROJECT_VERSION = 75; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.62.0; @@ -959,7 +963,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 74; + CURRENT_PROJECT_VERSION = 75; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.62.0; @@ -978,7 +982,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 74; + CURRENT_PROJECT_VERSION = 75; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.62.0; diff --git a/README.ar.md b/README.ar.md index 82a77bd0..86dddcd9 100644 --- a/README.ar.md +++ b/README.ar.md @@ -10,7 +10,7 @@

- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe + English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ

diff --git a/README.bs.md b/README.bs.md index 603978dc..6782fa3a 100644 --- a/README.bs.md +++ b/README.bs.md @@ -10,7 +10,7 @@

- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe + English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ

diff --git a/README.da.md b/README.da.md index 588d9c09..db36c1df 100644 --- a/README.da.md +++ b/README.da.md @@ -10,7 +10,7 @@

- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe + English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ

diff --git a/README.de.md b/README.de.md index b04fd471..68bb81ff 100644 --- a/README.de.md +++ b/README.de.md @@ -10,7 +10,7 @@

- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe + English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ

diff --git a/README.es.md b/README.es.md index 503d376a..1159c79e 100644 --- a/README.es.md +++ b/README.es.md @@ -10,7 +10,7 @@

- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe + English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ

diff --git a/README.fr.md b/README.fr.md index 59c049b8..81cba423 100644 --- a/README.fr.md +++ b/README.fr.md @@ -10,7 +10,7 @@

- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe + English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ

diff --git a/README.it.md b/README.it.md index a6546587..cfc97d8a 100644 --- a/README.it.md +++ b/README.it.md @@ -10,7 +10,7 @@

- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe + English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ

diff --git a/README.ja.md b/README.ja.md index fabe91d4..9d3bdc50 100644 --- a/README.ja.md +++ b/README.ja.md @@ -10,7 +10,7 @@

- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe + English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ

diff --git a/README.km.md b/README.km.md index 65f245a3..f083816c 100644 --- a/README.km.md +++ b/README.km.md @@ -1,3 +1,5 @@ +> ការបកប្រែនេះត្រូវបានបង្កើតដោយ Claude។ ប្រសិនបើអ្នកមានការកែលម្អ សូមបង្កើត PR។ +

cmux

Terminal សម្រាប់ macOS ផ្អែកលើ Ghostty ដែលមាន tab បញ្ឈរ និងការជូនដំណឹងសម្រាប់ AI coding agents

@@ -29,8 +31,8 @@
-

Notification rings

-Panes get a blue ring and tabs light up when coding agents need your attention +

រង្វង់ជូនដំណឹង (Notification rings)

+ផ្ទាំង (Panes) នឹងមានរង្វង់ពណ៌ខៀវ ហើយ tabs នឹងភ្លឺឡើង នៅពេល coding agents ត្រូវការការយកចិត្តទុកដាក់របស់អ្នក
Notification rings @@ -38,8 +40,8 @@ Panes get a blue ring and tabs light up when coding agents need your attention
-

Notification panel

-See all pending notifications in one place, jump to the most recent unread +

ផ្ទាំងជូនដំណឹង (Notification panel)

+មើលការជូនដំណឹងដែលកំពុងរង់ចាំទាំងអស់នៅកន្លែងតែមួយ លោតទៅកាន់សារមិនទាន់អានថ្មីបំផុត
Sidebar notification badge @@ -47,8 +49,8 @@ See all pending notifications in one place, jump to the most recent unread
-

In-app browser

-Split a browser alongside your terminal with a scriptable API ported from agent-browser +

កម្មវិធីរុករកក្នុងកម្មវិធី (In-app browser)

+បំបែកកម្មវិធីរុករកនៅក្បែរ terminal របស់អ្នកជាមួយ scriptable API ដែលបានយកចេញពី agent-browser
Built-in browser @@ -56,8 +58,8 @@ Split a browser alongside your terminal with a scriptable API ported from
-

Vertical + horizontal tabs

-Sidebar shows git branch, linked PR status/number, working directory, listening ports, and latest notification text. Split horizontally and vertically. +

Tab បញ្ឈរ + ផ្ដេក (Vertical + horizontal tabs)

+របារចំហៀងបង្ហាញ git branch, ស្ថានភាព/លេខ PR, ថតការងារ, port ដែលកំពុងស្តាប់ និងអត្ថបទជូនដំណឹងចុងក្រោយ។ បំបែកទាំងផ្ដេក និងបញ្ឈរ។
Vertical tabs and split panes @@ -74,6 +76,10 @@ Sidebar shows git branch, linked PR status/number, working directory, listening ### DMG (ត្រូវបានណែនាំ) + + ទាញយក cmux សម្រាប់ macOS + + បើកឯកសារ `.dmg` ហើយអូស cmux បញ្ចូលទៅក្នុងថត Applications របស់អ្នក។ cmux ធ្វើបច្ចុប្បន្នភាពដោយស្វ័យប្រវត្តិតាមរយៈ Sparkle ដូច្នេះអ្នកគ្រាន់តែទាញយកវាតែម្តងគត់។ ### Homebrew @@ -156,7 +162,7 @@ cmux គឺជាមូលដ្ឋានគ្រឹះ (primitive) មិន ### កម្មវិធីរុករក (Browser) -Browser developer-tool shortcuts follow Safari defaults and are customizable in `Settings → Keyboard Shortcuts`. +ផ្លូវកាត់ឧបករណ៍អ្នកអភិវឌ្ឍន៍កម្មវិធីរុករក (Browser developer-tool shortcuts) ប្រើតាមលំនាំដើមរបស់ Safari ហើយអាចប្ដូរតាមបំណងបាននៅក្នុង `Settings → Keyboard Shortcuts`។ | ផ្លូវកាត់ (Shortcut) | សកម្មភាព (Action) | |---|---| diff --git a/README.ko.md b/README.ko.md index 7f0406eb..9f36929b 100644 --- a/README.ko.md +++ b/README.ko.md @@ -10,7 +10,7 @@

- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe + English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ

diff --git a/README.no.md b/README.no.md index 15605c94..fb7c211a 100644 --- a/README.no.md +++ b/README.no.md @@ -10,7 +10,7 @@

- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe + English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ

diff --git a/README.pl.md b/README.pl.md index ba28fd2d..3408897d 100644 --- a/README.pl.md +++ b/README.pl.md @@ -10,7 +10,7 @@

- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe + English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ

diff --git a/README.pt-BR.md b/README.pt-BR.md index bd79e450..f815f276 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -10,7 +10,7 @@

- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe + English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ

diff --git a/README.ru.md b/README.ru.md index 61d049d0..78769516 100644 --- a/README.ru.md +++ b/README.ru.md @@ -10,7 +10,7 @@

- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe + English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ

diff --git a/README.th.md b/README.th.md index f77aea0b..d57fe8a8 100644 --- a/README.th.md +++ b/README.th.md @@ -10,7 +10,7 @@

- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe + English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ

diff --git a/README.tr.md b/README.tr.md index d317b7e9..a69c4a29 100644 --- a/README.tr.md +++ b/README.tr.md @@ -10,7 +10,7 @@

- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe + English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ

diff --git a/README.zh-CN.md b/README.zh-CN.md index d0435a4f..f376b5f0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,7 +10,7 @@

- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe + English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ

diff --git a/README.zh-TW.md b/README.zh-TW.md index 7547e4ec..fee17fd4 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -10,7 +10,7 @@

- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe + English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ

diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 643fc841..ab4b6e2c 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -44,10 +44,10 @@ _CMUX_GIT_JOB_STARTED_AT="${_CMUX_GIT_JOB_STARTED_AT:-0}" _CMUX_GIT_HEAD_LAST_PWD="${_CMUX_GIT_HEAD_LAST_PWD:-}" _CMUX_GIT_HEAD_PATH="${_CMUX_GIT_HEAD_PATH:-}" _CMUX_GIT_HEAD_SIGNATURE="${_CMUX_GIT_HEAD_SIGNATURE:-}" -_CMUX_PR_LAST_PWD="${_CMUX_PR_LAST_PWD:-}" -_CMUX_PR_LAST_RUN="${_CMUX_PR_LAST_RUN:-0}" -_CMUX_PR_JOB_PID="${_CMUX_PR_JOB_PID:-}" -_CMUX_PR_JOB_STARTED_AT="${_CMUX_PR_JOB_STARTED_AT:-0}" +_CMUX_PR_POLL_PID="${_CMUX_PR_POLL_PID:-}" +_CMUX_PR_POLL_PWD="${_CMUX_PR_POLL_PWD:-}" +_CMUX_PR_POLL_INTERVAL="${_CMUX_PR_POLL_INTERVAL:-45}" +_CMUX_PR_FORCE="${_CMUX_PR_FORCE:-0}" _CMUX_ASYNC_JOB_TIMEOUT="${_CMUX_ASYNC_JOB_TIMEOUT:-20}" _CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}" @@ -115,6 +115,182 @@ _cmux_ports_kick() { } >/dev/null 2>&1 & disown } +_cmux_clear_pr_for_panel() { + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" +} + +_cmux_pr_output_indicates_no_pull_request() { + local output="$1" + output="$(printf '%s' "$output" | tr '[:upper:]' '[:lower:]')" + [[ "$output" == *"no pull requests found"* \ + || "$output" == *"no pull request found"* \ + || "$output" == *"no pull requests associated"* \ + || "$output" == *"no pull request associated"* ]] +} + +_cmux_report_pr_for_path() { + local repo_path="$1" + [[ -n "$repo_path" ]] || { + _cmux_clear_pr_for_panel + return 0 + } + [[ -d "$repo_path" ]] || { + _cmux_clear_pr_for_panel + return 0 + } + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + + local branch gh_output gh_error="" err_file="" gh_status number state url status_opt="" + branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)" + if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then + _cmux_clear_pr_for_panel + return 0 + fi + + err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" + [[ -n "$err_file" ]] || return 1 + gh_output="$( + builtin cd "$repo_path" 2>/dev/null \ + && gh pr view \ + --json number,state,url \ + --jq '[.number, .state, .url] | @tsv' \ + 2>"$err_file" + )" + gh_status=$? + if [[ -f "$err_file" ]]; then + gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" + /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true + fi + if (( gh_status != 0 )); then + if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then + _cmux_clear_pr_for_panel + return 0 + fi + # Preserve the last-known PR badge when gh fails transiently, then retry + # on the next background poll instead of clearing visible state. + return 1 + fi + if [[ -z "$gh_output" ]]; then + _cmux_clear_pr_for_panel + return 0 + fi + + IFS=$'\t' read -r number state url <<< "$gh_output" + if [[ -z "$number" || -z "$url" ]]; then + return 1 + fi + + case "$state" in + MERGED) status_opt="--state=merged" ;; + OPEN) status_opt="--state=open" ;; + CLOSED) status_opt="--state=closed" ;; + *) return 1 ;; + esac + + _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" +} + +_cmux_child_pids() { + local parent_pid="$1" + [[ -n "$parent_pid" ]] || return 0 + /bin/ps -ax -o pid= -o ppid= 2>/dev/null | /usr/bin/awk -v parent="$parent_pid" '$2 == parent { print $1 }' +} + +_cmux_kill_process_tree() { + local pid="$1" + local signal="${2:-TERM}" + local child_pid="" + [[ -n "$pid" ]] || return 0 + + while IFS= read -r child_pid; do + [[ -n "$child_pid" ]] || continue + [[ "$child_pid" == "$pid" ]] && continue + _cmux_kill_process_tree "$child_pid" "$signal" + done < <(_cmux_child_pids "$pid") + + kill "-$signal" "$pid" >/dev/null 2>&1 || true +} + +_cmux_run_pr_probe_with_timeout() { + local repo_path="$1" + local probe_pid="" + local started_at=$SECONDS + local now=$started_at + + ( + _cmux_report_pr_for_path "$repo_path" + ) & + probe_pid=$! + + while kill -0 "$probe_pid" >/dev/null 2>&1; do + sleep 1 + now=$SECONDS + if (( _CMUX_ASYNC_JOB_TIMEOUT > 0 )) && (( now - started_at >= _CMUX_ASYNC_JOB_TIMEOUT )); then + _cmux_kill_process_tree "$probe_pid" TERM + sleep 0.2 + if kill -0 "$probe_pid" >/dev/null 2>&1; then + _cmux_kill_process_tree "$probe_pid" KILL + sleep 0.2 + fi + if ! kill -0 "$probe_pid" >/dev/null 2>&1; then + wait "$probe_pid" >/dev/null 2>&1 || true + fi + return 1 + fi + done + + wait "$probe_pid" +} + +_cmux_stop_pr_poll_loop() { + if [[ -n "$_CMUX_PR_POLL_PID" ]]; then + _cmux_kill_process_tree "$_CMUX_PR_POLL_PID" TERM + sleep 0.1 + if kill -0 "$_CMUX_PR_POLL_PID" >/dev/null 2>&1; then + _cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL + fi + _CMUX_PR_POLL_PID="" + fi +} + +_cmux_start_pr_poll_loop() { + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + + local watch_pwd="${1:-$PWD}" + local force_restart="${2:-0}" + local watch_shell_pid="$$" + local interval="${_CMUX_PR_POLL_INTERVAL:-45}" + + if [[ "$force_restart" != "1" && "$watch_pwd" == "$_CMUX_PR_POLL_PWD" && -n "$_CMUX_PR_POLL_PID" ]] \ + && kill -0 "$_CMUX_PR_POLL_PID" 2>/dev/null; then + return 0 + fi + + _cmux_stop_pr_poll_loop + _CMUX_PR_POLL_PWD="$watch_pwd" + + { + while :; do + kill -0 "$watch_shell_pid" 2>/dev/null || break + _cmux_run_pr_probe_with_timeout "$watch_pwd" || true + sleep "$interval" + done + } >/dev/null 2>&1 & + _CMUX_PR_POLL_PID=$! + disown "$_CMUX_PR_POLL_PID" 2>/dev/null || disown +} + +_cmux_bash_cleanup() { + _cmux_stop_pr_poll_loop +} + _cmux_prompt_command() { [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 @@ -135,16 +311,6 @@ _cmux_prompt_command() { fi fi - if [[ -n "$_CMUX_PR_JOB_PID" ]]; then - if ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then - _CMUX_PR_JOB_PID="" - _CMUX_PR_JOB_STARTED_AT=0 - elif (( _CMUX_PR_JOB_STARTED_AT > 0 )) && (( now - _CMUX_PR_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT )); then - _CMUX_PR_JOB_PID="" - _CMUX_PR_JOB_STARTED_AT=0 - fi - fi - # Resolve TTY name once. if [[ -z "$_CMUX_TTY_NAME" ]]; then local t @@ -178,8 +344,8 @@ _cmux_prompt_command() { if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then _CMUX_GIT_HEAD_SIGNATURE="$head_signature" git_head_changed=1 - # Also invalidate the PR probe so it refreshes with the new branch. - _CMUX_PR_LAST_RUN=0 + # Also invalidate the PR poller so it refreshes with the new branch. + _CMUX_PR_FORCE=1 fi fi @@ -215,56 +381,35 @@ _cmux_prompt_command() { _CMUX_GIT_JOB_STARTED_AT=$now fi - # Pull request metadata (number/state/url): - # refresh on cwd change, HEAD change, and periodically to avoid stale status. - if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then - if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" || "$git_head_changed" == "1" ]]; then - kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true - _CMUX_PR_JOB_PID="" - _CMUX_PR_JOB_STARTED_AT=0 - fi + # Pull request metadata is remote state. Keep polling while the shell sits + # at a prompt so newly created or merged PRs appear without another command. + local should_restart_pr_poll=0 + local pr_context_changed=0 + if [[ -n "$_CMUX_PR_POLL_PWD" && "$pwd" != "$_CMUX_PR_POLL_PWD" ]]; then + pr_context_changed=1 + elif [[ "$git_head_changed" == "1" ]]; then + pr_context_changed=1 + fi + if [[ "$pwd" != "$_CMUX_PR_POLL_PWD" || "$git_head_changed" == "1" ]]; then + should_restart_pr_poll=1 + elif (( _CMUX_PR_FORCE )); then + should_restart_pr_poll=1 + elif [[ -z "$_CMUX_PR_POLL_PID" ]] || ! kill -0 "$_CMUX_PR_POLL_PID" 2>/dev/null; then + should_restart_pr_poll=1 fi - if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" || "$git_head_changed" == "1" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then - if [[ -z "$_CMUX_PR_JOB_PID" ]] || ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then - _CMUX_PR_LAST_PWD="$pwd" - _CMUX_PR_LAST_RUN=$now - { - local branch pr_tsv number state url status_opt="" - branch=$(git branch --show-current 2>/dev/null) - if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then - _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - else - pr_tsv="$(gh pr view --json number,state,url --jq '[.number, .state, .url] | @tsv' 2>/dev/null || true)" - if [[ -z "$pr_tsv" ]]; then - _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - else - IFS=$'\t' read -r number state url <<< "$pr_tsv" - if [[ -z "$number" || -z "$url" ]]; then - _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - else - case "$state" in - MERGED) status_opt="--state=merged" ;; - OPEN) status_opt="--state=open" ;; - CLOSED) status_opt="--state=closed" ;; - *) status_opt="" ;; - esac - _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - fi - fi - fi - } >/dev/null 2>&1 & - _CMUX_PR_JOB_PID=$! - disown - _CMUX_PR_JOB_STARTED_AT=$now + if (( should_restart_pr_poll )); then + _CMUX_PR_FORCE=0 + if (( pr_context_changed )); then + _cmux_clear_pr_for_panel fi + _cmux_start_pr_poll_loop "$pwd" 1 fi # Ports: lightweight kick to the app's batched scanner every ~10s. if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then _cmux_ports_kick fi - } _cmux_install_prompt_command() { diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index ee2047a7..45a99aaf 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -47,10 +47,9 @@ typeset -g _CMUX_GIT_HEAD_LAST_PWD="" typeset -g _CMUX_GIT_HEAD_PATH="" typeset -g _CMUX_GIT_HEAD_SIGNATURE="" typeset -g _CMUX_GIT_HEAD_WATCH_PID="" -typeset -g _CMUX_PR_LAST_PWD="" -typeset -g _CMUX_PR_LAST_RUN=0 -typeset -g _CMUX_PR_JOB_PID="" -typeset -g _CMUX_PR_JOB_STARTED_AT=0 +typeset -g _CMUX_PR_POLL_PID="" +typeset -g _CMUX_PR_POLL_PWD="" +typeset -g _CMUX_PR_POLL_INTERVAL=45 typeset -g _CMUX_PR_FORCE=0 typeset -g _CMUX_ASYNC_JOB_TIMEOUT=20 @@ -237,6 +236,177 @@ _cmux_report_git_branch_for_path() { fi } +_cmux_clear_pr_for_panel() { + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" +} + +_cmux_pr_output_indicates_no_pull_request() { + local output="${1:l}" + [[ "$output" == *"no pull requests found"* \ + || "$output" == *"no pull request found"* \ + || "$output" == *"no pull requests associated"* \ + || "$output" == *"no pull request associated"* ]] +} + +_cmux_report_pr_for_path() { + local repo_path="$1" + [[ -n "$repo_path" ]] || { + _cmux_clear_pr_for_panel + return 0 + } + [[ -d "$repo_path" ]] || { + _cmux_clear_pr_for_panel + return 0 + } + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + + local branch gh_output gh_error="" err_file="" number state url status_opt="" gh_status + branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)" + if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then + _cmux_clear_pr_for_panel + return 0 + fi + + err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)" + [[ -n "$err_file" ]] || return 1 + gh_output="$( + builtin cd "$repo_path" 2>/dev/null \ + && gh pr view \ + --json number,state,url \ + --jq '[.number, .state, .url] | @tsv' \ + 2>"$err_file" + )" + gh_status=$? + if [[ -f "$err_file" ]]; then + gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)" + /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true + fi + if (( gh_status != 0 )); then + if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then + _cmux_clear_pr_for_panel + return 0 + fi + # Keep the last-known PR badge on transient gh failures (auth hiccups, + # API lag after creation, or rate limiting) and retry on the next poll. + return 1 + fi + if [[ -z "$gh_output" ]]; then + _cmux_clear_pr_for_panel + return 0 + fi + + local IFS=$'\t' + read -r number state url <<< "$gh_output" + if [[ -z "$number" ]] || [[ -z "$url" ]]; then + return 1 + fi + + case "$state" in + MERGED) status_opt="--state=merged" ;; + OPEN) status_opt="--state=open" ;; + CLOSED) status_opt="--state=closed" ;; + *) return 1 ;; + esac + + _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" +} + +_cmux_child_pids() { + local parent_pid="$1" + [[ -n "$parent_pid" ]] || return 0 + /bin/ps -ax -o pid= -o ppid= 2>/dev/null | /usr/bin/awk -v parent="$parent_pid" '$2 == parent { print $1 }' +} + +_cmux_kill_process_tree() { + local pid="$1" + local signal="${2:-TERM}" + local child_pid="" + [[ -n "$pid" ]] || return 0 + + while IFS= read -r child_pid; do + [[ -n "$child_pid" ]] || continue + [[ "$child_pid" == "$pid" ]] && continue + _cmux_kill_process_tree "$child_pid" "$signal" + done < <(_cmux_child_pids "$pid") + + kill "-$signal" "$pid" >/dev/null 2>&1 || true +} + +_cmux_run_pr_probe_with_timeout() { + local repo_path="$1" + local probe_pid="" + local started_at=$EPOCHSECONDS + local now=$started_at + + ( + _cmux_report_pr_for_path "$repo_path" + ) & + probe_pid=$! + + while kill -0 "$probe_pid" >/dev/null 2>&1; do + sleep 1 + now=$EPOCHSECONDS + if (( _CMUX_ASYNC_JOB_TIMEOUT > 0 )) && (( now - started_at >= _CMUX_ASYNC_JOB_TIMEOUT )); then + _cmux_kill_process_tree "$probe_pid" TERM + sleep 0.2 + if kill -0 "$probe_pid" >/dev/null 2>&1; then + _cmux_kill_process_tree "$probe_pid" KILL + sleep 0.2 + fi + if ! kill -0 "$probe_pid" >/dev/null 2>&1; then + wait "$probe_pid" >/dev/null 2>&1 || true + fi + return 1 + fi + done + + wait "$probe_pid" +} + +_cmux_stop_pr_poll_loop() { + if [[ -n "$_CMUX_PR_POLL_PID" ]]; then + _cmux_kill_process_tree "$_CMUX_PR_POLL_PID" TERM + sleep 0.1 + if kill -0 "$_CMUX_PR_POLL_PID" >/dev/null 2>&1; then + _cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL + fi + _CMUX_PR_POLL_PID="" + fi +} + +_cmux_start_pr_poll_loop() { + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + + local watch_pwd="${1:-$PWD}" + local force_restart="${2:-0}" + local watch_shell_pid="$$" + local interval="${_CMUX_PR_POLL_INTERVAL:-45}" + + if [[ "$force_restart" != "1" && "$watch_pwd" == "$_CMUX_PR_POLL_PWD" && -n "$_CMUX_PR_POLL_PID" ]] \ + && kill -0 "$_CMUX_PR_POLL_PID" 2>/dev/null; then + return 0 + fi + + _cmux_stop_pr_poll_loop + _CMUX_PR_POLL_PWD="$watch_pwd" + + { + while true; do + kill -0 "$watch_shell_pid" >/dev/null 2>&1 || break + _cmux_run_pr_probe_with_timeout "$watch_pwd" || true + sleep "$interval" + done + } >/dev/null 2>&1 &! + _CMUX_PR_POLL_PID=$! +} + _cmux_stop_git_head_watch() { if [[ -n "$_CMUX_GIT_HEAD_WATCH_PID" ]]; then kill "$_CMUX_GIT_HEAD_WATCH_PID" >/dev/null 2>&1 || true @@ -299,6 +469,7 @@ _cmux_preexec() { # Register TTY + kick batched port scan for foreground commands (servers). _cmux_report_tty_once _cmux_ports_kick + _cmux_stop_pr_poll_loop _cmux_start_git_head_watch } @@ -342,17 +513,6 @@ _cmux_precmd() { fi fi - if [[ -n "$_CMUX_PR_JOB_PID" ]]; then - if ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then - _CMUX_PR_JOB_PID="" - _CMUX_PR_JOB_STARTED_AT=0 - elif (( _CMUX_PR_JOB_STARTED_AT > 0 )) && (( now - _CMUX_PR_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT )); then - _CMUX_PR_JOB_PID="" - _CMUX_PR_JOB_STARTED_AT=0 - _CMUX_PR_FORCE=1 - fi - fi - # CWD: keep the app in sync with the actual shell directory. # This is also the simplest way to test sidebar directory behavior end-to-end. if [[ "$pwd" != "$_CMUX_PWD_LAST_PWD" ]]; then @@ -368,6 +528,7 @@ _cmux_precmd() { # While a foreground command is running, _cmux_start_git_head_watch probes HEAD # once per second so agent-initiated git checkouts still surface quickly. local should_git=0 + local git_head_changed=0 # Git branch can change without a `git ...`-prefixed command (aliases like `gco`, # tools like `gh pr checkout`, etc.). Detect HEAD changes and force a refresh. @@ -381,6 +542,7 @@ _cmux_precmd() { head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)" if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then _CMUX_GIT_HEAD_SIGNATURE="$head_signature" + git_head_changed=1 # Treat HEAD file change like a git command — force-replace any # running probe so the sidebar picks up the new branch immediately. _CMUX_GIT_FORCE=1 @@ -427,63 +589,30 @@ _cmux_precmd() { fi fi - # Pull request metadata (number/state/url): - # - refresh on cwd change, explicit git/gh commands, and occasionally for status drift - # - keep this independent from the git probe cadence to avoid hitting GitHub too often - local should_pr=0 - if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then - should_pr=1 + # Pull request metadata is remote state. Keep a lightweight background poll + # alive while the shell is idle so gh-created PRs and merge status changes + # appear even without another prompt. + local should_restart_pr_poll=0 + local pr_context_changed=0 + if [[ -n "$_CMUX_PR_POLL_PWD" && "$pwd" != "$_CMUX_PR_POLL_PWD" ]]; then + pr_context_changed=1 + elif (( git_head_changed )); then + pr_context_changed=1 + fi + if [[ "$pwd" != "$_CMUX_PR_POLL_PWD" ]]; then + should_restart_pr_poll=1 elif (( _CMUX_PR_FORCE )); then - should_pr=1 - elif (( now - _CMUX_PR_LAST_RUN >= 60 )); then - should_pr=1 + should_restart_pr_poll=1 + elif [[ -z "$_CMUX_PR_POLL_PID" ]] || ! kill -0 "$_CMUX_PR_POLL_PID" 2>/dev/null; then + should_restart_pr_poll=1 fi - if (( should_pr )); then - local can_launch_pr=1 - if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then - if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( _CMUX_PR_FORCE )); then - kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true - _CMUX_PR_JOB_PID="" - _CMUX_PR_JOB_STARTED_AT=0 - else - can_launch_pr=0 - fi - fi - - if (( can_launch_pr )); then - _CMUX_PR_FORCE=0 - _CMUX_PR_LAST_PWD="$pwd" - _CMUX_PR_LAST_RUN=$now - { - local branch pr_tsv number state url status_opt="" - branch=$(git branch --show-current 2>/dev/null) - if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then - _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - else - pr_tsv="$(gh pr view --json number,state,url --jq '[.number, .state, .url] | @tsv' 2>/dev/null || true)" - if [[ -z "$pr_tsv" ]]; then - _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - else - local IFS=$'\t' - read -r number state url <<< "$pr_tsv" - if [[ -z "$number" ]] || [[ -z "$url" ]]; then - _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - else - case "$state" in - MERGED) status_opt="--state=merged" ;; - OPEN) status_opt="--state=open" ;; - CLOSED) status_opt="--state=closed" ;; - *) status_opt="" ;; - esac - _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - fi - fi - fi - } >/dev/null 2>&1 &! - _CMUX_PR_JOB_PID=$! - _CMUX_PR_JOB_STARTED_AT=$now + if (( should_restart_pr_poll )); then + _CMUX_PR_FORCE=0 + if (( pr_context_changed )); then + _cmux_clear_pr_for_panel fi + _cmux_start_pr_poll_loop "$pwd" 1 fi # Ports: lightweight kick to the app's batched scanner. @@ -520,6 +649,7 @@ _cmux_fix_path() { _cmux_zshexit() { _cmux_stop_git_head_watch + _cmux_stop_pr_poll_loop } autoload -Uz add-zsh-hook diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 023ee5dd..4da5cd1d 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1682,6 +1682,21 @@ func shouldRouteTerminalFontZoomShortcutToGhostty( ) != nil } +/// Let AppKit own native Cmd+` window cycling so key-window changes do not +/// re-enter our direct-to-menu shortcut path. +func shouldRouteCommandEquivalentDirectlyToMainMenu(_ event: NSEvent) -> Bool { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard flags.contains(.command) else { return false } + + let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) + if event.keyCode == 50, + normalizedFlags == [.command] || normalizedFlags == [.command, .shift] { + return false + } + + return true +} + func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? { guard let responder else { return nil } if let ghosttyView = responder as? GhosttyNSView { @@ -11071,7 +11086,7 @@ private extension NSWindow { // (which walks the SwiftUI content view hierarchy) and dispatch Command-key // events directly to the main menu. This avoids the broken SwiftUI focus path. if firstResponderGhosttyView != nil, - event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command), + shouldRouteCommandEquivalentDirectlyToMainMenu(event), let mainMenu = NSApp.mainMenu { let consumedByMenu = mainMenu.performKeyEquivalent(with: event) #if DEBUG diff --git a/Sources/Backport.swift b/Sources/Backport.swift index d1bb5461..b6a1ec3b 100644 --- a/Sources/Backport.swift +++ b/Sources/Backport.swift @@ -7,6 +7,15 @@ struct Backport { extension View { var backport: Backport { Backport(content: self) } + + @ViewBuilder + func safeHelp(_ text: String) -> some View { + if text.isEmpty { + self + } else { + self.help(text) + } + } } extension Scene { diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index f1a9830f..07393dbe 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -7,6 +7,7 @@ import WebKit private var cmuxWindowBrowserPortalKey: UInt8 = 0 private var cmuxWindowBrowserPortalCloseObserverKey: UInt8 = 0 private var cmuxBrowserSearchOverlayPanelIdAssociationKey: UInt8 = 0 +private var cmuxBrowserPortalNeedsRenderingStateReattachKey: UInt8 = 0 #if DEBUG private func browserPortalDebugToken(_ view: NSView?) -> String { @@ -44,7 +45,23 @@ private extension NSResponder { } private extension WKWebView { + private var browserPortalNeedsRenderingStateReattach: Bool { + get { + (objc_getAssociatedObject(self, &cmuxBrowserPortalNeedsRenderingStateReattachKey) as? NSNumber)? + .boolValue ?? false + } + set { + objc_setAssociatedObject( + self, + &cmuxBrowserPortalNeedsRenderingStateReattachKey, + NSNumber(value: newValue), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + func browserPortalNotifyHidden(reason: String) { + browserPortalNeedsRenderingStateReattach = true let firedSelectors = ["viewDidHide", "_exitInWindow"].filter { browserPortalCallVoidIfAvailable($0) } @@ -59,7 +76,9 @@ private extension WKWebView { } func browserPortalReattachRenderingState(reason: String) { + guard browserPortalNeedsRenderingStateReattach else { return } guard window != nil else { return } + browserPortalNeedsRenderingStateReattach = false let firedSelectors = [ "viewDidUnhide", @@ -126,13 +145,11 @@ enum HostedInspectorDockSide { inspectorFrame: NSRect, expansion: CGFloat ) -> NSRect { - let minY = max(bounds.minY, min(pageFrame.minY, inspectorFrame.minY)) - let maxY = min(bounds.maxY, max(pageFrame.maxY, inspectorFrame.maxY)) return NSRect( x: dividerX(pageFrame: pageFrame, inspectorFrame: inspectorFrame) - expansion, - y: minY, + y: bounds.minY, width: expansion * 2, - height: max(0, maxY - minY) + height: max(0, bounds.height) ) } @@ -168,35 +185,54 @@ enum HostedInspectorDockSide { in containerBounds: NSRect, pageFrame: NSRect, inspectorFrame: NSRect, - minimumInspectorWidth _: CGFloat + minimumInspectorWidth: CGFloat ) -> (pageFrame: NSRect, inspectorFrame: NSRect) { + let normalizedMinY = containerBounds.minY + let normalizedHeight = max(0, containerBounds.height) + switch self { case .leading: let maximumInspectorWidth = max(0, containerBounds.width) - let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth)) + let clampedMinimumInspectorWidth = min(maximumInspectorWidth, max(0, minimumInspectorWidth)) + let clampedInspectorWidth = min( + maximumInspectorWidth, + max(clampedMinimumInspectorWidth, preferredWidth) + ) let dividerX = min(containerBounds.maxX, containerBounds.minX + clampedInspectorWidth) var nextPageFrame = pageFrame nextPageFrame.origin.x = dividerX + nextPageFrame.origin.y = normalizedMinY nextPageFrame.size.width = max(0, containerBounds.maxX - dividerX) + nextPageFrame.size.height = normalizedHeight var nextInspectorFrame = inspectorFrame nextInspectorFrame.origin.x = containerBounds.minX + nextInspectorFrame.origin.y = normalizedMinY nextInspectorFrame.size.width = max(0, dividerX - containerBounds.minX) + nextInspectorFrame.size.height = normalizedHeight return (pageFrame: nextPageFrame, inspectorFrame: nextInspectorFrame) case .trailing: let maximumInspectorWidth = max(0, containerBounds.width) - let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth)) + let clampedMinimumInspectorWidth = min(maximumInspectorWidth, max(0, minimumInspectorWidth)) + let clampedInspectorWidth = min( + maximumInspectorWidth, + max(clampedMinimumInspectorWidth, preferredWidth) + ) let dividerX = max(containerBounds.minX, containerBounds.maxX - clampedInspectorWidth) var nextPageFrame = pageFrame nextPageFrame.origin.x = containerBounds.minX + nextPageFrame.origin.y = normalizedMinY nextPageFrame.size.width = max(0, dividerX - containerBounds.minX) + nextPageFrame.size.height = normalizedHeight var nextInspectorFrame = inspectorFrame nextInspectorFrame.origin.x = dividerX + nextInspectorFrame.origin.y = normalizedMinY nextInspectorFrame.size.width = max(0, containerBounds.maxX - dividerX) + nextInspectorFrame.size.height = normalizedHeight return (pageFrame: nextPageFrame, inspectorFrame: nextInspectorFrame) } } @@ -572,6 +608,7 @@ final class WindowBrowserHostView: NSView { inspectorView: dragState.inspectorView, dockSide: dragState.dockSide ), + minimumInspectorWidth: Self.minimumHostedInspectorWidth, reason: "drag" ) updateDividerCursor( @@ -946,7 +983,12 @@ final class WindowBrowserHostView: NSView { guard let hit = hostedInspectorDividerCandidate(in: slot) else { return false } let oldPageFrame = hit.pageView.frame let oldInspectorFrame = hit.inspectorView.frame - _ = applyHostedInspectorDividerWidth(preferredWidth, to: hit, reason: reason) + _ = applyHostedInspectorDividerWidth( + preferredWidth, + to: hit, + minimumInspectorWidth: Self.minimumHostedInspectorWidth, + reason: reason + ) return !Self.rectApproximatelyEqual(oldPageFrame, hit.pageView.frame, epsilon: 0.5) || !Self.rectApproximatelyEqual(oldInspectorFrame, hit.inspectorView.frame, epsilon: 0.5) } @@ -955,6 +997,7 @@ final class WindowBrowserHostView: NSView { private func applyHostedInspectorDividerWidth( _ preferredWidth: CGFloat, to hit: HostedInspectorDividerHit, + minimumInspectorWidth: CGFloat, reason: String ) -> (pageFrame: NSRect, inspectorFrame: NSRect) { let containerBounds = hit.containerView.bounds @@ -963,7 +1006,7 @@ final class WindowBrowserHostView: NSView { in: containerBounds, pageFrame: hit.pageView.frame, inspectorFrame: hit.inspectorView.frame, - minimumInspectorWidth: 0 + minimumInspectorWidth: minimumInspectorWidth ) let pageFrame = nextFrames.pageFrame let inspectorFrame = nextFrames.inspectorFrame @@ -1772,8 +1815,9 @@ final class WindowBrowserSlotView: NSView { func pinHostedWebView(_ webView: WKWebView) { guard webView.superview === self else { return } + let hasCompanionWKSubviews = Self.hasWebKitCompanionSubview(in: self, primaryWebView: webView) let needsPlainWebViewFrameReset = - !Self.hasWebKitCompanionSubview(in: self, primaryWebView: webView) && + !hasCompanionWKSubviews && Self.frameDiffersFromBounds(webView.frame, bounds: bounds) let needsFrameHosting = hostedWebView !== webView || @@ -1795,7 +1839,9 @@ final class WindowBrowserSlotView: NSView { // WebKit-managed split frame when docked DevTools siblings are present. webView.translatesAutoresizingMaskIntoConstraints = true webView.autoresizingMask = [.width, .height] - webView.frame = bounds + if !hasCompanionWKSubviews { + webView.frame = bounds + } needsLayout = true layoutSubtreeIfNeeded() } @@ -2918,7 +2964,11 @@ final class WindowBrowserPortal: NSObject { containerView.setPaneDropContext(nil) containerView.setPortalDragDropZone(nil) containerView.setDropZoneOverlay(zone: nil) - if !containerView.isHidden, webView.superview === containerView { + // Tab/workspace visibility changes should hide the portal slot without forcing + // WebKit through `_exitInWindow`/`_enterInWindow`, which fires visibilitychange + // and can trigger page reloads. Reserve the full lifecycle notify for cases + // where the visible surface is actually leaving the window/render tree. + if entry.visibleInUI, !containerView.isHidden, webView.superview === containerView { webView.browserPortalNotifyHidden(reason: reason) } containerView.isHidden = true diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 64e72a7f..de60f501 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2633,10 +2633,14 @@ struct ContentView: View { if abs(sidebarState.persistedWidth - sanitized) > 0.5 { sidebarState.persistedWidth = sanitized } + // Sidebar width changes are pure SwiftUI layout updates, so portal-hosted + // terminals need an explicit post-layout geometry resync. + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() updateSidebarResizerBandState() }) view = AnyView(view.onChange(of: sidebarState.isVisible) { _ in + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() updateSidebarResizerBandState() }) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index a9bbb0a9..5b4db687 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5640,6 +5640,15 @@ private final class GhosttyPassthroughVisualEffectView: NSVisualEffectView { } } +func shouldAllowEnsureFocusWindowActivation( + activeTabManager: TabManager?, + targetTabManager: TabManager, + keyWindow: NSWindow?, + mainWindow: NSWindow? +) -> Bool { + activeTabManager === targetTabManager || (keyWindow == nil && mainWindow == nil) +} + final class GhosttySurfaceScrollView: NSView { enum FlashStyle { case standardFocus @@ -7054,6 +7063,14 @@ final class GhosttySurfaceScrollView: NSView { } if !window.isKeyWindow { + guard shouldAllowEnsureFocusWindowActivation( + activeTabManager: delegate.tabManager, + targetTabManager: tabManager, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { + return + } window.makeKeyAndOrderFront(nil) } let result = window.makeFirstResponder(surfaceView) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 623d575d..b2927a8a 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1798,6 +1798,10 @@ final class BrowserPanel: Panel, ObservableObject { @Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus? private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35 private var developerToolsDetachedOpenGraceDeadline: Date? + private var developerToolsTransitionTargetVisible: Bool? + private var pendingDeveloperToolsTransitionTargetVisible: Bool? + private var developerToolsTransitionSettleWorkItem: DispatchWorkItem? + private let developerToolsTransitionSettleDelay: TimeInterval = 0.15 private var detachedDeveloperToolsWindowCloseObserver: NSObjectProtocol? private var preferredAttachedDeveloperToolsWidth: CGFloat? private var preferredAttachedDeveloperToolsWidthFraction: CGFloat? @@ -2814,6 +2818,8 @@ final class BrowserPanel: Panel, ObservableObject { deinit { developerToolsRestoreRetryWorkItem?.cancel() developerToolsRestoreRetryWorkItem = nil + developerToolsTransitionSettleWorkItem?.cancel() + developerToolsTransitionSettleWorkItem = nil if let detachedDeveloperToolsWindowCloseObserver { NotificationCenter.default.removeObserver(detachedDeveloperToolsWindowCloseObserver) } @@ -3232,29 +3238,97 @@ extension BrowserPanel { return !(inspector.cmuxCallBool(selector: isVisibleSelector) ?? false) } + private var isDeveloperToolsTransitionInFlight: Bool { + developerToolsTransitionSettleWorkItem != nil + } + + private func effectiveDeveloperToolsVisibilityIntent() -> Bool { + if let pendingDeveloperToolsTransitionTargetVisible { + return pendingDeveloperToolsTransitionTargetVisible + } + if let developerToolsTransitionTargetVisible { + return developerToolsTransitionTargetVisible + } + return isDeveloperToolsVisible() + } + + private func scheduleDeveloperToolsTransitionSettle(source: String) { + developerToolsTransitionSettleWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.developerToolsTransitionSettleWorkItem = nil + self?.finishDeveloperToolsTransition(source: source) + } + developerToolsTransitionSettleWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + developerToolsTransitionSettleDelay, execute: workItem) + } + + private func finishDeveloperToolsTransition(source: String) { + let pendingTargetVisible = pendingDeveloperToolsTransitionTargetVisible + pendingDeveloperToolsTransitionTargetVisible = nil + developerToolsTransitionTargetVisible = nil + + guard let pendingTargetVisible else { return } + guard pendingTargetVisible != isDeveloperToolsVisible() else { return } + _ = performDeveloperToolsVisibilityTransition(to: pendingTargetVisible, source: "\(source).queued") + } + @discardableResult - func toggleDeveloperTools() -> Bool { + private func enqueueDeveloperToolsVisibilityTransition( + to targetVisible: Bool, + source: String + ) -> Bool { + if isDeveloperToolsTransitionInFlight { + pendingDeveloperToolsTransitionTargetVisible = targetVisible + preferredDeveloperToolsVisible = targetVisible + if !targetVisible { + developerToolsDetachedOpenGraceDeadline = nil + forceDeveloperToolsRefreshOnNextAttach = false + cancelDeveloperToolsRestoreRetry() + } #if DEBUG - dlog( - "browser.devtools toggle.begin panel=\(id.uuidString.prefix(5)) " + - "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" - ) + dlog( + "browser.devtools transition.queue panel=\(id.uuidString.prefix(5)) " + + "source=\(source) target=\(targetVisible ? 1 : 0) \(debugDeveloperToolsStateSummary())" + ) #endif + return true + } + + return performDeveloperToolsVisibilityTransition(to: targetVisible, source: source) + } + + @discardableResult + private func performDeveloperToolsVisibilityTransition( + to targetVisible: Bool, + source: String + ) -> Bool { guard let inspector = webView.cmuxInspectorObject() else { return false } + let isVisibleSelector = NSSelectorFromString("isVisible") let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false - let targetVisible = !visible + preferredDeveloperToolsVisible = targetVisible + developerToolsTransitionTargetVisible = targetVisible + if targetVisible { - _ = revealDeveloperTools(inspector) + if !visible { + _ = revealDeveloperTools(inspector) + } else { + developerToolsDetachedOpenGraceDeadline = nil + } } else { - syncDeveloperToolsPresentationPreferenceFromUI() - guard concealDeveloperTools(inspector) else { return false } + if visible { + syncDeveloperToolsPresentationPreferenceFromUI() + guard concealDeveloperTools(inspector) else { + developerToolsTransitionTargetVisible = nil + return false + } + } developerToolsDetachedOpenGraceDeadline = nil } - preferredDeveloperToolsVisible = targetVisible + if targetVisible { - let visibleAfterToggle = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false - if visibleAfterToggle { + let visibleAfterTransition = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false + if visibleAfterTransition { syncDeveloperToolsPresentationPreferenceFromUI() cancelDeveloperToolsRestoreRetry() scheduleDetachedDeveloperToolsWindowDismissal() @@ -3266,6 +3340,26 @@ extension BrowserPanel { cancelDeveloperToolsRestoreRetry() forceDeveloperToolsRefreshOnNextAttach = false } + + if visible != targetVisible { + scheduleDeveloperToolsTransitionSettle(source: source) + } else { + developerToolsTransitionTargetVisible = nil + } + + return true + } + + @discardableResult + func toggleDeveloperTools() -> Bool { +#if DEBUG + dlog( + "browser.devtools toggle.begin panel=\(id.uuidString.prefix(5)) " + + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" + ) +#endif + let targetVisible = !effectiveDeveloperToolsVisibilityIntent() + let handled = enqueueDeveloperToolsVisibilityTransition(to: targetVisible, source: "toggle") #if DEBUG dlog( "browser.devtools toggle.end panel=\(id.uuidString.prefix(5)) targetVisible=\(targetVisible ? 1 : 0) " + @@ -3279,30 +3373,18 @@ extension BrowserPanel { ) } #endif - return true + return handled } @discardableResult func showDeveloperTools() -> Bool { - guard let inspector = webView.cmuxInspectorObject() else { return false } - let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false - if !visible { - guard revealDeveloperTools(inspector) else { return false } - } - preferredDeveloperToolsVisible = true - if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) { - syncDeveloperToolsPresentationPreferenceFromUI() - cancelDeveloperToolsRestoreRetry() - scheduleDetachedDeveloperToolsWindowDismissal() - } else { - scheduleDeveloperToolsRestoreRetry() - } - return true + return enqueueDeveloperToolsVisibilityTransition(to: true, source: "show") } @discardableResult func showDeveloperToolsConsole() -> Bool { guard showDeveloperTools() else { return false } + guard !isDeveloperToolsTransitionInFlight else { return true } guard let inspector = webView.cmuxInspectorObject() else { return true } // WebKit private inspector API differs by OS; try known console selectors. let consoleSelectors = [ @@ -3324,6 +3406,20 @@ extension BrowserPanel { func syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: Bool = false) { guard let inspector = webView.cmuxInspectorObject() else { return } guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return } + if isDeveloperToolsTransitionInFlight { + let targetVisible = pendingDeveloperToolsTransitionTargetVisible ?? developerToolsTransitionTargetVisible ?? visible + preferredDeveloperToolsVisible = targetVisible + if targetVisible, visible { + developerToolsDetachedOpenGraceDeadline = nil + syncDeveloperToolsPresentationPreferenceFromUI() + cancelDeveloperToolsRestoreRetry() + } else if !targetVisible { + developerToolsDetachedOpenGraceDeadline = nil + forceDeveloperToolsRefreshOnNextAttach = false + cancelDeveloperToolsRestoreRetry() + } + return + } if visible { developerToolsDetachedOpenGraceDeadline = nil syncDeveloperToolsPresentationPreferenceFromUI() @@ -3345,6 +3441,7 @@ extension BrowserPanel { forceDeveloperToolsRefreshOnNextAttach = false return } + guard !isDeveloperToolsTransitionInFlight else { return } guard let inspector = webView.cmuxInspectorObject() else { scheduleDeveloperToolsRestoreRetry() return @@ -3410,17 +3507,7 @@ extension BrowserPanel { @discardableResult func hideDeveloperTools() -> Bool { - guard let inspector = webView.cmuxInspectorObject() else { return false } - let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false - if visible { - syncDeveloperToolsPresentationPreferenceFromUI() - guard concealDeveloperTools(inspector) else { return false } - } - preferredDeveloperToolsVisible = false - developerToolsDetachedOpenGraceDeadline = nil - forceDeveloperToolsRefreshOnNextAttach = false - cancelDeveloperToolsRestoreRetry() - return true + return enqueueDeveloperToolsVisibilityTransition(to: false, source: "hide") } /// During split/layout transitions SwiftUI can briefly mark the browser surface hidden @@ -4296,7 +4383,9 @@ extension BrowserPanel { let attached = webView.superview == nil ? 0 : 1 let inWindow = webView.window == nil ? 0 : 1 let forceRefresh = forceDeveloperToolsRefreshOnNextAttach ? 1 : 0 - return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh)" + let transitionTarget = developerToolsTransitionTargetVisible.map { $0 ? "1" : "0" } ?? "nil" + let pendingTarget = pendingDeveloperToolsTransitionTargetVisible.map { $0 ? "1" : "0" } ?? "nil" + return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh) tx=\(transitionTarget) pending=\(pendingTarget)" } func debugDeveloperToolsGeometrySummary() -> String { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 7122edc7..5c9c780a 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -3715,7 +3715,24 @@ struct WebViewRepresentable: NSViewRepresentable { final class HostContainerView: NSView { private final class HostedInspectorSideDockContainerView: NSView { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.masksToBounds = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } + override var isOpaque: Bool { false } + + override func resizeSubviews(withOldSize oldSize: NSSize) { + // Managed side-docked DevTools use explicit frame updates from the host. + // Letting AppKit autoresize the WK siblings here makes them snap back to + // stale widths while the divider drag or pane resize is in flight. + } } var onDidMoveToWindow: (() -> Void)? @@ -3760,7 +3777,9 @@ struct WebViewRepresentable: NSViewRepresentable { } private static let hostedInspectorDividerHitExpansion: CGFloat = 10 - private static let minimumHostedInspectorWidth: CGFloat = 1 + private static let minimumHostedInspectorWidth: CGFloat = 120 + private static let minimumHostedInspectorPageWidthForSideDock: CGFloat = 240 + private static let adaptiveBottomDockRequestCooldown: TimeInterval = 0.25 private var trackingArea: NSTrackingArea? private var activeDividerCursorKind: DividerCursorKind? private var hostedInspectorDividerDrag: HostedInspectorDividerDragState? @@ -3774,6 +3793,9 @@ struct WebViewRepresentable: NSViewRepresentable { private var isApplyingHostedInspectorLayout = false private var hostedInspectorReapplyWorkItem: DispatchWorkItem? private var hostedInspectorDockConfigurationSyncWorkItem: DispatchWorkItem? + private var adaptiveBottomDockRequestCooldownDeadline: Date? + private var recordedHostedInspectorSideDockWidth: CGFloat? + private var lastHostedInspectorManualSideDockAllowed: Bool? private var lastHostedInspectorLayoutBoundsSize: NSSize? #if DEBUG private var lastLoggedHostedInspectorFrames: (page: NSRect, inspector: NSRect)? @@ -3812,6 +3834,103 @@ struct WebViewRepresentable: NSViewRepresentable { preferredHostedInspectorWidthFraction = widthFraction } + private func recordHostedInspectorSideDockWidth(_ width: CGFloat) { + guard width > 1 else { return } + recordedHostedInspectorSideDockWidth = max(Self.minimumHostedInspectorWidth, width) + } + + private func shouldAllowHostedInspectorManualSideDock() -> Bool { + let containerWidth = max(0, bounds.width) + guard containerWidth > 1 else { return true } + let baselineWidth = max( + Self.minimumHostedInspectorWidth, + recordedHostedInspectorSideDockWidth ?? Self.minimumHostedInspectorWidth + ) + return containerWidth - baselineWidth >= Self.minimumHostedInspectorPageWidthForSideDock + } + + private func updateHostedInspectorDockControlAvailabilityIfNeeded(reason: String) { + guard let hostedInspectorFrontendWebView else { + lastHostedInspectorManualSideDockAllowed = nil + return + } + + let sideDockAllowed = shouldAllowHostedInspectorManualSideDock() + guard lastHostedInspectorManualSideDockAllowed != sideDockAllowed else { return } + lastHostedInspectorManualSideDockAllowed = sideDockAllowed + + let sideDockAllowedLiteral = sideDockAllowed ? "true" : "false" +#if DEBUG + let recordedWidthDesc = recordedHostedInspectorSideDockWidth.map { + String(format: "%.1f", $0) + } ?? "nil" + dlog( + "browser.panel.hostedInspector stage=\(reason).dockControls " + + "host=\(Self.debugObjectID(self)) allowSideDock=\(sideDockAllowed ? 1 : 0) " + + "recordedWidth=\(recordedWidthDesc) bounds=\(Self.debugRect(bounds))" + ) +#endif + hostedInspectorFrontendWebView.evaluateJavaScript( + """ + (() => { + if (typeof WI === "undefined") + return null; + const allowSideDock = \(sideDockAllowedLiteral); + if (!WI.__cmuxOriginalUpdateDockNavigationItems && typeof WI._updateDockNavigationItems === "function") + WI.__cmuxOriginalUpdateDockNavigationItems = WI._updateDockNavigationItems; + if (!WI.__cmuxOriginalDockLeft && typeof WI._dockLeft === "function") + WI.__cmuxOriginalDockLeft = WI._dockLeft; + if (!WI.__cmuxOriginalDockRight && typeof WI._dockRight === "function") + WI.__cmuxOriginalDockRight = WI._dockRight; + if (!WI.__cmuxOriginalTogglePreviousDockConfiguration && typeof WI._togglePreviousDockConfiguration === "function") + WI.__cmuxOriginalTogglePreviousDockConfiguration = WI._togglePreviousDockConfiguration; + function callOriginal(fn, event) { + return typeof fn === "function" ? fn.call(WI, event) : null; + } + function updateButton(button, hidden) { + if (!button) + return; + button.hidden = hidden; + if (button.element) { + button.element.style.display = hidden ? "none" : ""; + button.element.style.pointerEvents = hidden ? "none" : ""; + } + } + function enforceDockControls() { + const disallowSideDock = !WI.__cmuxAllowSideDock; + updateButton(WI._dockLeftTabBarButton, disallowSideDock || WI.dockConfiguration === WI.DockConfiguration.Left); + updateButton(WI._dockRightTabBarButton, disallowSideDock || WI.dockConfiguration === WI.DockConfiguration.Right); + } + WI.__cmuxAllowSideDock = allowSideDock; + WI._dockLeft = function(event) { + if (!WI.__cmuxAllowSideDock) + return callOriginal(WI._dockBottom, event); + return callOriginal(WI.__cmuxOriginalDockLeft, event); + }; + WI._dockRight = function(event) { + if (!WI.__cmuxAllowSideDock) + return callOriginal(WI._dockBottom, event); + return callOriginal(WI.__cmuxOriginalDockRight, event); + }; + WI._togglePreviousDockConfiguration = function(event) { + const previousSideDock = WI._previousDockConfiguration === WI.DockConfiguration.Left || WI._previousDockConfiguration === WI.DockConfiguration.Right; + if (!WI.__cmuxAllowSideDock && previousSideDock) + return callOriginal(WI._dockBottom, event); + return callOriginal(WI.__cmuxOriginalTogglePreviousDockConfiguration, event); + }; + WI._updateDockNavigationItems = function(...args) { + if (typeof WI.__cmuxOriginalUpdateDockNavigationItems === "function") + WI.__cmuxOriginalUpdateDockNavigationItems.apply(WI, args); + enforceDockControls(); + }; + WI._updateDockNavigationItems(); + return WI.__cmuxAllowSideDock; + })(); + """, + completionHandler: nil + ) + } + func containsManagedLocalInlineContent(_ view: NSView) -> Bool { if let localInlineSlotView, view === localInlineSlotView || view.isDescendant(of: localInlineSlotView) { @@ -3836,6 +3955,8 @@ struct WebViewRepresentable: NSViewRepresentable { func setHostedInspectorFrontendWebView(_ webView: WKWebView?) { hostedInspectorFrontendWebView = webView + lastHostedInspectorManualSideDockAllowed = nil + updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "setHostedInspectorFrontendWebView") } private var hasStoredHostedInspectorWidthPreference: Bool { @@ -4116,6 +4237,21 @@ struct WebViewRepresentable: NSViewRepresentable { layoutHostedInspectorSideDockIfNeeded(reason: "sideDock.activate") } + @discardableResult + func promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() -> Bool { + guard !isHostedInspectorSideDockActive(), + let slotView = localInlineSlotView, + let hit = hostedInspectorDividerCandidateUsingKnownWebViews(in: slotView) else { + return false + } + + // The inspector frontend sometimes reports its dock configuration a tick + // late after local-inline reattach. Promote the visible left/right split + // immediately so drag routing stays symmetric on both dock sides. + activateHostedInspectorSideDockIfNeeded(using: hit) + return isHostedInspectorSideDockActive() + } + private func deactivateHostedInspectorSideDockIfNeeded(reparentTo slotView: WindowBrowserSlotView?) { guard let slotView, let pageView = hostedInspectorSideDockPageView, @@ -4151,10 +4287,71 @@ struct WebViewRepresentable: NSViewRepresentable { inspectorView: inspectorView, dockSide: dockSide ), + minimumInspectorWidth: Self.minimumHostedInspectorWidth, reason: reason ) } + func normalizeHostedInspectorLayoutIfNeeded(reason: String) { + if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).adaptive") { + return + } + _ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() + if isHostedInspectorSideDockActive() { + layoutHostedInspectorSideDockIfNeeded(reason: reason) + } else if !hasStoredHostedInspectorWidthPreference { + captureHostedInspectorPreferredWidthFromCurrentLayout(reason: reason) + } + } + + private func shouldForceHostedInspectorBottomDock(using hit: HostedInspectorDividerHit) -> Bool { + let containerWidth = max(0, hit.containerView.bounds.width) + guard containerWidth > 1 else { return false } + + let currentInspectorWidth = max(0, hit.inspectorView.frame.width) + let currentPageWidth = max(0, hit.pageView.frame.width) + let remainingPageWidth = max(0, containerWidth - max(Self.minimumHostedInspectorWidth, currentInspectorWidth)) + let effectivePageWidth = min(currentPageWidth, remainingPageWidth) + + return effectivePageWidth < Self.minimumHostedInspectorPageWidthForSideDock + } + + @discardableResult + private func requestAdaptiveHostedInspectorBottomDock(reason: String) -> Bool { + let now = Date() + if let adaptiveBottomDockRequestCooldownDeadline, adaptiveBottomDockRequestCooldownDeadline > now { + return true + } + guard let hostedInspectorFrontendWebView else { return false } + + adaptiveBottomDockRequestCooldownDeadline = now.addingTimeInterval(Self.adaptiveBottomDockRequestCooldown) + updateHostedInspectorDockControlAvailabilityIfNeeded(reason: reason) +#if DEBUG + dlog( + "browser.panel.hostedInspector stage=\(reason).adaptiveBottomDock " + + "host=\(Self.debugObjectID(self)) bounds=\(Self.debugRect(bounds))" + ) +#endif + hostedInspectorFrontendWebView.evaluateJavaScript( + "typeof WI !== 'undefined' ? WI._dockBottom() : null" + ) { [weak self] _, _ in + self?.scheduleHostedInspectorDockConfigurationSync( + reason: "\(reason).adaptiveBottomDock" + ) + } + return true + } + + @discardableResult + private func enforceAdaptiveBottomDockIfNeeded(reason: String) -> Bool { + guard let hit = hostedInspectorDividerCandidate(), + shouldForceHostedInspectorBottomDock(using: hit) else { + return false + } + recordHostedInspectorSideDockWidth(hit.inspectorView.frame.width) + return requestAdaptiveHostedInspectorBottomDock(reason: reason) + } + fileprivate func scheduleHostedInspectorDockConfigurationSync(reason: String) { hostedInspectorDockConfigurationSyncWorkItem?.cancel() guard hostedInspectorFrontendWebView != nil else { return } @@ -4180,22 +4377,37 @@ struct WebViewRepresentable: NSViewRepresentable { case "left": hostedInspectorSideDockDockSide = .leading if isHostedInspectorSideDockActive() { + if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).dockLeft") { + return + } layoutHostedInspectorSideDockIfNeeded(reason: "\(reason).dockLeft") } else if let slotView = localInlineSlotView, let hit = hostedInspectorDividerCandidate(in: slotView), hit.dockSide == .leading { + if shouldForceHostedInspectorBottomDock(using: hit) { + _ = requestAdaptiveHostedInspectorBottomDock(reason: "\(reason).dockLeft") + return + } activateHostedInspectorSideDockIfNeeded(using: hit) } case "right": hostedInspectorSideDockDockSide = .trailing if isHostedInspectorSideDockActive() { + if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).dockRight") { + return + } layoutHostedInspectorSideDockIfNeeded(reason: "\(reason).dockRight") } else if let slotView = localInlineSlotView, let hit = hostedInspectorDividerCandidate(in: slotView), hit.dockSide == .trailing { + if shouldForceHostedInspectorBottomDock(using: hit) { + _ = requestAdaptiveHostedInspectorBottomDock(reason: "\(reason).dockRight") + return + } activateHostedInspectorSideDockIfNeeded(using: hit) } default: + adaptiveBottomDockRequestCooldownDeadline = nil if isHostedInspectorSideDockActive() { deactivateHostedInspectorSideDockIfNeeded(reparentTo: localInlineSlotView) if dockConfiguration == "bottom" { @@ -4206,6 +4418,7 @@ struct WebViewRepresentable: NSViewRepresentable { } } } + updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "\(reason).dockConfiguration") } override func viewDidMoveToWindow() { @@ -4236,13 +4449,26 @@ struct WebViewRepresentable: NSViewRepresentable { override func layout() { super.layout() + _ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() + if enforceAdaptiveBottomDockIfNeeded(reason: "host.layout") { + updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout") + notifyGeometryChangedIfNeeded() +#if DEBUG + debugLogHostedInspectorLayoutIfNeeded(reason: "layout") +#endif + return + } if let previousSize = lastHostedInspectorLayoutBoundsSize, Self.sizeApproximatelyEqual(previousSize, bounds.size, epsilon: 0.5) { - if isHostedInspectorSideDockActive() { - layoutHostedInspectorSideDockIfNeeded(reason: "host.layout.sideDock.sameSize") - } else if !isHostedInspectorDividerDragActive && !hasStoredHostedInspectorWidthPreference { + // Origin-only frame churn is common while the surrounding split layout + // settles. Reapplying the side-docked inspector at the same size fights + // WebKit's own dock layout and shows up as visible flicker. + if !isHostedInspectorSideDockActive() && + !isHostedInspectorDividerDragActive && + !hasStoredHostedInspectorWidthPreference { captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout.sameSize") } + updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout.sameSize") notifyGeometryChangedIfNeeded() #if DEBUG debugLogHostedInspectorLayoutIfNeeded(reason: "layout") @@ -4255,6 +4481,7 @@ struct WebViewRepresentable: NSViewRepresentable { } else if !hasStoredHostedInspectorWidthPreference { captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout") } + updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout") scheduleHostedInspectorDockConfigurationSync(reason: "layout") notifyGeometryChangedIfNeeded() #if DEBUG @@ -4264,9 +4491,6 @@ struct WebViewRepresentable: NSViewRepresentable { override func setFrameOrigin(_ newOrigin: NSPoint) { super.setFrameOrigin(newOrigin) - if isHostedInspectorSideDockActive() { - layoutHostedInspectorSideDockIfNeeded(reason: "setFrameOrigin.sideDock") - } window?.invalidateCursorRects(for: self) notifyGeometryChangedIfNeeded() #if DEBUG @@ -4276,9 +4500,6 @@ struct WebViewRepresentable: NSViewRepresentable { override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) - if isHostedInspectorSideDockActive() { - layoutHostedInspectorSideDockIfNeeded(reason: "setFrameSize.sideDock") - } window?.invalidateCursorRects(for: self) notifyGeometryChangedIfNeeded() #if DEBUG @@ -4419,6 +4640,7 @@ struct WebViewRepresentable: NSViewRepresentable { inspectorView: dragState.inspectorView, dockSide: dragState.dockSide ), + minimumInspectorWidth: Self.minimumHostedInspectorWidth, reason: "drag" ) #if DEBUG @@ -4698,6 +4920,7 @@ struct WebViewRepresentable: NSViewRepresentable { let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.hostedInspectorReapplyWorkItem = nil + _ = self.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() if self.isHostedInspectorSideDockActive() { self.reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: reason) } else if !self.hasStoredHostedInspectorWidthPreference { @@ -4729,6 +4952,7 @@ struct WebViewRepresentable: NSViewRepresentable { let inspectorWidth = max(0, hit.inspectorView.frame.width) guard inspectorWidth > 1 else { return } + recordHostedInspectorSideDockWidth(inspectorWidth) let currentFraction: CGFloat? = { guard hit.containerView.bounds.width > 0 else { return nil } return inspectorWidth / hit.containerView.bounds.width @@ -4766,13 +4990,19 @@ struct WebViewRepresentable: NSViewRepresentable { } let currentInspectorWidth = max(0, hit.inspectorView.frame.width) guard abs(currentInspectorWidth - preferredWidth) > 0.5 else { return } - _ = applyHostedInspectorDividerWidth(preferredWidth, to: hit, reason: reason) + _ = applyHostedInspectorDividerWidth( + preferredWidth, + to: hit, + minimumInspectorWidth: Self.minimumHostedInspectorWidth, + reason: reason + ) } @discardableResult private func applyHostedInspectorDividerWidth( _ preferredWidth: CGFloat, to hit: HostedInspectorDividerHit, + minimumInspectorWidth: CGFloat, reason: String ) -> (pageFrame: NSRect, inspectorFrame: NSRect) { let containerBounds = hit.containerView.bounds @@ -4781,7 +5011,7 @@ struct WebViewRepresentable: NSViewRepresentable { in: containerBounds, pageFrame: hit.pageView.frame, inspectorFrame: hit.inspectorView.frame, - minimumInspectorWidth: 0 + minimumInspectorWidth: minimumInspectorWidth ) let pageFrame = nextFrames.pageFrame let inspectorFrame = nextFrames.inspectorFrame @@ -4793,6 +5023,7 @@ struct WebViewRepresentable: NSViewRepresentable { guard pageChanged || inspectorChanged else { return (pageFrame, inspectorFrame) } + recordHostedInspectorSideDockWidth(inspectorFrame.width) isApplyingHostedInspectorLayout = true CATransaction.begin() @@ -4802,6 +5033,19 @@ struct WebViewRepresentable: NSViewRepresentable { CATransaction.commit() isApplyingHostedInspectorLayout = false + hit.pageView.needsDisplay = true + hit.pageView.setNeedsDisplay(hit.pageView.bounds) + hit.inspectorView.needsDisplay = true + hit.inspectorView.setNeedsDisplay(hit.inspectorView.bounds) + hit.containerView.needsDisplay = true + hit.containerView.setNeedsDisplay(hit.containerView.bounds) + if let localInlineSlotView { + localInlineSlotView.needsDisplay = true + localInlineSlotView.setNeedsDisplay(localInlineSlotView.bounds) + } + needsDisplay = true + setNeedsDisplay(bounds) + let isLiveDrag = reason == "drag" #if DEBUG dlog( @@ -5148,6 +5392,7 @@ struct WebViewRepresentable: NSViewRepresentable { webView.layoutSubtreeIfNeeded() slotView.layoutSubtreeIfNeeded() host.layoutSubtreeIfNeeded() + host.normalizeHostedInspectorLayoutIfNeeded(reason: "localInline.update.immediate") host.scheduleHostedInspectorDividerReapply(reason: "localInline.update.sync") DispatchQueue.main.async { [weak host, weak webView] in guard let host, let webView else { return } diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index f2947851..aaf751d9 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -143,6 +143,14 @@ final class CmuxWebView: WKWebView { return result } + if !shouldRouteCommandEquivalentDirectlyToMainMenu(event) { + let result = super.performKeyEquivalent(with: event) +#if DEBUG + handled = result +#endif + return result + } + // Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc). if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { #if DEBUG diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index a0b890f0..b44fbffb 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -724,7 +724,7 @@ final class WindowTerminalPortal: NSObject { return frameInContainer.width > 1 && frameInContainer.height > 1 } - private func synchronizeAllEntriesFromExternalGeometryChange() { + fileprivate func synchronizeAllEntriesFromExternalGeometryChange() { guard ensureInstalled() else { return } synchronizeLayoutHierarchy() synchronizeAllHostedViews(excluding: nil) @@ -1635,6 +1635,7 @@ final class WindowTerminalPortal: NSObject { enum TerminalWindowPortalRegistry { private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:] private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] + private static var hasPendingExternalGeometrySyncForAllWindows = false #if DEBUG private static var blockedBindCount: Int = 0 private static var blockedBindReasons: [String: Int] = [:] @@ -1780,6 +1781,17 @@ enum TerminalWindowPortalRegistry { portal.synchronizeHostedViewForAnchor(anchorView) } + static func scheduleExternalGeometrySynchronizeForAllWindows() { + guard !Self.hasPendingExternalGeometrySyncForAllWindows else { return } + Self.hasPendingExternalGeometrySyncForAllWindows = true + DispatchQueue.main.async { + Self.hasPendingExternalGeometrySyncForAllWindows = false + for portal in Self.portalsByWindowId.values { + portal.synchronizeAllEntriesFromExternalGeometryChange() + } + } + } + static func hideHostedView(_ hostedView: GhosttySurfaceScrollView) { let hostedId = ObjectIdentifier(hostedView) guard let windowId = hostedToWindowId[hostedId], diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 1df2b75a..984df39c 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -193,6 +193,14 @@ struct ShortcutHintHorizontalPlanner { } } +func titlebarShortcutHintHeight(for config: TitlebarControlsStyleConfig) -> CGFloat { + max(14, config.iconSize + 1) +} + +func titlebarShortcutHintVerticalOffset(for config: TitlebarControlsStyleConfig) -> CGFloat { + max(0, floor(config.buttonSize - titlebarShortcutHintHeight(for: config))) +} + struct TitlebarControlButton: View { let config: TitlebarControlsStyleConfig let action: () -> Void @@ -240,7 +248,6 @@ struct TitlebarControlsView: View { @StateObject private var modifierKeyMonitor = TitlebarShortcutHintModifierMonitor() private let titlebarHintRightSafetyShift: CGFloat = 10 private let titlebarHintBaseXShift: CGFloat = -10 - private let titlebarHintBaseYShift: CGFloat = 1 private enum HintSlot: Int, CaseIterable { case toggleSidebar @@ -304,7 +311,7 @@ struct TitlebarControlsView: View { } private func titlebarHintVerticalBaseOffset(for config: TitlebarControlsStyleConfig) -> CGFloat { - max(8, config.buttonSize * 0.4) + titlebarShortcutHintVerticalOffset(for: config) } @ViewBuilder @@ -452,7 +459,6 @@ struct TitlebarControlsView: View { ) -> some View { let yOffset = config.groupPadding.top + titlebarHintVerticalBaseOffset(for: config) - + titlebarHintBaseYShift + ShortcutHintDebugSettings.clamped(titlebarShortcutHintYOffset) ZStack(alignment: .topLeading) { @@ -480,7 +486,7 @@ struct TitlebarControlsView: View { .foregroundColor(.primary) .padding(.horizontal, 6) .padding(.vertical, 2) - .frame(minHeight: max(14, config.iconSize + 1)) + .frame(minHeight: titlebarShortcutHintHeight(for: config)) .background(ShortcutHintPillBackground()) } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index ecfaf772..bbe59232 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -111,6 +111,23 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } + private final class WindowCyclingActionSpy: NSObject { + weak var firstWindow: NSWindow? + weak var secondWindow: NSWindow? + private(set) var invocationCount = 0 + + @objc func cycleWindow(_ sender: Any?) { + invocationCount += 1 + guard let firstWindow, let secondWindow else { return } + + if NSApp.keyWindow === firstWindow { + secondWindow.makeKeyAndOrderFront(nil) + } else { + firstWindow.makeKeyAndOrderFront(nil) + } + } + } + private final class FirstResponderView: NSView { override var acceptsFirstResponder: Bool { true } } @@ -677,15 +694,145 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } XCTAssertTrue(window.makeFirstResponder(responder)) } + + @MainActor + func testCmdBacktickMenuActionThatChangesKeyWindowOnlyRunsOnceWhenTerminalIsFirstResponder() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let firstWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let secondWindow = NSWindow( + contentRect: NSRect(x: 40, y: 40, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + + let firstContainer = NSView(frame: firstWindow.contentRect(forFrameRect: firstWindow.frame)) + let secondContainer = NSView(frame: secondWindow.contentRect(forFrameRect: secondWindow.frame)) + firstWindow.contentView = firstContainer + secondWindow.contentView = secondContainer + + let firstTerminal = GhosttyNSView(frame: firstContainer.bounds) + firstTerminal.autoresizingMask = [.width, .height] + firstContainer.addSubview(firstTerminal) + + let secondTerminal = GhosttyNSView(frame: secondContainer.bounds) + secondTerminal.autoresizingMask = [.width, .height] + secondContainer.addSubview(secondTerminal) + + let spy = WindowCyclingActionSpy() + spy.firstWindow = firstWindow + spy.secondWindow = secondWindow + installMenu( + target: spy, + action: #selector(WindowCyclingActionSpy.cycleWindow(_:)), + key: "`", + modifiers: [.command] + ) + + secondWindow.orderFront(nil) + firstWindow.makeKeyAndOrderFront(nil) + defer { + secondWindow.orderOut(nil) + firstWindow.orderOut(nil) + } + + XCTAssertTrue(firstWindow.makeFirstResponder(firstTerminal)) + guard let event = makeKeyDownEvent( + key: "`", + modifiers: [.command], + keyCode: 50, + windowNumber: firstWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+` event") + return + } + + NSApp.sendEvent(event) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + XCTAssertEqual(spy.invocationCount, 1, "Cmd+` should only trigger one window-cycle action") + } + + @MainActor + func testCmdBacktickDoesNotRouteDirectlyToMainMenuWhenWebViewIsFirstResponder() { + _ = NSApplication.shared + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + let spy = ActionSpy() + installMenu( + target: spy, + action: #selector(ActionSpy.didInvoke(_:)), + key: "`", + modifiers: [.command] + ) + + window.makeKeyAndOrderFront(nil) + defer { + window.orderOut(nil) + } + + XCTAssertTrue(window.makeFirstResponder(webView)) + guard let event = makeKeyDownEvent( + key: "`", + modifiers: [.command], + keyCode: 50, + windowNumber: window.windowNumber + ) else { + XCTFail("Failed to construct Cmd+` event") + return + } + + XCTAssertFalse(shouldRouteCommandEquivalentDirectlyToMainMenu(event)) + _ = webView.performKeyEquivalent(with: event) + XCTAssertFalse( + spy.invoked, + "CmuxWebView should not route Cmd+` directly to the menu when WebKit is first responder" + ) + } + private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) { + installMenu( + target: spy, + action: #selector(ActionSpy.didInvoke(_:)), + key: key, + modifiers: modifiers + ) + } + + private func installMenu( + target: NSObject, + action: Selector, + key: String, + modifiers: NSEvent.ModifierFlags + ) { let mainMenu = NSMenu() let fileItem = NSMenuItem(title: "File", action: nil, keyEquivalent: "") let fileMenu = NSMenu(title: "File") - let item = NSMenuItem(title: "Test Item", action: #selector(ActionSpy.didInvoke(_:)), keyEquivalent: key) + let item = NSMenuItem(title: "Test Item", action: action, keyEquivalent: key) item.keyEquivalentModifierMask = modifiers - item.target = spy + item.target = target fileMenu.addItem(item) mainMenu.addItem(fileItem) @@ -696,13 +843,18 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { NSApp.mainMenu = mainMenu } - private func makeKeyDownEvent(key: String, modifiers: NSEvent.ModifierFlags, keyCode: UInt16) -> NSEvent? { + private func makeKeyDownEvent( + key: String, + modifiers: NSEvent.ModifierFlags, + keyCode: UInt16, + windowNumber: Int = 0 + ) -> NSEvent? { NSEvent.keyEvent( with: .keyDown, location: .zero, modifierFlags: modifiers, timestamp: ProcessInfo.processInfo.systemUptime, - windowNumber: 0, + windowNumber: windowNumber, context: nil, characters: key, charactersIgnoringModifiers: key, @@ -2606,6 +2758,10 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { return nil } + private func waitForDeveloperToolsTransitions() { + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + } + private func findWindowBrowserSlotView(in root: NSView) -> WindowBrowserSlotView? { if let slot = root as? WindowBrowserSlotView { return slot @@ -2699,6 +2855,37 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach()) } + func testRapidToggleCoalescesToFinalVisibleIntentWithoutExtraInspectorCalls() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 0) + + waitForDeveloperToolsTransitions() + + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 0) + } + + func testRapidToggleQueuesHideAfterOpenTransitionSettles() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertTrue(panel.toggleDeveloperTools()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 0) + + waitForDeveloperToolsTransitions() + + XCTAssertFalse(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 1) + } + func testToggleDeveloperToolsFallsBackToCloseWhenHideDoesNotConcealInspector() { let (panel, inspector) = makePanelWithInspector(hideBehavior: .noEffect) @@ -9451,6 +9638,18 @@ final class BrowserPanelHostContainerViewTests: XCTestCase { } } + private final class TrackingInspectorFrontendWebView: WKWebView { + private(set) var evaluatedJavaScript: [String] = [] + + @MainActor override func evaluateJavaScript( + _ javaScriptString: String, + completionHandler: (@MainActor @Sendable (Any?, (any Error)?) -> Void)? = nil + ) { + evaluatedJavaScript.append(javaScriptString) + completionHandler?(nil, nil) + } + } + private final class WKInspectorProbeView: NSView { override func hitTest(_ point: NSPoint) -> NSView? { bounds.contains(point) ? self : nil @@ -9588,6 +9787,45 @@ final class BrowserPanelHostContainerViewTests: XCTestCase { XCTAssertGreaterThan(inspectorContainer.frame.minX, 0) } + func testBrowserPanelHostClaimsHostedInspectorDividerAcrossFullHeight() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 20, width: 92, height: webViewRoot.bounds.height - 40)) + let inspectorContainer = EdgeTransparentWKInspectorProbeView( + frame: NSRect(x: 92, y: 20, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height - 40) + ) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + XCTAssertTrue( + host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host, + "The custom DevTools divider should remain draggable at the top edge of the browser pane" + ) + XCTAssertTrue( + host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host, + "The custom DevTools divider should remain draggable at the bottom edge of the browser pane" + ) + } + func testBrowserPanelHostFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), @@ -9639,6 +9877,346 @@ final class BrowserPanelHostContainerViewTests: XCTestCase { XCTAssertGreaterThan(inspectorContainer.frame.minX, 92) } + func testBrowserPanelHostKeepsInspectorResizableAfterShrinkingToMinimumWidth() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let webViewRoot = NSView(frame: host.bounds) + webViewRoot.autoresizingMask = [.width, .height] + host.addSubview(webViewRoot) + + let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)) + let inspectorContainer = EdgeTransparentWKInspectorProbeView( + frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height) + ) + webViewRoot.addSubview(pageView) + webViewRoot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + + host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x + 220, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThanOrEqual( + inspectorContainer.frame.width, + 120, + "Shrinking the DevTools pane should clamp to a recoverable minimum width" + ) + XCTAssertTrue( + host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host, + "After clamping, the DevTools divider should still be draggable near the top edge" + ) + XCTAssertTrue( + host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host, + "After clamping, the DevTools divider should still be draggable near the bottom edge" + ) + } + + func testBrowserPanelHostPromotesVisibleRightDockedInspectorIntoManagedSideDock() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height + 180)) + let inspectorView = WKWebView( + frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue( + host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(), + "A visible right-docked inspector should not wait on async dock-configuration JS before entering the managed side-dock path" + ) + XCTAssertTrue( + pageView.superview === inspectorView.superview && pageView.superview !== slotView, + "Promotion should move both hosted inspector siblings into the managed side-dock container" + ) + XCTAssertEqual( + pageView.frame.height, + host.bounds.height, + accuracy: 0.5, + "Promotion should normalize stale page heights to the host height so the page layer stops covering the divider" + ) + XCTAssertEqual( + inspectorView.frame.height, + host.bounds.height, + accuracy: 0.5, + "Promotion should normalize the inspector height to the host height" + ) + } + + func testBrowserPanelHostAllowsRightDockedInspectorToExpandLeftAfterPromotion() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height)) + let inspectorView = WKWebView( + frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue( + host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(), + "The managed side-dock path should be active before drag assertions run" + ) + + let initialPageWidth = pageView.frame.width + let initialInspectorWidth = inspectorView.frame.width + let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + + host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x - 40, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + XCTAssertGreaterThan( + inspectorView.frame.width, + initialInspectorWidth, + "Right-docked DevTools should expand when the divider is dragged left" + ) + XCTAssertLessThan( + pageView.frame.width, + initialPageWidth, + "Expanding right-docked DevTools should shrink the page width" + ) + } + + func testBrowserPanelHostKeepsAutomaticRightDockedWidthAboveMinimumWhileShrinking() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 140, y: 0, width: 280, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 132, height: host.bounds.height)) + let inspectorView = WKWebView( + frame: NSRect(x: 132, y: 0, width: slotView.bounds.width - 132, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()) + + host.setPreferredHostedInspectorWidth(width: 80, widthFraction: nil) + host.setFrameSize(NSSize(width: 210, height: host.frame.height)) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertGreaterThanOrEqual( + inspectorView.frame.width, + 120, + "Automatic pane resize should honor the same minimum hosted inspector width as manual dragging" + ) + XCTAssertEqual( + inspectorView.frame.height, + host.bounds.height, + accuracy: 0.5, + "Automatic shrink should keep the inspector vertically normalized to the host height" + ) + } + + func testBrowserPanelHostRequestsBottomDockWhenSideDockLeavesTooLittlePageWidth() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 280, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height)) + let inspectorView = TrackingInspectorFrontendWebView( + frame: NSRect(x: 120, y: 0, width: slotView.bounds.width - 120, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()) + + host.setFrameSize(NSSize(width: 210, height: host.frame.height)) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue( + inspectorView.evaluatedJavaScript.contains(where: { $0.contains("WI._dockBottom()") }), + "Narrow pane widths should request bottom-docked DevTools instead of leaving the side-docked inspector in an unstable layout" + ) + XCTAssertTrue( + inspectorView.evaluatedJavaScript.contains(where: { $0.contains("const allowSideDock = false;") }), + "Once a narrow pane proves it cannot safely side-dock DevTools, the inspector frontend should hide and disable left/right dock controls" + ) + } + + func testBrowserPanelManagedSideDockDoesNotAutoresizeDraggedFrames() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)) + host.autoresizingMask = [.minXMargin, .height] + contentView.addSubview(host) + + let slotView = host.ensureLocalInlineSlotView() + let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height)) + let inspectorView = WKWebView( + frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height) + ) + slotView.addSubview(pageView) + slotView.addSubview(inspectorView) + host.pinHostedWebView(pageView, in: slotView) + host.setHostedInspectorFrontendWebView(inspectorView) + contentView.layoutSubtreeIfNeeded() + host.layoutSubtreeIfNeeded() + + XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()) + + let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY) + let dividerPointInWindow = host.convert(dividerPointInHost, to: nil) + host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)) + let drag = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: dividerPointInWindow.x - 30, y: dividerPointInWindow.y), + window: window + ) + host.mouseDragged(with: drag) + host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window)) + + guard let managedContainer = pageView.superview else { + XCTFail("Expected managed side-dock container") + return + } + let draggedPageFrame = pageView.frame + let draggedInspectorFrame = inspectorView.frame + + managedContainer.setFrameSize( + NSSize(width: managedContainer.frame.width, height: managedContainer.frame.height + 24) + ) + + XCTAssertEqual( + pageView.frame.origin.x, + draggedPageFrame.origin.x, + accuracy: 0.5, + "Managed side-dock container should not autoresize the page back to a stale divider position" + ) + XCTAssertEqual( + pageView.frame.width, + draggedPageFrame.width, + accuracy: 0.5, + "Managed side-dock container should preserve the dragged page width until the host explicitly reapplies layout" + ) + XCTAssertEqual( + inspectorView.frame.origin.x, + draggedInspectorFrame.origin.x, + accuracy: 0.5, + "Managed side-dock container should preserve the dragged inspector origin" + ) + XCTAssertEqual( + inspectorView.frame.width, + draggedInspectorFrame.width, + accuracy: 0.5, + "Managed side-dock container should preserve the dragged inspector width" + ) + } + func testBrowserPanelHostFallsBackToManualHostedInspectorDragForLeftDockedInspector() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), @@ -11503,6 +12081,80 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { portal.synchronizeHostedViewForAnchor(anchor) XCTAssertFalse(hosted.isHidden, "Portal should unhide after geometry is usable") } + + func testScheduledExternalGeometrySyncRefreshesAncestorLayoutShift() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + window.orderOut(nil) + } + + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let shiftedContainer = NSView(frame: NSRect(x: 120, y: 60, width: 220, height: 160)) + contentView.addSubview(shiftedContainer) + let anchor = NSView(frame: NSRect(x: 24, y: 28, width: 72, height: 56)) + shiftedContainer.addSubview(anchor) + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + let hosted = surface.hostedView + TerminalWindowPortalRegistry.bind( + hostedView: hosted, + to: anchor, + visibleInUI: true, + expectedSurfaceId: surface.id, + expectedGeneration: surface.portalBindingGeneration() + ) + TerminalWindowPortalRegistry.synchronizeForAnchor(anchor) + + let anchorCenter = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY) + let originalWindowPoint = anchor.convert(anchorCenter, to: nil) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), + "Initial hit-testing should resolve the portal-hosted terminal at its original window position" + ) + + shiftedContainer.frame.origin.x += 96 + contentView.layoutSubtreeIfNeeded() + window.displayIfNeeded() + + let shiftedWindowPoint = anchor.convert(anchorCenter, to: nil) + XCTAssertNotEqual(originalWindowPoint.x, shiftedWindowPoint.x, accuracy: 0.5) + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), + "Ancestor-only layout shifts should leave the portal stale until an external geometry sync runs" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), + "Before the external geometry sync, hit-testing should still point at the stale portal location" + ) + + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), + "The stale portal position should be cleared after the scheduled external geometry sync" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), + "The scheduled external geometry sync should move the portal-hosted terminal to the anchor's new window position" + ) + } } @MainActor @@ -11793,6 +12445,33 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5) } + func testPortalSlotPinPreservesSideDockedInspectorManagedWebViewFrameOnRehost() { + let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 240, height: 160)) + let webView = CmuxWebView(frame: NSRect(x: 0, y: 0, width: 132, height: 160), configuration: WKWebViewConfiguration()) + let inspectorContainer = NSView(frame: NSRect(x: 132, y: 0, width: 108, height: 160)) + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + slot.addSubview(webView) + slot.addSubview(inspectorContainer) + + webView.translatesAutoresizingMaskIntoConstraints = false + webView.autoresizingMask = [] + slot.pinHostedWebView(webView) + + XCTAssertEqual( + webView.frame.maxX, + inspectorContainer.frame.minX, + accuracy: 0.5, + "Rehosting a portal-managed browser should preserve the WebKit-owned side inspector split" + ) + XCTAssertLessThan( + webView.frame.width, + slot.bounds.width, + "The page frame should stay narrower than the full slot while a side-docked inspector is present" + ) + } + func testPortalResizePreservesSideDockedInspectorManagedWebViewFrame() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), diff --git a/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift b/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift new file mode 100644 index 00000000..e2718c9a --- /dev/null +++ b/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift @@ -0,0 +1,62 @@ +import XCTest +import AppKit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class GhosttyEnsureFocusWindowActivationTests: XCTestCase { + func testAllowsActivationForActiveManager() { + let activeManager = TabManager() + let otherManager = TabManager() + + XCTAssertTrue( + shouldAllowEnsureFocusWindowActivation( + activeTabManager: activeManager, + targetTabManager: activeManager, + keyWindow: NSWindow(), + mainWindow: NSWindow() + ) + ) + XCTAssertFalse( + shouldAllowEnsureFocusWindowActivation( + activeTabManager: activeManager, + targetTabManager: otherManager, + keyWindow: NSWindow(), + mainWindow: NSWindow() + ) + ) + } + + func testAllowsActivationWhenAppHasNoKeyAndNoMainWindow() { + let targetManager = TabManager() + + XCTAssertTrue( + shouldAllowEnsureFocusWindowActivation( + activeTabManager: nil, + targetTabManager: targetManager, + keyWindow: nil, + mainWindow: nil + ) + ) + XCTAssertFalse( + shouldAllowEnsureFocusWindowActivation( + activeTabManager: nil, + targetTabManager: targetManager, + keyWindow: NSWindow(), + mainWindow: nil + ) + ) + XCTAssertFalse( + shouldAllowEnsureFocusWindowActivation( + activeTabManager: nil, + targetTabManager: targetManager, + keyWindow: nil, + mainWindow: NSWindow() + ) + ) + } +} diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 96826edf..1225c111 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -166,6 +166,21 @@ final class TitlebarControlsSizingPolicyTests: XCTestCase { ) XCTAssertTrue(titlebarControlsShouldApplyLayout(previous: baseline, next: changed)) } + + func testShortcutHintVerticalOffsetKeepsPillInsideButtonLane() { + for style in TitlebarControlsStyle.allCases { + let config = style.config + let hintHeight = titlebarShortcutHintHeight(for: config) + let verticalOffset = titlebarShortcutHintVerticalOffset(for: config) + + XCTAssertGreaterThanOrEqual(verticalOffset, 0, "Expected non-negative hint offset for style \(style)") + XCTAssertLessThanOrEqual( + verticalOffset + hintHeight, + config.buttonSize, + "Expected hint pill to fit within the titlebar button lane for style \(style)" + ) + } + } } final class TitlebarControlsHoverPolicyTests: XCTestCase { diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index c98a76c3..30ba29bf 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,7 +12,7 @@ When we change the fork, update this document and the parent submodule SHA. ## Current fork changes -Fork rebased onto upstream `v1.3.0` plus newer `main` commits as of March 9, 2026. +Fork rebased onto upstream `v1.3.0` plus newer `main` commits as of March 12, 2026. ### 1) OSC 99 (kitty) notification parser @@ -45,8 +45,9 @@ Fork rebased onto upstream `v1.3.0` plus newer `main` commits as of March 9, 202 ### 4) macOS resize stale-frame mitigation -Sections 3 and 4 are grouped by feature, not by commit order. The fork branch HEAD is the -section 3 copy-mode commit, even though the section 4 resize commits were applied earlier. +Sections 3 and 4 are grouped by feature, not by commit order. The section 4 resize commits were +applied earlier than the section 3 copy-mode commit, but they are kept together here because they +touch the same stale-frame mitigation path and tend to conflict in the same files during rebases. - Commits: - `769bbf7a9` (macos: reduce transient blank/scaled frames during resize) @@ -62,6 +63,26 @@ section 3 copy-mode commit, even though the section 4 resize commits were applie - Replays the last rendered frame during resize and keeps its geometry anchored correctly. - Reduces transient blank or scaled frames while a macOS window is being resized. +### 5) zsh prompt redraw markers use OSC 133 P + +- Commit: `8ade43ce5` (zsh: use OSC 133 P for prompt redraws) +- Files: + - `src/shell-integration/zsh/ghostty-integration` +- Summary: + - Emits one `OSC 133;A` fresh-prompt mark for real prompt transitions. + - Uses `OSC 133;P` markers for prompt redraws so async zsh themes do not look like extra prompt lines. + +### 6) zsh Pure-style multiline prompt redraws + +- Commit: `0cf559581` (zsh: fix Pure-style multiline prompt redraws) +- Files: + - `src/shell-integration/zsh/ghostty-integration` +- Summary: + - Handles multiline prompts that use `\n%{\r%}` to return to column 0 before the visible prompt line. + - Places the continuation marker after Pure's hidden carriage return so async redraws do not leave stale preprompt lines behind. + +The fork branch HEAD is now the section 6 zsh redraw commit. + ## Upstreamed fork changes ### cursor-click-to-move respects OSC 133 click-to-move @@ -80,4 +101,9 @@ These files change frequently upstream; be careful when rebasing the fork: - `src/terminal/osc.zig` - OSC dispatch logic moves often. Re-check the integration points for the OSC 99 parser. +- `src/shell-integration/zsh/ghostty-integration` + - Prompt marker handling is easy to regress when upstream adjusts zsh redraw behavior. Keep the + `OSC 133;A` vs `OSC 133;P` split intact for redraw-heavy themes, and preserve the special + handling for Pure-style `\n%{\r%}` prompt newlines. + If you resolve a conflict, update this doc with what changed. diff --git a/ghostty b/ghostty index c47010b8..0cf55958 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit c47010b80cd9ae6d1ab744c120f011a465521ea3 +Subproject commit 0cf5595817794466e3a60abe6bf97f8494dedcfe diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 522da07a..986b55d2 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -4,3 +4,4 @@ 7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207 a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d c47010b80cd9ae6d1ab744c120f011a465521ea3 d6904870a3c920b2787b1c4b950cfdef232606bb9876964f5e8497081d5cb5df +0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de diff --git a/tests/test_cli_version_memory_guard.py b/tests/test_cli_version_memory_guard.py index 0a1c5bd1..6252ea5e 100644 --- a/tests/test_cli_version_memory_guard.py +++ b/tests/test_cli_version_memory_guard.py @@ -83,6 +83,8 @@ def build_fixture(root: str, cli_path: str) -> str: with open(os.path.join(contents_path, "Info.plist"), "wb") as handle: plistlib.dump(info, handle) + # Regular files are enough here because the fallback scan keys off the + # ".app" suffix before it ever tries to inspect bundle contents. for index in range(JUNK_APP_COUNT): open(os.path.join(resources_path, f"junk-{index:05d}.app"), "wb").close() diff --git a/tests/test_ghostty_zsh_prompt_redraw_uses_prompt_start.py b/tests/test_ghostty_zsh_prompt_redraw_uses_prompt_start.py new file mode 100644 index 00000000..32ff0d64 --- /dev/null +++ b/tests/test_ghostty_zsh_prompt_redraw_uses_prompt_start.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +Regression: zsh prompt redraws should not replay fresh-line OSC 133;A markers. + +Prompt themes with async redraws (such as Prezto-like setups) can call +`zle reset-prompt` after the prompt is already visible. Ghostty's zsh shell +integration should emit a single fresh prompt mark for the actual prompt, then +use OSC 133;P for redraws so redraws stay in place instead of looking like +extra prompt lines. +""" + +from __future__ import annotations + +import os +import pty +import select +import shutil +import subprocess +import tempfile +import time +from pathlib import Path + + +FRESH_PROMPT = b"\x1b]133;A;cl=line\x07" +PROMPT_START = b"\x1b]133;P;k=i\x07" +END_COMMAND = b"\x1b]133;D\x07" +START_OUTPUT = b"\x1b]133;C\x07" + + +def _write_redrawing_zshrc(path: Path) -> None: + path.write_text( + """ +autoload -Uz add-zsh-hook + +setopt prompt_cr prompt_percent prompt_sp prompt_subst +PROMPT='%F{4}%1~%f %# ' +RPROMPT='' + +typeset -gi _cmux_redraw_done=0 +typeset -g _cmux_redraw_fd='' + +_cmux_redraw_precmd() { + _cmux_redraw_done=0 +} + +_cmux_redraw_ready() { + emulate -L zsh + local fd="${1:-$_cmux_redraw_fd}" + if [[ -n "$fd" ]]; then + zle -F "$fd" + exec {fd}<&- + fi + _cmux_redraw_fd='' + (( _cmux_redraw_done )) && return 0 + _cmux_redraw_done=1 + zle reset-prompt +} + +_cmux_redraw_line_init() { + if (( !_cmux_redraw_done )) && [[ -z "$_cmux_redraw_fd" ]]; then + exec {_cmux_redraw_fd}< <( + sleep 0.05 + printf 'ready\\n' + ) + zle -F "$_cmux_redraw_fd" _cmux_redraw_ready + fi +} + +add-zsh-hook precmd _cmux_redraw_precmd +zle -N zle-line-init _cmux_redraw_line_init +""".lstrip(), + encoding="utf-8", + ) + + +def _capture_session(env: dict[str, str], zsh_path: str) -> bytes: + master, slave = pty.openpty() + proc = subprocess.Popen( + [zsh_path, "-d", "-i"], + stdin=slave, + stdout=slave, + stderr=slave, + env=env, + close_fds=True, + ) + os.close(slave) + + output = bytearray() + start = time.time() + phase = 0 + try: + while time.time() - start < 5: + readable, _, _ = select.select([master], [], [], 0.2) + if master in readable: + try: + chunk = os.read(master, 4096) + except OSError: + break + if not chunk: + break + output.extend(chunk) + + elapsed = time.time() - start + if phase == 0 and elapsed > 1.0: + os.write(master, b"\n") + phase = 1 + elif phase == 1 and elapsed > 2.5: + os.write(master, b"exit\n") + phase = 2 + finally: + try: + proc.wait(timeout=5) + finally: + os.close(master) + + return bytes(output) + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + wrapper_dir = root / "ghostty" / "src" / "shell-integration" / "zsh" + if not (wrapper_dir / ".zshenv").exists(): + print(f"SKIP: missing Ghostty zsh wrapper at {wrapper_dir}") + return 0 + + zsh_path = shutil.which("zsh") + if zsh_path is None: + print("SKIP: zsh not installed") + return 0 + + base = Path(tempfile.mkdtemp(prefix="cmux_ghostty_prompt_redraw_")) + try: + home = base / "home" + home.mkdir(parents=True, exist_ok=True) + _write_redrawing_zshrc(home / ".zshrc") + + env = dict(os.environ) + env["HOME"] = str(home) + env["ZDOTDIR"] = str(wrapper_dir) + env["GHOSTTY_ZSH_ZDOTDIR"] = str(home) + env["GHOSTTY_RESOURCES_DIR"] = str(root / "ghostty" / "src") + env.pop("GHOSTTY_SHELL_FEATURES", None) + env.pop("GHOSTTY_BIN_DIR", None) + + output = _capture_session(env, zsh_path) + + marker = output.find(END_COMMAND) + if marker == -1: + print("FAIL: did not observe OSC 133;D for the empty command prompt cycle") + return 1 + + end = output.find(START_OUTPUT, marker + len(END_COMMAND)) + if end == -1: + end = len(output) + + prompt_cycle = output[marker:end] + fresh_count = prompt_cycle.count(FRESH_PROMPT) + prompt_start_count = prompt_cycle.count(PROMPT_START) + + if fresh_count != 1: + print(f"FAIL: expected exactly 1 fresh prompt marker after redraw, saw {fresh_count}") + return 1 + + if prompt_start_count < 1: + print("FAIL: expected redraw path to emit OSC 133;P prompt-start markers") + return 1 + + print("PASS: zsh prompt redraws keep a single fresh prompt marker and reuse OSC 133;P") + return 0 + finally: + shutil.rmtree(base, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ghostty_zsh_pure_preprompt_redraw.py b/tests/test_ghostty_zsh_pure_preprompt_redraw.py new file mode 100644 index 00000000..ab916fe5 --- /dev/null +++ b/tests/test_ghostty_zsh_pure_preprompt_redraw.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +""" +Regression: Ghostty's zsh integration must not leave stale Pure-style preprompt +lines behind after an async redraw. + +Pure does not render its top path/git line as a static multiline PS1. Instead, +it rewrites PROMPT with a special newline sequence and later calls +`zle .reset-prompt` when async git info arrives. Plain zsh redraws that cleanly. +The Ghostty integration currently leaves stale copies of the old top line behind. + +This test uses a minimal Pure-like prompt implementation as a control: +- plain zsh must redraw without stale preprompt lines +- Ghostty-integrated zsh must match that behavior +""" + +from __future__ import annotations + +import os +import pty +import re +import select +import shutil +import subprocess +import tempfile +import time +from pathlib import Path + + +_MINIMAL_PURE_ZSHRC = r""" +setopt promptsubst nopromptcr nopromptsp +prompt_newline=$'\n%{\r%}' + +typeset -g CMUX_TOP='%F{4}%~%f' +typeset -g CMUX_LAST_PROMPT='' +typeset -gi CMUX_ASYNC_DONE=0 +typeset -g CMUX_ASYNC_FD='' + +cmux_render_prompt() { + local cleaned_ps1=$PROMPT + if [[ $PROMPT = *$prompt_newline* ]]; then + cleaned_ps1=${PROMPT##*${prompt_newline}} + fi + + PROMPT="${CMUX_TOP}${prompt_newline}${cleaned_ps1:-%F{5}❯%f }" + + local expanded_prompt="${(S%%)PROMPT}" + if [[ ${1:-} == precmd ]]; then + print + elif [[ $CMUX_LAST_PROMPT != $expanded_prompt ]]; then + zle && zle .reset-prompt + fi + typeset -g CMUX_LAST_PROMPT=$expanded_prompt +} + +cmux_async_ready() { + emulate -L zsh + local fd="${1:-$CMUX_ASYNC_FD}" + if [[ -n $fd ]]; then + zle -F "$fd" + exec {fd}<&- + fi + CMUX_ASYNC_FD='' + + (( CMUX_ASYNC_DONE )) && return + CMUX_ASYNC_DONE=1 + CMUX_TOP='%F{4}%~%f %F{242}main%f%F{218}*%f %F{6}⇣⇡%f' + cmux_render_prompt async +} + +precmd() { + CMUX_ASYNC_DONE=0 + cmux_render_prompt precmd +} + +cmux_line_init() { + if (( !CMUX_ASYNC_DONE )) && [[ -z $CMUX_ASYNC_FD ]]; then + exec {CMUX_ASYNC_FD}< <( + sleep 0.05 + printf 'ready\n' + ) + zle -F "$CMUX_ASYNC_FD" cmux_async_ready + fi +} + +zle -N zle-line-init cmux_line_init +PROMPT='%F{5}❯%f ' +""".lstrip() + +_ANSI_RE = re.compile(rb"\x1b\][^\x07]*\x07|\x1b\[[0-9;?]*[ -/]*[@-~]|\r") + + +def _capture_session( + *, + use_ghostty: bool, + wrapper_dir: Path, + resources_dir: Path, + workdir: Path, + zsh_path: str, +) -> str: + base = Path(tempfile.mkdtemp(prefix="cmux_ghostty_pure_preprompt_")) + try: + home = base / "home" + home.mkdir(parents=True, exist_ok=True) + (home / ".zshrc").write_text(_MINIMAL_PURE_ZSHRC, encoding="utf-8") + + env = dict(os.environ) + env["HOME"] = str(home) + env["TERM"] = "xterm-256color" + env.pop("GHOSTTY_SHELL_FEATURES", None) + env.pop("GHOSTTY_BIN_DIR", None) + if use_ghostty: + env["ZDOTDIR"] = str(wrapper_dir) + env["GHOSTTY_ZSH_ZDOTDIR"] = str(home) + env["GHOSTTY_RESOURCES_DIR"] = str(resources_dir) + else: + env["ZDOTDIR"] = str(home) + env.pop("GHOSTTY_ZSH_ZDOTDIR", None) + env.pop("GHOSTTY_RESOURCES_DIR", None) + + master, slave = pty.openpty() + proc = subprocess.Popen( + [zsh_path, "-d", "-i"], + cwd=str(workdir), + stdin=slave, + stdout=slave, + stderr=slave, + env=env, + close_fds=True, + ) + os.close(slave) + + output = bytearray() + start = time.time() + phase = 0 + try: + while time.time() - start < 4.5: + readable, _, _ = select.select([master], [], [], 0.2) + if master in readable: + try: + chunk = os.read(master, 4096) + except OSError: + break + if not chunk: + break + output.extend(chunk) + + elapsed = time.time() - start + if phase == 0 and elapsed > 1.2: + os.write(master, b"\n") + phase = 1 + elif phase == 1 and elapsed > 2.8: + os.write(master, b"exit\n") + phase = 2 + finally: + try: + proc.wait(timeout=5) + finally: + os.close(master) + + cleaned = _ANSI_RE.sub(b"", bytes(output)).decode("utf-8", errors="replace") + return cleaned + finally: + shutil.rmtree(base, ignore_errors=True) + + +def _stale_preprompt_lines(cleaned: str, path_line: str, async_line: str) -> tuple[int, int]: + marker = cleaned.find(async_line) + if marker == -1: + return (-1, -1) + + tail = cleaned[marker + len(async_line) :] + return (tail.count(path_line), tail.count(async_line)) + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + wrapper_dir = root / "ghostty" / "src" / "shell-integration" / "zsh" + resources_dir = root / "ghostty" / "src" + workdir = root + + if not (wrapper_dir / ".zshenv").exists(): + print(f"SKIP: missing Ghostty zsh wrapper at {wrapper_dir}") + return 0 + zsh_path = shutil.which("zsh") + if zsh_path is None: + print("SKIP: zsh not installed") + return 0 + + path_line = f"{workdir}\n" + async_line = f"{workdir} main* ⇣⇡" + + plain = _capture_session( + use_ghostty=False, + wrapper_dir=wrapper_dir, + resources_dir=resources_dir, + workdir=workdir, + zsh_path=zsh_path, + ) + ghostty = _capture_session( + use_ghostty=True, + wrapper_dir=wrapper_dir, + resources_dir=resources_dir, + workdir=workdir, + zsh_path=zsh_path, + ) + + plain_stale, plain_async = _stale_preprompt_lines(plain, path_line, async_line) + ghostty_stale, ghostty_async = _stale_preprompt_lines(ghostty, path_line, async_line) + + if plain_stale < 0: + print("FAIL: plain zsh control never rendered the async preprompt line") + return 1 + if ghostty_stale < 0: + print("FAIL: Ghostty zsh integration never rendered the async preprompt line") + return 1 + + if plain_stale != 0: + print(f"FAIL: plain zsh control left stale preprompt lines behind ({plain_stale})") + return 1 + + if ghostty_stale != plain_stale: + print( + "FAIL: Ghostty zsh integration left stale preprompt lines behind " + f"(ghostty={ghostty_stale}, plain={plain_stale}, async_renders={ghostty_async})" + ) + return 1 + + print("PASS: Ghostty zsh integration redraws Pure-style preprompts without stale lines") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_issue_1138_sidebar_pr_polling.py b/tests/test_issue_1138_sidebar_pr_polling.py new file mode 100644 index 00000000..973e98dd --- /dev/null +++ b/tests/test_issue_1138_sidebar_pr_polling.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +""" +Regression coverage for issue #1138. + +Validates that shell integration: +1) keeps polling PR state while idle and recovers after a transient gh failure +2) resolves the current branch PR via `gh pr view` instead of repository-wide + branch-name matching +3) clears stale PR state when the branch changes and the new probe fails +4) recovers when a gh probe wedges longer than the async timeout +5) keeps polling in bash after prompt-render helper commands run +6) tears down the timed-out gh probe instead of leaking it in the background +""" + +from __future__ import annotations + +import os +import shutil +import socket +import subprocess +import textwrap +from pathlib import Path + + +class BoundUnixSocket: + def __init__(self, path: Path) -> None: + self.path = path + self.sock: socket.socket | None = None + + def __enter__(self) -> "BoundUnixSocket": + self.path.parent.mkdir(parents=True, exist_ok=True) + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.bind(str(self.path)) + self.sock.listen(1) + return self + + def __exit__(self, exc_type, exc, tb) -> None: + if self.sock is not None: + self.sock.close() + try: + self.path.unlink() + except FileNotFoundError: + pass + + +def _write_executable(path: Path, contents: str) -> None: + path.write_text(contents, encoding="utf-8") + path.chmod(0o755) + + +def _git_stub() -> str: + return textwrap.dedent( + """\ + #!/bin/sh + repo_path="$PWD" + if [ "$1" = "-C" ]; then + repo_path="$2" + shift + shift + fi + + head_file="$repo_path/.git/HEAD" + branch="" + if [ -f "$head_file" ]; then + head_line="$(cat "$head_file")" + case "$head_line" in + ref:\ refs/heads/*) + branch="${head_line#ref: refs/heads/}" + ;; + esac + fi + + if [ "$1" = "branch" ] && [ "$2" = "--show-current" ]; then + if [ -n "$branch" ]; then + printf '%s\\n' "$branch" + fi + exit 0 + fi + + if [ "$1" = "status" ] && [ "$2" = "--porcelain" ] && [ "$3" = "-uno" ]; then + exit 0 + fi + + printf 'unexpected git args: %s\\n' "$*" >&2 + exit 1 + """ + ) + + +def _gh_stub() -> str: + return textwrap.dedent( + """\ + #!/bin/sh + args_log="${CMUX_TEST_GH_ARGS_LOG:?}" + count_file="${CMUX_TEST_GH_COUNT_FILE:?}" + pid_file="${CMUX_TEST_GH_PID_FILE:-}" + scenario="${CMUX_TEST_SCENARIO:?}" + head_file="${CMUX_TEST_HEAD_FILE:?}" + + printf '%s\\n' "$*" >> "$args_log" + + count=0 + if [ -f "$count_file" ]; then + count="$(cat "$count_file")" + fi + count=$((count + 1)) + printf '%s\\n' "$count" > "$count_file" + + if [ "$1" != "pr" ] || [ "$2" != "view" ]; then + printf 'unexpected gh args: %s\\n' "$*" >&2 + exit 9 + fi + + branch="" + if [ -f "$head_file" ]; then + head_line="$(cat "$head_file")" + case "$head_line" in + ref:\ refs/heads/*) + branch="${head_line#ref: refs/heads/}" + ;; + esac + fi + + case "$scenario" in + prompt_helper_idle) + printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' + ;; + transient_same_context) + if [ "$count" -eq 1 ]; then + printf 'rate limit exceeded\\n' >&2 + exit 1 + fi + printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' + ;; + branch_switch_clear) + if [ "$branch" = "feature/old" ]; then + printf '111\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/111\\n' + exit 0 + fi + if [ "$branch" = "feature/new" ]; then + printf 'network unavailable\\n' >&2 + exit 1 + fi + printf 'no pull requests found for branch "%s"\\n' "$branch" >&2 + exit 1 + ;; + timeout_recovery) + if [ "$count" -eq 1 ]; then + if [ -n "$pid_file" ]; then + printf '%s\\n' "$$" > "$pid_file" + fi + sleep "${CMUX_TEST_HANG_SECONDS:-4}" + exit 0 + fi + printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n' + ;; + *) + printf 'unknown scenario: %s\\n' "$scenario" >&2 + exit 2 + ;; + esac + """ + ) + + +def _shell_command(kind: str, scenario: str) -> str: + shared = { + "prompt_helper_idle": ( + 'cd "$CMUX_TEST_REPO"\n' + '_CMUX_PR_POLL_INTERVAL=1\n' + '_cmux_prompt_entry\n' + ': "$(/bin/printf helper)"\n' + 'sleep 3\n' + '_cmux_cleanup\n' + ), + "transient_same_context": ( + 'cd "$CMUX_TEST_REPO"\n' + '_CMUX_PR_POLL_INTERVAL=1\n' + '_cmux_prompt_entry\n' + 'sleep 3\n' + '_cmux_cleanup\n' + ), + "branch_switch_clear": ( + 'cd "$CMUX_TEST_REPO"\n' + '_CMUX_PR_POLL_INTERVAL=10\n' + '_cmux_prompt_entry\n' + 'sleep 1\n' + 'printf \'ref: refs/heads/feature/new\\n\' > "$CMUX_TEST_HEAD_FILE"\n' + '_cmux_prompt_entry\n' + 'sleep 2\n' + '_cmux_cleanup\n' + ), + "timeout_recovery": ( + 'cd "$CMUX_TEST_REPO"\n' + '_CMUX_PR_POLL_INTERVAL=1\n' + '_CMUX_ASYNC_JOB_TIMEOUT=1\n' + '_cmux_prompt_entry\n' + 'sleep 4\n' + '_cmux_cleanup\n' + ), + }[scenario] + + if kind == "zsh": + return textwrap.dedent( + f"""\ + source "$CMUX_TEST_SCRIPT" + _cmux_send() {{ print -r -- "$1" >> "$CMUX_TEST_SEND_LOG"; }} + _cmux_prompt_entry() {{ _cmux_precmd; }} + _cmux_cleanup() {{ _cmux_zshexit; }} + {shared}""" + ) + + if kind == "bash": + return textwrap.dedent( + f"""\ + source "$CMUX_TEST_SCRIPT" + _cmux_send() {{ printf '%s\\n' "$1" >> "$CMUX_TEST_SEND_LOG"; }} + _cmux_prompt_entry() {{ _cmux_prompt_command; }} + _cmux_cleanup() {{ type _cmux_bash_cleanup >/dev/null 2>&1 && _cmux_bash_cleanup; }} + {shared}""" + ) + + raise ValueError(f"Unsupported shell kind: {kind}") + + +def _read_lines(path: Path) -> list[str]: + if not path.exists(): + return [] + return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + + +def _report_line(number: int) -> str: + return ( + f"report_pr {number} https://github.com/manaflow-ai/cmux/pull/{number} " + "--state=open --tab=00000000-0000-0000-0000-000000000001 " + "--panel=00000000-0000-0000-0000-000000000002" + ) + + +def _pid_exists(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path, scenario: str) -> tuple[int, str]: + bindir = base / "bin" + repo = base / "repo" + repo_git = repo / ".git" + socket_path = base / "cmux.sock" + send_log = base / f"{shell}-{scenario}-send.log" + gh_count_file = base / f"{shell}-{scenario}-gh-count.txt" + gh_args_log = base / f"{shell}-{scenario}-gh-args.log" + gh_pid_file = base / f"{shell}-{scenario}-gh-pid.txt" + head_file = repo_git / "HEAD" + + bindir.mkdir(parents=True, exist_ok=True) + repo_git.mkdir(parents=True, exist_ok=True) + initial_branch = "feature/old" if scenario == "branch_switch_clear" else "feature/issue-1138" + head_file.write_text(f"ref: refs/heads/{initial_branch}\n", encoding="utf-8") + _write_executable(bindir / "git", _git_stub()) + _write_executable(bindir / "gh", _gh_stub()) + + env = dict(os.environ) + env["PATH"] = f"{bindir}:{env.get('PATH', '')}" + env["CMUX_SOCKET_PATH"] = str(socket_path) + env["CMUX_TAB_ID"] = "00000000-0000-0000-0000-000000000001" + env["CMUX_PANEL_ID"] = "00000000-0000-0000-0000-000000000002" + env["CMUX_TEST_SCRIPT"] = str(script) + env["CMUX_TEST_REPO"] = str(repo) + env["CMUX_TEST_SEND_LOG"] = str(send_log) + env["CMUX_TEST_GH_COUNT_FILE"] = str(gh_count_file) + env["CMUX_TEST_GH_ARGS_LOG"] = str(gh_args_log) + env["CMUX_TEST_GH_PID_FILE"] = str(gh_pid_file) + env["CMUX_TEST_SCENARIO"] = scenario + env["CMUX_TEST_HEAD_FILE"] = str(head_file) + env["CMUX_TEST_HANG_SECONDS"] = "4" + + with BoundUnixSocket(socket_path): + result = subprocess.run( + [shell, *shell_args, _shell_command(shell, scenario)], + env=env, + capture_output=True, + text=True, + timeout=12, + ) + + combined_output = (result.stdout or "") + (result.stderr or "") + if result.returncode != 0: + return (result.returncode, combined_output) + + send_lines = _read_lines(send_log) + gh_args_lines = _read_lines(gh_args_log) + gh_count = int((gh_count_file.read_text(encoding="utf-8").strip() or "0")) if gh_count_file.exists() else 0 + + if not gh_args_lines: + return (1, f"{shell}/{scenario}: expected at least one gh invocation") + if any(not line.startswith("pr view ") for line in gh_args_lines): + return (1, f"{shell}/{scenario}: expected gh pr view only\n" + "\n".join(gh_args_lines)) + + if scenario == "prompt_helper_idle": + if gh_count < 2: + return (1, f"{shell}/{scenario}: expected idle polling to survive prompt helpers, saw {gh_count}") + if _report_line(1138) not in send_lines: + return (1, f"{shell}/{scenario}: missing report_pr payload\n" + "\n".join(send_lines)) + return (0, f"{shell}/{scenario}: ok") + + if scenario == "transient_same_context": + if gh_count < 2: + return (1, f"{shell}/{scenario}: expected at least 2 gh probes while idle, saw {gh_count}") + if any(line.startswith("clear_pr ") for line in send_lines): + return (1, f"{shell}/{scenario}: transient failure should not clear PR state\n" + "\n".join(send_lines)) + if _report_line(1138) not in send_lines: + return (1, f"{shell}/{scenario}: expected recovered report_pr payload\n" + "\n".join(send_lines)) + return (0, f"{shell}/{scenario}: ok") + + if scenario == "branch_switch_clear": + old_report = _report_line(111) + if old_report not in send_lines: + return (1, f"{shell}/{scenario}: missing old-branch report\n" + "\n".join(send_lines)) + try: + old_index = send_lines.index(old_report) + except ValueError: + return (1, f"{shell}/{scenario}: missing old-branch report\n" + "\n".join(send_lines)) + clear_indices = [idx for idx, line in enumerate(send_lines) if line.startswith("clear_pr ")] + if not clear_indices: + return (1, f"{shell}/{scenario}: expected clear_pr after branch change\n" + "\n".join(send_lines)) + if clear_indices[0] <= old_index: + return (1, f"{shell}/{scenario}: clear_pr happened before old report\n" + "\n".join(send_lines)) + return (0, f"{shell}/{scenario}: ok") + + if scenario == "timeout_recovery": + if gh_count < 2: + return (1, f"{shell}/{scenario}: expected timed-out probe to be retried, saw {gh_count}") + if _report_line(1138) not in send_lines: + return (1, f"{shell}/{scenario}: missing report_pr after timeout recovery\n" + "\n".join(send_lines)) + if gh_pid_file.exists(): + gh_pid = int(gh_pid_file.read_text(encoding="utf-8").strip() or "0") + if gh_pid > 0 and _pid_exists(gh_pid): + return (1, f"{shell}/{scenario}: timed-out gh probe still running as pid {gh_pid}") + return (0, f"{shell}/{scenario}: ok") + + return (1, f"{shell}/{scenario}: unhandled scenario") + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + cases = [ + ("zsh", ["-f", "-c"], root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh"), + ("bash", ["--noprofile", "--norc", "-c"], root / "Resources" / "shell-integration" / "cmux-bash-integration.bash"), + ] + scenarios = [ + "prompt_helper_idle", + "transient_same_context", + "branch_switch_clear", + "timeout_recovery", + ] + + base = Path("/tmp") / f"cmux_issue_1138_pr_poll_{os.getpid()}" + try: + shutil.rmtree(base, ignore_errors=True) + base.mkdir(parents=True, exist_ok=True) + + failures: list[str] = [] + for shell, shell_args, script in cases: + if not script.exists(): + print(f"SKIP: missing integration script at {script}") + continue + for scenario in scenarios: + rc, detail = _run_case( + base / f"{shell}-{scenario}", + shell=shell, + shell_args=shell_args, + script=script, + scenario=scenario, + ) + if rc != 0: + failures.append(detail) + + if failures: + print("FAIL:") + for failure in failures: + print(failure) + return 1 + + print("PASS: shell integrations poll PR state robustly across transient failures, branch changes, and timeouts") + return 0 + finally: + shutil.rmtree(base, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main())