Merge origin/main into issue-846-vim-indicator
This commit is contained in:
commit
2f4c81a96a
52 changed files with 1549 additions and 3506 deletions
3
.github/workflows/ci-macos-compat.yml
vendored
3
.github/workflows/ci-macos-compat.yml
vendored
|
|
@ -100,9 +100,6 @@ jobs:
|
|||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
- name: Run issue #952 regression guard
|
||||
run: python3 tests/test_issue_952_socket_listener_recovery.py
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
|
|
|||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -108,9 +108,6 @@ jobs:
|
|||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
- name: Run issue #952 regression guard
|
||||
run: python3 tests/test_issue_952_socket_listener_recovery.py
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
|
@ -216,9 +213,6 @@ jobs:
|
|||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
- name: Run issue #952 regression guard
|
||||
run: python3 tests/test_issue_952_socket_listener_recovery.py
|
||||
|
||||
- name: Create virtual display
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
|
|
|||
3
.github/workflows/test-depot.yml
vendored
3
.github/workflows/test-depot.yml
vendored
|
|
@ -122,9 +122,6 @@ jobs:
|
|||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
- name: Run issue #952 regression guard
|
||||
run: python3 tests/test_issue_952_socket_listener_recovery.py
|
||||
|
||||
- name: Run unit tests
|
||||
if: ${{ !inputs.skip_unit_tests }}
|
||||
run: |
|
||||
|
|
|
|||
97
CHANGELOG.md
97
CHANGELOG.md
|
|
@ -2,6 +2,103 @@
|
|||
|
||||
All notable changes to cmux are documented here.
|
||||
|
||||
## [0.62.0] - 2026-03-07
|
||||
|
||||
### Added
|
||||
- Markdown viewer panel with live file watching ([#883](https://github.com/manaflow-ai/cmux/pull/883))
|
||||
- Find-in-page (Cmd+F) for browser panels ([#837](https://github.com/manaflow-ai/cmux/issues/837), [#875](https://github.com/manaflow-ai/cmux/pull/875))
|
||||
- Keyboard copy mode for terminal scrollback with vi-style navigation ([#792](https://github.com/manaflow-ai/cmux/pull/792))
|
||||
- Custom notification sounds with file picker support ([#839](https://github.com/manaflow-ai/cmux/pull/839), [#869](https://github.com/manaflow-ai/cmux/pull/869))
|
||||
- Browser camera and microphone permission support ([#760](https://github.com/manaflow-ai/cmux/issues/760), [#913](https://github.com/manaflow-ai/cmux/pull/913))
|
||||
- Language setting for per-app locale override ([#886](https://github.com/manaflow-ai/cmux/pull/886))
|
||||
- Japanese localization ([#819](https://github.com/manaflow-ai/cmux/pull/819))
|
||||
- 16 new languages added to localization ([#895](https://github.com/manaflow-ai/cmux/pull/895))
|
||||
- Kagi as a search provider option ([#561](https://github.com/manaflow-ai/cmux/pull/561))
|
||||
- Open Folder command (Cmd+O) ([#656](https://github.com/manaflow-ai/cmux/pull/656))
|
||||
- Dark mode app icon for macOS Sequoia ([#702](https://github.com/manaflow-ai/cmux/pull/702))
|
||||
- Close other pane tabs with confirmation ([#475](https://github.com/manaflow-ai/cmux/pull/475))
|
||||
- Flash Focused Panel command palette action ([#638](https://github.com/manaflow-ai/cmux/pull/638))
|
||||
- Zoom/maximize focused pane in splits ([#634](https://github.com/manaflow-ai/cmux/pull/634))
|
||||
- `cmux tree` command for full CLI hierarchy view ([#592](https://github.com/manaflow-ai/cmux/pull/592))
|
||||
- Install or uninstall the `cmux` CLI from the command palette ([#626](https://github.com/manaflow-ai/cmux/pull/626))
|
||||
- Clipboard image paste in terminal with Cmd+V ([#562](https://github.com/manaflow-ai/cmux/pull/562), [#853](https://github.com/manaflow-ai/cmux/pull/853))
|
||||
- Middle-click X11-style selection paste in terminal ([#369](https://github.com/manaflow-ai/cmux/pull/369))
|
||||
- Honor Ghostty `background-opacity` across all cmux chrome ([#667](https://github.com/manaflow-ai/cmux/pull/667))
|
||||
- Setting to hide Cmd-hold shortcut hints ([#765](https://github.com/manaflow-ai/cmux/pull/765))
|
||||
- Focus-follows-mouse on terminal hover ([#519](https://github.com/manaflow-ai/cmux/pull/519))
|
||||
- Sidebar help menu in the footer ([#958](https://github.com/manaflow-ai/cmux/pull/958))
|
||||
- External URL bypass rules for the embedded browser ([#768](https://github.com/manaflow-ai/cmux/pull/768))
|
||||
- Telemetry opt-out setting ([#610](https://github.com/manaflow-ai/cmux/pull/610))
|
||||
- Browser automation docs page ([#622](https://github.com/manaflow-ai/cmux/pull/622))
|
||||
|
||||
### Changed
|
||||
- Command palette search is now async and decoupled from typing for reduced lag
|
||||
- Fuzzy matching improved with single-edit and omitted-character word matches
|
||||
- Replaced keychain password storage with file-based storage ([#576](https://github.com/manaflow-ai/cmux/pull/576))
|
||||
- Fullscreen shortcut changed to Cmd+Ctrl+F, and Cmd+Enter also toggles fullscreen ([#530](https://github.com/manaflow-ai/cmux/pull/530))
|
||||
- Workspace rename shortcut Cmd+Shift+R now uses the command palette flow
|
||||
- Renamed tab color to workspace color in user-facing strings ([#637](https://github.com/manaflow-ai/cmux/pull/637))
|
||||
- Feedback recipient changed to `feedback@manaflow.com` ([#1007](https://github.com/manaflow-ai/cmux/pull/1007))
|
||||
- Regenerated app icons from Icon Composer ([#1005](https://github.com/manaflow-ai/cmux/pull/1005))
|
||||
- Moved update logs into the Debug menu ([#1008](https://github.com/manaflow-ai/cmux/pull/1008))
|
||||
|
||||
### Fixed
|
||||
- Frozen blank launch from session restore race condition ([#399](https://github.com/manaflow-ai/cmux/issues/399), [#565](https://github.com/manaflow-ai/cmux/pull/565))
|
||||
- Crash on launch from an exclusive access violation in drag-handle hit testing ([#490](https://github.com/manaflow-ai/cmux/issues/490))
|
||||
- Use-after-free in `ghostty_surface_refresh` after sleep/wake ([#432](https://github.com/manaflow-ai/cmux/issues/432), [#619](https://github.com/manaflow-ai/cmux/pull/619))
|
||||
- Startup SIGSEGV by pre-warming locale before `SentrySDK.start` ([#927](https://github.com/manaflow-ai/cmux/pull/927))
|
||||
- IME issues: Shift+Space toggle inserting a space ([#641](https://github.com/manaflow-ai/cmux/issues/641), [#670](https://github.com/manaflow-ai/cmux/pull/670)), Ctrl fast path blocking IME events, browser address bar Japanese IME ([#789](https://github.com/manaflow-ai/cmux/issues/789), [#867](https://github.com/manaflow-ai/cmux/pull/867)), and Cmd shortcuts during IME composition
|
||||
- CLI socket autodiscovery for tagged sockets ([#832](https://github.com/manaflow-ai/cmux/pull/832))
|
||||
- Flaky CLI socket listener recovery ([#952](https://github.com/manaflow-ai/cmux/issues/952), [#954](https://github.com/manaflow-ai/cmux/pull/954))
|
||||
- Side-docked dev tools resize ([#712](https://github.com/manaflow-ai/cmux/pull/712))
|
||||
- Dvorak Cmd+C colliding with the notifications shortcut ([#762](https://github.com/manaflow-ai/cmux/pull/762))
|
||||
- Terminal drag hover overlay flicker
|
||||
- Titlebar controls clipped at the bottom edge ([#1016](https://github.com/manaflow-ai/cmux/pull/1016))
|
||||
- Sidebar git branch recovery after sleep/wake and agent checkout ([#494](https://github.com/manaflow-ai/cmux/issues/494), [#671](https://github.com/manaflow-ai/cmux/pull/671), [#905](https://github.com/manaflow-ai/cmux/pull/905))
|
||||
- Browser portal routing, uploads, and click focus regressions ([#908](https://github.com/manaflow-ai/cmux/pull/908), [#961](https://github.com/manaflow-ai/cmux/pull/961))
|
||||
- Notification unread persistence on workspace focus
|
||||
- Escape propagation when the command palette is visible ([#847](https://github.com/manaflow-ai/cmux/pull/847))
|
||||
- Cmd+Shift+Enter pane zoom regression in browser focus ([#826](https://github.com/manaflow-ai/cmux/pull/826))
|
||||
- Cross-window theme background after jump-to-unread ([#861](https://github.com/manaflow-ai/cmux/pull/861))
|
||||
- `window.open()` and `target=_blank` not opening in a new tab ([#693](https://github.com/manaflow-ai/cmux/pull/693))
|
||||
- Terminal wrap width for the overlay scrollbar ([#522](https://github.com/manaflow-ai/cmux/pull/522))
|
||||
- Orphaned child processes when closing workspace tabs ([#889](https://github.com/manaflow-ai/cmux/pull/889))
|
||||
- Cmd+F Escape passthrough into terminal ([#918](https://github.com/manaflow-ai/cmux/pull/918))
|
||||
- Terminal link opens staying in the source workspace ([#912](https://github.com/manaflow-ai/cmux/pull/912))
|
||||
- Ghost terminal surface rebind after close ([#808](https://github.com/manaflow-ai/cmux/pull/808))
|
||||
- Cmd+plus zoom handling on non-US keyboard layouts ([#680](https://github.com/manaflow-ai/cmux/pull/680))
|
||||
- Menubar icon invisible in light mode ([#741](https://github.com/manaflow-ai/cmux/pull/741))
|
||||
- Various drag-handle crash fixes and reentrancy guards
|
||||
- Background workspace git metadata refresh after external checkout
|
||||
- Markdown panel text click focus ([#991](https://github.com/manaflow-ai/cmux/pull/991))
|
||||
- Browser Cmd+F overlay clipping in portal mode ([#916](https://github.com/manaflow-ai/cmux/pull/916))
|
||||
- Voice dictation text insertion ([#857](https://github.com/manaflow-ai/cmux/pull/857))
|
||||
- Browser panel lifecycle after WebContent process termination ([#892](https://github.com/manaflow-ai/cmux/pull/892))
|
||||
- Typing lag reduction by hiding invisible views from the accessibility tree ([#862](https://github.com/manaflow-ai/cmux/pull/862))
|
||||
|
||||
### Thanks to 21 contributors!
|
||||
- [@afxjzs](https://github.com/afxjzs)
|
||||
- [@AI-per](https://github.com/AI-per)
|
||||
- [@atani](https://github.com/atani)
|
||||
- [@austinywang](https://github.com/austinywang)
|
||||
- [@cheulyop](https://github.com/cheulyop)
|
||||
- [@ConnorCallison](https://github.com/ConnorCallison)
|
||||
- [@harukitosa](https://github.com/harukitosa)
|
||||
- [@homanp](https://github.com/homanp)
|
||||
- [@JLeeChan](https://github.com/JLeeChan)
|
||||
- [@josemasri](https://github.com/josemasri)
|
||||
- [@lawrencecchen](https://github.com/lawrencecchen)
|
||||
- [@novarii](https://github.com/novarii)
|
||||
- [@orkhanrz](https://github.com/orkhanrz)
|
||||
- [@qianwan](https://github.com/qianwan)
|
||||
- [@rjwittams](https://github.com/rjwittams)
|
||||
- [@sminamot](https://github.com/sminamot)
|
||||
- [@tmcarr](https://github.com/tmcarr)
|
||||
- [@trydis](https://github.com/trydis)
|
||||
- [@ukoasis](https://github.com/ukoasis)
|
||||
- [@y-agatsuma](https://github.com/y-agatsuma)
|
||||
- [@yasunogithub](https://github.com/yasunogithub)
|
||||
|
||||
## [0.61.0] - 2026-02-25
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -133,8 +133,11 @@ This makes it visible in the GitHub PR UI (Commits tab, check statuses) that the
|
|||
## Test quality policy
|
||||
|
||||
- Do not add tests that only verify source code text, method signatures, AST fragments, or grep-style patterns.
|
||||
- Do not add tests that read checked-in metadata or project files such as `Resources/Info.plist`, `project.pbxproj`, `.xcconfig`, or source files only to assert that a key, string, plist entry, or snippet exists.
|
||||
- Tests must verify observable runtime behavior through executable paths (unit/integration/e2e/CLI), not implementation shape.
|
||||
- For metadata changes, prefer verifying the built app bundle or the runtime behavior that depends on that metadata, not the checked-in source file.
|
||||
- If a behavior cannot be exercised end-to-end yet, add a small runtime seam or harness first, then test through that seam.
|
||||
- If no meaningful behavioral or artifact-level test is practical, skip the fake regression test and state that explicitly.
|
||||
|
||||
## Socket command threading policy
|
||||
|
||||
|
|
|
|||
|
|
@ -6720,15 +6720,12 @@ struct CMUXCLI {
|
|||
}
|
||||
|
||||
private func versionInfoFromProjectFile() -> [String: String]? {
|
||||
guard let executable = currentExecutablePath(), !executable.isEmpty else {
|
||||
guard let executableURL = resolvedExecutableURL() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
var current = URL(fileURLWithPath: executable)
|
||||
.resolvingSymlinksInPath()
|
||||
.standardizedFileURL
|
||||
.deletingLastPathComponent()
|
||||
var current = executableURL.deletingLastPathComponent()
|
||||
|
||||
while true {
|
||||
let projectFile = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj")
|
||||
|
|
@ -6820,23 +6817,29 @@ struct CMUXCLI {
|
|||
}
|
||||
|
||||
private func candidateInfoPlistURLs() -> [URL] {
|
||||
guard let executable = currentExecutablePath(), !executable.isEmpty else {
|
||||
guard let executableURL = resolvedExecutableURL() else {
|
||||
return []
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let executableURL = URL(fileURLWithPath: executable)
|
||||
.resolvingSymlinksInPath()
|
||||
.standardizedFileURL
|
||||
|
||||
var candidates: [URL] = []
|
||||
var seen: Set<String> = []
|
||||
func appendIfExisting(_ url: URL) {
|
||||
let path = url.path
|
||||
guard !path.isEmpty else { return }
|
||||
guard seen.insert(path).inserted else { return }
|
||||
guard fileManager.fileExists(atPath: path) else { return }
|
||||
candidates.append(url)
|
||||
}
|
||||
|
||||
var current = executableURL.deletingLastPathComponent()
|
||||
while true {
|
||||
if current.pathExtension == "app" {
|
||||
candidates.append(current.appendingPathComponent("Contents/Info.plist"))
|
||||
appendIfExisting(current.appendingPathComponent("Contents/Info.plist"))
|
||||
}
|
||||
if current.lastPathComponent == "Contents" {
|
||||
candidates.append(current.appendingPathComponent("Info.plist"))
|
||||
appendIfExisting(current.appendingPathComponent("Info.plist"))
|
||||
}
|
||||
|
||||
// Local dev fallback: resolve version from the repo's app Info.plist
|
||||
|
|
@ -6845,7 +6848,7 @@ struct CMUXCLI {
|
|||
let repoInfo = current.appendingPathComponent("Resources/Info.plist")
|
||||
if fileManager.fileExists(atPath: projectMarker.path),
|
||||
fileManager.fileExists(atPath: repoInfo.path) {
|
||||
candidates.append(repoInfo)
|
||||
appendIfExisting(repoInfo)
|
||||
break
|
||||
}
|
||||
|
||||
|
|
@ -6856,30 +6859,31 @@ struct CMUXCLI {
|
|||
current = parent
|
||||
}
|
||||
|
||||
// If we already found an ancestor bundle or repo Info.plist, avoid scanning
|
||||
// sibling app bundles. Large Resources directories can otherwise balloon RSS.
|
||||
guard candidates.isEmpty else {
|
||||
return candidates
|
||||
}
|
||||
|
||||
let searchRoots = [
|
||||
executableURL.deletingLastPathComponent(),
|
||||
executableURL.deletingLastPathComponent().deletingLastPathComponent()
|
||||
]
|
||||
for root in searchRoots {
|
||||
guard let entries = try? fileManager.contentsOfDirectory(
|
||||
guard let entries = fileManager.enumerator(
|
||||
at: root,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
includingPropertiesForKeys: nil,
|
||||
options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants],
|
||||
errorHandler: { _, _ in true }
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
for entry in entries where entry.pathExtension == "app" {
|
||||
candidates.append(entry.appendingPathComponent("Contents/Info.plist"))
|
||||
for case let entry as URL in entries where entry.pathExtension == "app" {
|
||||
appendIfExisting(entry.appendingPathComponent("Contents/Info.plist"))
|
||||
}
|
||||
}
|
||||
|
||||
var seen: Set<String> = []
|
||||
return candidates.filter { url in
|
||||
let path = url.path
|
||||
guard !path.isEmpty else { return false }
|
||||
guard seen.insert(path).inserted else { return false }
|
||||
return fileManager.fileExists(atPath: path)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
private func currentExecutablePath() -> String? {
|
||||
|
|
@ -6897,6 +6901,20 @@ struct CMUXCLI {
|
|||
return Bundle.main.executableURL?.path ?? args.first
|
||||
}
|
||||
|
||||
private func resolvedExecutableURL() -> URL? {
|
||||
guard let executable = currentExecutablePath(), !executable.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let expanded = (executable as NSString).expandingTildeInPath
|
||||
if let resolvedPath = realpath(expanded, nil) {
|
||||
defer { free(resolvedPath) }
|
||||
return URL(fileURLWithPath: String(cString: resolvedPath)).standardizedFileURL
|
||||
}
|
||||
|
||||
return URL(fileURLWithPath: expanded).standardizedFileURL
|
||||
}
|
||||
|
||||
private func usage() -> String {
|
||||
return """
|
||||
cmux - control cmux via Unix socket
|
||||
|
|
|
|||
|
|
@ -798,7 +798,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 73;
|
||||
CURRENT_PROJECT_VERSION = 74;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_HARDENED_RUNTIME = NO;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
|
|
@ -807,7 +807,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.61.0;
|
||||
MARKETING_VERSION = 0.62.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-lc++",
|
||||
"-framework",
|
||||
|
|
@ -837,7 +837,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 73;
|
||||
CURRENT_PROJECT_VERSION = 74;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_HARDENED_RUNTIME = NO;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
|
|
@ -846,7 +846,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.61.0;
|
||||
MARKETING_VERSION = 0.62.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-lc++",
|
||||
"-framework",
|
||||
|
|
@ -913,10 +913,10 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 73;
|
||||
CURRENT_PROJECT_VERSION = 74;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.61.0;
|
||||
MARKETING_VERSION = 0.62.0;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -930,10 +930,10 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 73;
|
||||
CURRENT_PROJECT_VERSION = 74;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.61.0;
|
||||
MARKETING_VERSION = 0.62.0;
|
||||
ONLY_ACTIVE_ARCH = NO;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -947,10 +947,10 @@
|
|||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 73;
|
||||
CURRENT_PROJECT_VERSION = 74;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.61.0;
|
||||
MARKETING_VERSION = 0.62.0;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -966,10 +966,10 @@
|
|||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 73;
|
||||
CURRENT_PROJECT_VERSION = 74;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.61.0;
|
||||
MARKETING_VERSION = 0.62.0;
|
||||
ONLY_ACTIVE_ARCH = NO;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
|
|||
|
|
@ -30,6 +30,22 @@
|
|||
<string>A program running within cmux would like to use your microphone.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>A program running within cmux would like to use your camera.</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).web</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Default</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>http</string>
|
||||
<string>https</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<key>NSServices</key>
|
||||
|
|
|
|||
|
|
@ -1350,6 +1350,8 @@ final class WindowBrowserSlotView: NSView {
|
|||
private let paneDropTargetView = BrowserPaneDropTargetView(frame: .zero)
|
||||
private let dropZoneOverlayView = BrowserDropZoneOverlayView(frame: .zero)
|
||||
private var searchOverlayHostingView: NSHostingView<BrowserSearchOverlay>?
|
||||
private weak var hostedWebView: WKWebView?
|
||||
private var hostedWebViewConstraints: [NSLayoutConstraint] = []
|
||||
private var forwardedDropZone: DropZone?
|
||||
private var portalDragDropZone: DropZone?
|
||||
private var displayedDropZone: DropZone?
|
||||
|
|
@ -1460,6 +1462,34 @@ final class WindowBrowserSlotView: NSView {
|
|||
searchOverlayHostingView = overlay
|
||||
}
|
||||
|
||||
func pinHostedWebView(_ webView: WKWebView) {
|
||||
guard webView.superview === self else { return }
|
||||
|
||||
let needsNewConstraints =
|
||||
hostedWebView !== webView ||
|
||||
hostedWebViewConstraints.isEmpty ||
|
||||
webView.translatesAutoresizingMaskIntoConstraints
|
||||
guard needsNewConstraints else {
|
||||
needsLayout = true
|
||||
layoutSubtreeIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
NSLayoutConstraint.deactivate(hostedWebViewConstraints)
|
||||
hostedWebView = webView
|
||||
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||
webView.autoresizingMask = []
|
||||
hostedWebViewConstraints = [
|
||||
webView.topAnchor.constraint(equalTo: topAnchor),
|
||||
webView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
webView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
webView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
]
|
||||
NSLayoutConstraint.activate(hostedWebViewConstraints)
|
||||
needsLayout = true
|
||||
layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
func effectivePaneTopChromeHeight() -> CGFloat {
|
||||
paneTopChromeHeight
|
||||
}
|
||||
|
|
@ -2241,11 +2271,11 @@ final class WindowBrowserPortal: NSObject {
|
|||
} else {
|
||||
containerView.addSubview(webView, positioned: .above, relativeTo: nil)
|
||||
}
|
||||
webView.translatesAutoresizingMaskIntoConstraints = true
|
||||
webView.autoresizingMask = [.width, .height]
|
||||
webView.frame = containerView.bounds
|
||||
containerView.pinHostedWebView(webView)
|
||||
webView.needsLayout = true
|
||||
webView.layoutSubtreeIfNeeded()
|
||||
} else {
|
||||
containerView.pinHostedWebView(webView)
|
||||
}
|
||||
|
||||
if containerView.superview !== hostView {
|
||||
|
|
@ -2496,10 +2526,10 @@ final class WindowBrowserPortal: NSObject {
|
|||
} else {
|
||||
containerView.addSubview(webView, positioned: .above, relativeTo: nil)
|
||||
}
|
||||
webView.translatesAutoresizingMaskIntoConstraints = true
|
||||
webView.autoresizingMask = [.width, .height]
|
||||
webView.frame = containerView.bounds
|
||||
containerView.pinHostedWebView(webView)
|
||||
refreshReasons.append("syncAttachWebView")
|
||||
} else {
|
||||
containerView.pinHostedWebView(webView)
|
||||
}
|
||||
|
||||
_ = synchronizeHostFrameToReference()
|
||||
|
|
@ -2626,12 +2656,23 @@ final class WindowBrowserPortal: NSObject {
|
|||
}
|
||||
#endif
|
||||
if shouldPreserveVisibleOnTransientGeometry {
|
||||
let hasExistingVisibleFrame =
|
||||
oldFrame.width > 1 &&
|
||||
oldFrame.height > 1 &&
|
||||
containerView.bounds.width > 1 &&
|
||||
containerView.bounds.height > 1
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " +
|
||||
"reason=\(transientRecoveryReason ?? "unknown") frame=\(browserPortalDebugFrame(containerView.frame))"
|
||||
"reason=\(transientRecoveryReason ?? "unknown") frame=\(browserPortalDebugFrame(containerView.frame)) " +
|
||||
"keepFrame=\(hasExistingVisibleFrame ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
if hasExistingVisibleFrame {
|
||||
containerView.setDropZoneOverlay(zone: nil)
|
||||
containerView.setPaneDropContext(nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !Self.rectApproximatelyEqual(oldFrame, targetFrame) {
|
||||
CATransaction.begin()
|
||||
|
|
@ -2760,7 +2801,10 @@ final class WindowBrowserPortal: NSObject {
|
|||
guard entry.webView != nil else { return webViewId }
|
||||
guard let container = entry.containerView else { return webViewId }
|
||||
guard let anchor = entry.anchorView else {
|
||||
return entry.visibleInUI ? nil : webViewId
|
||||
// Workspace switching hides retiring browser portals before SwiftUI unmounts
|
||||
// their anchor views. Keep the hidden WKWebView/slot alive so switching back
|
||||
// can rebind the existing view instead of forcing a full WebKit reload.
|
||||
return nil
|
||||
}
|
||||
if container.superview == nil || !container.isDescendant(of: hostView) {
|
||||
return webViewId
|
||||
|
|
@ -2770,7 +2814,10 @@ final class WindowBrowserPortal: NSObject {
|
|||
anchor.superview == nil ||
|
||||
(installedReferenceView.map { !anchor.isDescendant(of: $0) } ?? false)
|
||||
if anchorInvalidForCurrentHost {
|
||||
return entry.visibleInUI ? nil : webViewId
|
||||
// Hidden browser portals can legitimately be off-tree between workspace
|
||||
// deactivation and the next rebind. Preserve them until an explicit detach
|
||||
// (panel close, window teardown, or web view replacement) says otherwise.
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9816,6 +9816,7 @@ private struct TabItemView: View {
|
|||
let modifiers = NSEvent.modifierFlags
|
||||
let isCommand = modifiers.contains(.command)
|
||||
let isShift = modifiers.contains(.shift)
|
||||
let wasSelected = tabManager.selectedTabId == tab.id
|
||||
|
||||
if isShift, let lastIndex = lastSidebarSelectionIndex {
|
||||
let lower = min(lastIndex, index)
|
||||
|
|
@ -9838,6 +9839,12 @@ private struct TabItemView: View {
|
|||
|
||||
lastSidebarSelectionIndex = index
|
||||
tabManager.selectTab(tab)
|
||||
if wasSelected, !isCommand, !isShift {
|
||||
tabManager.dismissNotificationOnDirectInteraction(
|
||||
tabId: tab.id,
|
||||
surfaceId: tabManager.focusedSurfaceId(for: tab.id)
|
||||
)
|
||||
}
|
||||
selection = .tabs
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1028,9 +1028,197 @@ class GhosttyApp {
|
|||
loadReleaseAppSupportGhosttyConfigIfNeeded(config)
|
||||
loadLegacyGhosttyConfigIfNeeded(config)
|
||||
ghostty_config_load_recursive_files(config)
|
||||
loadCJKFontFallbackIfNeeded(config)
|
||||
ghostty_config_finalize(config)
|
||||
}
|
||||
|
||||
/// When the user has not configured `font-codepoint-map` for CJK ranges,
|
||||
/// Ghostty's `CTFontCollection` scoring may pick an inappropriate fallback
|
||||
/// font for Hiragana, Katakana, and CJK symbols. The scoring prioritizes
|
||||
/// monospace fonts, so decorative fonts with monospace attributes (e.g.
|
||||
/// AB_appare from Adobe CC, or LingWai) can be selected depending on what
|
||||
/// is installed. This injects a sensible default based on the system's
|
||||
/// preferred languages.
|
||||
///
|
||||
/// See: https://github.com/manaflow-ai/cmux/pull/1017
|
||||
private func loadCJKFontFallbackIfNeeded(_ config: ghostty_config_t) {
|
||||
if Self.userConfigContainsCJKCodepointMap() { return }
|
||||
|
||||
guard let mappings = Self.cjkFontMappings() else { return }
|
||||
|
||||
let lines = mappings.map { range, font in
|
||||
"font-codepoint-map = \(range)=\(font)"
|
||||
}.joined(separator: "\n")
|
||||
|
||||
let tmpURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-cjk-font-fallback-\(UUID().uuidString).conf")
|
||||
do {
|
||||
try lines.write(to: tmpURL, atomically: true, encoding: .utf8)
|
||||
defer { try? FileManager.default.removeItem(at: tmpURL) }
|
||||
tmpURL.path.withCString { path in
|
||||
ghostty_config_load_file(config, path)
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
Self.initLog("failed to write CJK font fallback config: \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Unicode ranges shared by all CJK languages (Han ideographs, symbols, fullwidth forms).
|
||||
private static let sharedCJKRanges = [
|
||||
"U+3000-U+303F", // CJK Symbols and Punctuation
|
||||
"U+4E00-U+9FFF", // CJK Unified Ideographs
|
||||
"U+F900-U+FAFF", // CJK Compatibility Ideographs
|
||||
"U+FF00-U+FFEF", // Halfwidth and Fullwidth Forms
|
||||
"U+3400-U+4DBF", // CJK Unified Ideographs Extension A
|
||||
]
|
||||
|
||||
/// Unicode ranges specific to Japanese (kana).
|
||||
private static let japaneseRanges = [
|
||||
"U+3040-U+309F", // Hiragana
|
||||
"U+30A0-U+30FF", // Katakana
|
||||
]
|
||||
|
||||
/// Unicode ranges specific to Korean (Hangul).
|
||||
private static let koreanRanges = [
|
||||
"U+AC00-U+D7AF", // Hangul Syllables
|
||||
"U+1100-U+11FF", // Hangul Jamo
|
||||
]
|
||||
|
||||
/// Returns (range, font) pairs for CJK font fallback based on the system's
|
||||
/// preferred languages, or nil if no CJK language is detected. Each language
|
||||
/// only maps its own script ranges to avoid assigning glyphs to a font that
|
||||
/// lacks coverage (e.g. Hangul to Hiragino Sans).
|
||||
static func cjkFontMappings(
|
||||
preferredLanguages: [String] = Locale.preferredLanguages
|
||||
) -> [(String, String)]? {
|
||||
var mappings: [(String, String)] = []
|
||||
var coveredShared = false
|
||||
|
||||
for lang in preferredLanguages {
|
||||
let lower = lang.lowercased()
|
||||
let font: String
|
||||
var langRanges: [String] = []
|
||||
|
||||
if lower.hasPrefix("ja") {
|
||||
font = "Hiragino Sans"
|
||||
langRanges = japaneseRanges
|
||||
} else if lower.hasPrefix("ko") {
|
||||
font = "Apple SD Gothic Neo"
|
||||
langRanges = koreanRanges
|
||||
} else if lower.hasPrefix("zh-hant") || lower.hasPrefix("zh-tw") || lower.hasPrefix("zh-hk") {
|
||||
font = "PingFang TC"
|
||||
} else if lower.hasPrefix("zh") {
|
||||
font = "PingFang SC"
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
if !coveredShared {
|
||||
for range in sharedCJKRanges {
|
||||
mappings.append((range, font))
|
||||
}
|
||||
coveredShared = true
|
||||
}
|
||||
|
||||
for range in langRanges {
|
||||
mappings.append((range, font))
|
||||
}
|
||||
}
|
||||
|
||||
return mappings.isEmpty ? nil : mappings
|
||||
}
|
||||
|
||||
/// Checks whether the user's Ghostty config files already contain
|
||||
/// a `font-codepoint-map` entry covering CJK ranges. Also checks
|
||||
/// application-support config paths that cmux may load at runtime.
|
||||
static func userConfigContainsCJKCodepointMap(
|
||||
configPaths: [String] = defaultCJKScanPaths()
|
||||
) -> Bool {
|
||||
var visited = Set<String>()
|
||||
for rawPath in configPaths {
|
||||
let path = NSString(string: rawPath).expandingTildeInPath
|
||||
if Self.configFileContainsCodepointMap(atPath: path, visited: &visited) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Returns the default set of config paths to scan for existing
|
||||
/// `font-codepoint-map` entries. Includes both the standard Ghostty
|
||||
/// config locations and any app-support paths that cmux may load.
|
||||
private static func defaultCJKScanPaths() -> [String] {
|
||||
var paths = [
|
||||
"~/.config/ghostty/config",
|
||||
"~/.config/ghostty/config.ghostty",
|
||||
"~/Library/Application Support/com.mitchellh.ghostty/config",
|
||||
"~/Library/Application Support/com.mitchellh.ghostty/config.ghostty",
|
||||
]
|
||||
if let appSupport = FileManager.default.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask
|
||||
).first {
|
||||
let releaseDir = appSupport.appendingPathComponent(releaseBundleIdentifier)
|
||||
paths.append(releaseDir.appendingPathComponent("config").path)
|
||||
paths.append(releaseDir.appendingPathComponent("config.ghostty").path)
|
||||
|
||||
if let bundleId = Bundle.main.bundleIdentifier, bundleId != releaseBundleIdentifier {
|
||||
let currentDir = appSupport.appendingPathComponent(bundleId)
|
||||
paths.append(currentDir.appendingPathComponent("config").path)
|
||||
paths.append(currentDir.appendingPathComponent("config.ghostty").path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
/// Scans a single config file (and any files it includes) for
|
||||
/// `font-codepoint-map` entries. Tracks visited paths to prevent
|
||||
/// infinite recursion on cyclic includes.
|
||||
private static func configFileContainsCodepointMap(
|
||||
atPath path: String,
|
||||
visited: inout Set<String>
|
||||
) -> Bool {
|
||||
let resolved = (path as NSString).standardizingPath
|
||||
guard !visited.contains(resolved) else { return false }
|
||||
visited.insert(resolved)
|
||||
|
||||
guard let contents = try? String(contentsOfFile: resolved, encoding: .utf8) else {
|
||||
return false
|
||||
}
|
||||
let parentDir = (resolved as NSString).deletingLastPathComponent
|
||||
|
||||
for line in contents.components(separatedBy: .newlines) {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.hasPrefix("#") { continue }
|
||||
if trimmed.hasPrefix("font-codepoint-map") {
|
||||
return true
|
||||
}
|
||||
if trimmed.hasPrefix("config-file") {
|
||||
let parts = trimmed.split(separator: "=", maxSplits: 1)
|
||||
if parts.count == 2 {
|
||||
var includePath = parts[1]
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
// Ghostty supports optional includes with a trailing '?'
|
||||
if includePath.hasSuffix("?") {
|
||||
includePath.removeLast()
|
||||
}
|
||||
includePath = includePath
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||
let expanded = NSString(string: includePath).expandingTildeInPath
|
||||
let absolute = (expanded as NSString).isAbsolutePath
|
||||
? expanded
|
||||
: (parentDir as NSString).appendingPathComponent(expanded)
|
||||
if configFileContainsCodepointMap(atPath: absolute, visited: &visited) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func shouldLoadLegacyGhosttyConfig(
|
||||
newConfigFileSize: Int?,
|
||||
legacyConfigFileSize: Int?
|
||||
|
|
@ -2080,8 +2268,14 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
case closing
|
||||
case closed
|
||||
}
|
||||
private struct PortalHostLease {
|
||||
let hostId: ObjectIdentifier
|
||||
let inWindow: Bool
|
||||
let area: CGFloat
|
||||
}
|
||||
private var portalLifecycleState: PortalLifecycleState = .live
|
||||
private var portalLifecycleGeneration: UInt64 = 1
|
||||
private var activePortalHostLease: PortalHostLease?
|
||||
@Published var searchState: SearchState? = nil {
|
||||
didSet {
|
||||
if let searchState {
|
||||
|
|
@ -2173,6 +2367,90 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
return true
|
||||
}
|
||||
|
||||
private static let portalHostAreaThreshold: CGFloat = 4
|
||||
private static let portalHostReplacementAreaGainRatio: CGFloat = 1.2
|
||||
|
||||
private static func portalHostArea(for bounds: CGRect) -> CGFloat {
|
||||
max(0, bounds.width) * max(0, bounds.height)
|
||||
}
|
||||
|
||||
private static func portalHostIsUsable(_ lease: PortalHostLease) -> Bool {
|
||||
lease.inWindow && lease.area > portalHostAreaThreshold
|
||||
}
|
||||
|
||||
func claimPortalHost(
|
||||
hostId: ObjectIdentifier,
|
||||
inWindow: Bool,
|
||||
bounds: CGRect,
|
||||
reason: String
|
||||
) -> Bool {
|
||||
let next = PortalHostLease(
|
||||
hostId: hostId,
|
||||
inWindow: inWindow,
|
||||
area: Self.portalHostArea(for: bounds)
|
||||
)
|
||||
|
||||
if let current = activePortalHostLease {
|
||||
if current.hostId == hostId {
|
||||
activePortalHostLease = next
|
||||
return true
|
||||
}
|
||||
|
||||
let currentUsable = Self.portalHostIsUsable(current)
|
||||
let nextUsable = Self.portalHostIsUsable(next)
|
||||
let shouldReplace =
|
||||
!currentUsable ||
|
||||
(nextUsable && next.area > (current.area * Self.portalHostReplacementAreaGainRatio))
|
||||
|
||||
if shouldReplace {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"terminal.portal.host.claim surface=\(id.uuidString.prefix(5)) " +
|
||||
"reason=\(reason) host=\(hostId) inWin=\(inWindow ? 1 : 0) " +
|
||||
"size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
|
||||
"replacingHost=\(current.hostId) replacingInWin=\(current.inWindow ? 1 : 0) " +
|
||||
"replacingArea=\(String(format: "%.1f", current.area))"
|
||||
)
|
||||
#endif
|
||||
activePortalHostLease = next
|
||||
return true
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"terminal.portal.host.skip surface=\(id.uuidString.prefix(5)) " +
|
||||
"reason=\(reason) host=\(hostId) inWin=\(inWindow ? 1 : 0) " +
|
||||
"size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
|
||||
"ownerHost=\(current.hostId) ownerInWin=\(current.inWindow ? 1 : 0) " +
|
||||
"ownerArea=\(String(format: "%.1f", current.area))"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
activePortalHostLease = next
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"terminal.portal.host.claim surface=\(id.uuidString.prefix(5)) " +
|
||||
"reason=\(reason) host=\(hostId) inWin=\(inWindow ? 1 : 0) " +
|
||||
"size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) replacingHost=nil"
|
||||
)
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
func releasePortalHostIfOwned(hostId: ObjectIdentifier, reason: String) {
|
||||
guard let current = activePortalHostLease, current.hostId == hostId else { return }
|
||||
activePortalHostLease = nil
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"terminal.portal.host.release surface=\(id.uuidString.prefix(5)) " +
|
||||
"reason=\(reason) host=\(hostId) inWin=\(current.inWindow ? 1 : 0) " +
|
||||
"area=\(String(format: "%.1f", current.area))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func beginPortalCloseLifecycle(reason: String) {
|
||||
guard portalLifecycleState != .closed else { return }
|
||||
guard portalLifecycleState != .closing else { return }
|
||||
|
|
@ -2931,6 +3209,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
.fileURL,
|
||||
.URL
|
||||
]
|
||||
private static let tabTransferPasteboardType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer")
|
||||
private static let sidebarTabReorderPasteboardType = NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder")
|
||||
private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t"
|
||||
|
||||
fileprivate static func focusLog(_ message: String) {
|
||||
|
|
@ -3286,6 +3566,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
return currentBounds
|
||||
}
|
||||
|
||||
private static func hasActiveTabDragPasteboard() -> Bool {
|
||||
let types = NSPasteboard(name: .drag).types ?? []
|
||||
return types.contains(tabTransferPasteboardType) || types.contains(sidebarTabReorderPasteboardType)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func updateSurfaceSize(size: CGSize? = nil) -> Bool {
|
||||
guard let terminalSurface = terminalSurface else { return false }
|
||||
|
|
@ -3305,6 +3590,20 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
return false
|
||||
}
|
||||
pendingSurfaceSize = size
|
||||
guard !Self.hasActiveTabDragPasteboard() else {
|
||||
#if DEBUG
|
||||
let signature = "tabDrag-\(Int(size.width.rounded()))x\(Int(size.height.rounded()))"
|
||||
if lastSizeSkipSignature != signature {
|
||||
dlog(
|
||||
"surface.size.defer surface=\(terminalSurface.id.uuidString.prefix(5)) reason=tabDrag " +
|
||||
"size=\(String(format: "%.1fx%.1f", size.width, size.height)) " +
|
||||
"inWindow=\(window != nil ? 1 : 0)"
|
||||
)
|
||||
lastSizeSkipSignature = signature
|
||||
}
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
guard let window else {
|
||||
#if DEBUG
|
||||
let signature = "noWindow-\(Int(size.width))x\(Int(size.height))"
|
||||
|
|
@ -4480,6 +4779,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
dlog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))] clickCount=\(event.clickCount) point=(\(String(format: "%.0f", debugPoint.x)),\(String(format: "%.0f", debugPoint.y)))")
|
||||
#endif
|
||||
window?.makeFirstResponder(self)
|
||||
if let terminalSurface {
|
||||
AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction(
|
||||
tabId: terminalSurface.tabId,
|
||||
surfaceId: terminalSurface.id
|
||||
)
|
||||
}
|
||||
guard let surface = surface else { return }
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
|
||||
|
|
@ -4950,6 +5255,16 @@ private final class GhosttyPassthroughVisualEffectView: NSVisualEffectView {
|
|||
}
|
||||
|
||||
final class GhosttySurfaceScrollView: NSView {
|
||||
enum FlashStyle {
|
||||
case standardFocus
|
||||
case notificationDismiss
|
||||
}
|
||||
|
||||
private enum NotificationRingMetrics {
|
||||
static let inset: CGFloat = 2
|
||||
static let cornerRadius: CGFloat = 6
|
||||
}
|
||||
|
||||
private let backgroundView: NSView
|
||||
private let scrollView: GhosttyScrollView
|
||||
private let documentView: NSView
|
||||
|
|
@ -5120,6 +5435,13 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
)
|
||||
}
|
||||
|
||||
func releaseOwnedPortalHost(hostId: ObjectIdentifier, reason: String) {
|
||||
surfaceView.terminalSurface?.releasePortalHostIfOwned(
|
||||
hostId: hostId,
|
||||
reason: reason
|
||||
)
|
||||
}
|
||||
|
||||
init(surfaceView: GhosttyNSView) {
|
||||
self.surfaceView = surfaceView
|
||||
backgroundView = NSView(frame: .zero)
|
||||
|
|
@ -5419,7 +5741,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
_ = setFrameIfNeeded(notificationRingOverlayView, to: bounds)
|
||||
_ = setFrameIfNeeded(flashOverlayView, to: bounds)
|
||||
updateNotificationRingPath()
|
||||
updateFlashPath()
|
||||
updateFlashPath(style: .standardFocus)
|
||||
synchronizeScrollView()
|
||||
synchronizeSurfaceView()
|
||||
let didCoreSurfaceChange = synchronizeCoreSurface()
|
||||
|
|
@ -5865,7 +6187,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
}
|
||||
#endif
|
||||
|
||||
func triggerFlash() {
|
||||
func triggerFlash(style: FlashStyle = .standardFocus) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
#if DEBUG
|
||||
|
|
@ -5873,7 +6195,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
Self.recordFlash(for: surfaceId)
|
||||
}
|
||||
#endif
|
||||
self.updateFlashPath()
|
||||
self.updateFlashPath(style: style)
|
||||
self.flashLayer.removeAllAnimations()
|
||||
self.flashLayer.opacity = 0
|
||||
let animation = CAKeyframeAnimation(keyPath: "opacity")
|
||||
|
|
@ -6615,17 +6937,27 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
updateOverlayRingPath(
|
||||
layer: notificationRingLayer,
|
||||
bounds: notificationRingOverlayView.bounds,
|
||||
inset: 2,
|
||||
radius: 6
|
||||
inset: NotificationRingMetrics.inset,
|
||||
radius: NotificationRingMetrics.cornerRadius
|
||||
)
|
||||
}
|
||||
|
||||
private func updateFlashPath() {
|
||||
private func updateFlashPath(style: FlashStyle) {
|
||||
let inset: CGFloat
|
||||
let radius: CGFloat
|
||||
switch style {
|
||||
case .standardFocus:
|
||||
inset = CGFloat(FocusFlashPattern.ringInset)
|
||||
radius = CGFloat(FocusFlashPattern.ringCornerRadius)
|
||||
case .notificationDismiss:
|
||||
inset = NotificationRingMetrics.inset
|
||||
radius = NotificationRingMetrics.cornerRadius
|
||||
}
|
||||
updateOverlayRingPath(
|
||||
layer: flashLayer,
|
||||
bounds: flashOverlayView.bounds,
|
||||
inset: CGFloat(FocusFlashPattern.ringInset),
|
||||
radius: CGFloat(FocusFlashPattern.ringCornerRadius)
|
||||
inset: inset,
|
||||
radius: radius
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -7029,18 +7361,30 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
}
|
||||
#endif
|
||||
|
||||
let hostContainer = nsView as? HostContainerView
|
||||
let hostOwnsPortalNow = hostContainer.map { host in
|
||||
terminalSurface.claimPortalHost(
|
||||
hostId: ObjectIdentifier(host),
|
||||
inWindow: host.window != nil,
|
||||
bounds: host.bounds,
|
||||
reason: "update"
|
||||
)
|
||||
} ?? true
|
||||
|
||||
// Keep the surface lifecycle and handlers updated even if we defer re-parenting.
|
||||
hostedView.attachSurface(terminalSurface)
|
||||
hostedView.setInactiveOverlay(
|
||||
color: inactiveOverlayColor,
|
||||
opacity: CGFloat(inactiveOverlayOpacity),
|
||||
visible: showsInactiveOverlay
|
||||
)
|
||||
hostedView.setNotificationRing(visible: showsUnreadNotificationRing)
|
||||
hostedView.setSearchOverlay(searchState: searchState)
|
||||
hostedView.syncKeyStateIndicator(text: terminalSurface.currentKeyStateIndicatorText)
|
||||
hostedView.setFocusHandler { onFocus?(terminalSurface.id) }
|
||||
hostedView.setTriggerFlashHandler(onTriggerFlash)
|
||||
if hostOwnsPortalNow {
|
||||
hostedView.setInactiveOverlay(
|
||||
color: inactiveOverlayColor,
|
||||
opacity: CGFloat(inactiveOverlayOpacity),
|
||||
visible: showsInactiveOverlay
|
||||
)
|
||||
hostedView.setNotificationRing(visible: showsUnreadNotificationRing)
|
||||
hostedView.setSearchOverlay(searchState: searchState)
|
||||
hostedView.syncKeyStateIndicator(text: terminalSurface.currentKeyStateIndicatorText)
|
||||
}
|
||||
let portalExpectedSurfaceId = terminalSurface.id
|
||||
let portalExpectedGeneration = terminalSurface.portalBindingGeneration()
|
||||
let forwardedDropZone = isVisibleInUI ? paneDropZone : nil
|
||||
|
|
@ -7063,16 +7407,23 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
)
|
||||
}
|
||||
#endif
|
||||
hostedView.setDropZoneOverlay(zone: forwardedDropZone)
|
||||
if hostOwnsPortalNow {
|
||||
hostedView.setDropZoneOverlay(zone: forwardedDropZone)
|
||||
}
|
||||
|
||||
coordinator.attachGeneration += 1
|
||||
let generation = coordinator.attachGeneration
|
||||
|
||||
let hostContainer = nsView as? HostContainerView
|
||||
if let host = hostContainer {
|
||||
host.onDidMoveToWindow = { [weak host, weak hostedView, weak coordinator] in
|
||||
guard let host, let hostedView, let coordinator else { return }
|
||||
guard coordinator.attachGeneration == generation else { return }
|
||||
guard terminalSurface.claimPortalHost(
|
||||
hostId: ObjectIdentifier(host),
|
||||
inWindow: host.window != nil,
|
||||
bounds: host.bounds,
|
||||
reason: "didMoveToWindow"
|
||||
) else { return }
|
||||
guard host.window != nil else { return }
|
||||
TerminalWindowPortalRegistry.bind(
|
||||
hostedView: hostedView,
|
||||
|
|
@ -7091,9 +7442,16 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
host.onGeometryChanged = { [weak host, weak hostedView, weak coordinator] in
|
||||
guard let host, let hostedView, let coordinator else { return }
|
||||
guard coordinator.attachGeneration == generation else { return }
|
||||
guard coordinator.lastBoundHostId == ObjectIdentifier(host) else { return }
|
||||
guard terminalSurface.claimPortalHost(
|
||||
hostId: ObjectIdentifier(host),
|
||||
inWindow: host.window != nil,
|
||||
bounds: host.bounds,
|
||||
reason: "geometryChanged"
|
||||
) else { return }
|
||||
let hostId = ObjectIdentifier(host)
|
||||
if host.window != nil,
|
||||
!TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) {
|
||||
(coordinator.lastBoundHostId != hostId ||
|
||||
!TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)) {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"ws.hostState.rebindOnGeometry surface=\(terminalSurface.id.uuidString.prefix(5)) " +
|
||||
|
|
@ -7109,7 +7467,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
expectedSurfaceId: portalExpectedSurfaceId,
|
||||
expectedGeneration: portalExpectedGeneration
|
||||
)
|
||||
coordinator.lastBoundHostId = ObjectIdentifier(host)
|
||||
coordinator.lastBoundHostId = hostId
|
||||
hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI)
|
||||
hostedView.setActive(coordinator.desiredIsActive)
|
||||
hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing)
|
||||
|
|
@ -7118,7 +7476,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision
|
||||
}
|
||||
|
||||
if host.window != nil {
|
||||
if host.window != nil, hostOwnsPortalNow {
|
||||
let hostId = ObjectIdentifier(host)
|
||||
let geometryRevision = host.geometryRevision
|
||||
let portalEntryMissing = !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)
|
||||
|
|
@ -7153,7 +7511,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
|
||||
coordinator.lastSynchronizedHostGeometryRevision = geometryRevision
|
||||
}
|
||||
} else {
|
||||
} else if hostOwnsPortalNow {
|
||||
// Bind is deferred until host moves into a window. Update the
|
||||
// existing portal entry's visibleInUI now so that any portal sync
|
||||
// that runs before the deferred bind completes won't hide the view.
|
||||
|
|
@ -7178,7 +7536,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
let isBoundToCurrentHost = hostContainer.map { host in
|
||||
TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)
|
||||
} ?? true
|
||||
let shouldApplyImmediateHostedState = Self.shouldApplyImmediateHostedStateUpdate(
|
||||
let shouldApplyImmediateHostedState = hostOwnsPortalNow && Self.shouldApplyImmediateHostedStateUpdate(
|
||||
hostedViewHasSuperview: hostedView.superview != nil,
|
||||
isBoundToCurrentHost: isBoundToCurrentHost
|
||||
)
|
||||
|
|
@ -7193,7 +7551,8 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
if desiredStateChanged {
|
||||
dlog(
|
||||
"ws.hostState.deferApply surface=\(terminalSurface.id.uuidString.prefix(5)) " +
|
||||
"reason=staleHostBinding hostWindow=\(hostWindowAttached ? 1 : 0) " +
|
||||
"reason=\(hostOwnsPortalNow ? "staleHostBinding" : "hostOwnershipRejected") " +
|
||||
"hostWindow=\(hostWindowAttached ? 1 : 0) " +
|
||||
"boundToCurrent=\(isBoundToCurrentHost ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0) " +
|
||||
"visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)"
|
||||
)
|
||||
|
|
@ -7231,6 +7590,10 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
if let host = nsView as? HostContainerView {
|
||||
host.onDidMoveToWindow = nil
|
||||
host.onGeometryChanged = nil
|
||||
hostedView?.releaseOwnedPortalHost(
|
||||
hostId: ObjectIdentifier(host),
|
||||
reason: "dismantle"
|
||||
)
|
||||
}
|
||||
|
||||
// SwiftUI can transiently dismantle/rebuild NSViewRepresentable instances during split
|
||||
|
|
|
|||
|
|
@ -1714,6 +1714,13 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
}
|
||||
private var searchNeedleCancellable: AnyCancellable?
|
||||
let portalAnchorView = BrowserPortalAnchorView(frame: .zero)
|
||||
private struct PortalHostLease {
|
||||
let hostId: ObjectIdentifier
|
||||
let paneId: UUID
|
||||
let inWindow: Bool
|
||||
let area: CGFloat
|
||||
}
|
||||
private var activePortalHostLease: PortalHostLease?
|
||||
private var webViewCancellables = Set<AnyCancellable>()
|
||||
private var navigationDelegate: BrowserNavigationDelegate?
|
||||
private var uiDelegate: BrowserUIDelegate?
|
||||
|
|
@ -1755,6 +1762,96 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
return String(localized: "browser.newTab", defaultValue: "New tab")
|
||||
}
|
||||
|
||||
private static let portalHostAreaThreshold: CGFloat = 4
|
||||
private static let portalHostReplacementAreaGainRatio: CGFloat = 1.2
|
||||
|
||||
private static func portalHostArea(for bounds: CGRect) -> CGFloat {
|
||||
max(0, bounds.width) * max(0, bounds.height)
|
||||
}
|
||||
|
||||
private static func portalHostIsUsable(_ lease: PortalHostLease) -> Bool {
|
||||
lease.inWindow && lease.area > portalHostAreaThreshold
|
||||
}
|
||||
|
||||
func claimPortalHost(
|
||||
hostId: ObjectIdentifier,
|
||||
paneId: PaneID,
|
||||
inWindow: Bool,
|
||||
bounds: CGRect,
|
||||
reason: String
|
||||
) -> Bool {
|
||||
let next = PortalHostLease(
|
||||
hostId: hostId,
|
||||
paneId: paneId.id,
|
||||
inWindow: inWindow,
|
||||
area: Self.portalHostArea(for: bounds)
|
||||
)
|
||||
|
||||
if let current = activePortalHostLease {
|
||||
if current.hostId == hostId {
|
||||
activePortalHostLease = next
|
||||
return true
|
||||
}
|
||||
|
||||
let currentUsable = Self.portalHostIsUsable(current)
|
||||
let nextUsable = Self.portalHostIsUsable(next)
|
||||
let shouldReplace =
|
||||
current.paneId != paneId.id ||
|
||||
!currentUsable ||
|
||||
(nextUsable && next.area > (current.area * Self.portalHostReplacementAreaGainRatio))
|
||||
|
||||
if shouldReplace {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " +
|
||||
"reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " +
|
||||
"inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
|
||||
"replacingHost=\(current.hostId) replacingPane=\(current.paneId.uuidString.prefix(5)) " +
|
||||
"replacingInWin=\(current.inWindow ? 1 : 0) replacingArea=\(String(format: "%.1f", current.area))"
|
||||
)
|
||||
#endif
|
||||
activePortalHostLease = next
|
||||
return true
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.host.skip panel=\(id.uuidString.prefix(5)) " +
|
||||
"reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " +
|
||||
"inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
|
||||
"ownerHost=\(current.hostId) ownerPane=\(current.paneId.uuidString.prefix(5)) " +
|
||||
"ownerInWin=\(current.inWindow ? 1 : 0) ownerArea=\(String(format: "%.1f", current.area))"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
activePortalHostLease = next
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " +
|
||||
"reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " +
|
||||
"inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
|
||||
"replacingHost=nil"
|
||||
)
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func releasePortalHostIfOwned(hostId: ObjectIdentifier, reason: String) -> Bool {
|
||||
guard let current = activePortalHostLease, current.hostId == hostId else { return false }
|
||||
activePortalHostLease = nil
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.host.release panel=\(id.uuidString.prefix(5)) " +
|
||||
"reason=\(reason) host=\(hostId) pane=\(current.paneId.uuidString.prefix(5)) " +
|
||||
"inWin=\(current.inWindow ? 1 : 0) area=\(String(format: "%.1f", current.area))"
|
||||
)
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
var displayIcon: String? {
|
||||
"globe"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -206,6 +206,7 @@ func resolvedBrowserOmnibarPillBackgroundColor(
|
|||
/// View for rendering a browser panel with address bar
|
||||
struct BrowserPanelView: View {
|
||||
@ObservedObject var panel: BrowserPanel
|
||||
let paneId: PaneID
|
||||
let isFocused: Bool
|
||||
let isVisibleInUI: Bool
|
||||
let portalPriority: Int
|
||||
|
|
@ -312,13 +313,23 @@ struct BrowserPanelView: View {
|
|||
)
|
||||
}
|
||||
|
||||
private var isCurrentPaneOwner: Bool {
|
||||
guard let workspace = AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == panel.workspaceId }),
|
||||
let currentPaneId = workspace.paneId(forPanelId: panel.id) else {
|
||||
return false
|
||||
}
|
||||
return currentPaneId.id == paneId.id
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Layering contract: browser Cmd+F UI is mounted in the portal-hosted AppKit
|
||||
// container. Rendering it here can hide it behind the portal-hosted WKWebView.
|
||||
VStack(spacing: 0) {
|
||||
addressBar
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
webView
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.overlay {
|
||||
// Keep Cmd+F usable when the browser is still in the empty new-tab
|
||||
// state (no WKWebView mounted yet). WebView-backed cases are hosted
|
||||
|
|
@ -795,7 +806,8 @@ struct BrowserPanelView: View {
|
|||
if panel.shouldRenderWebView {
|
||||
WebViewRepresentable(
|
||||
panel: panel,
|
||||
shouldAttachWebView: isVisibleInUI,
|
||||
paneId: paneId,
|
||||
shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner,
|
||||
shouldFocusWebView: isFocused && !addressBarFocused,
|
||||
isPanelFocused: isFocused,
|
||||
portalZPriority: portalPriority,
|
||||
|
|
@ -813,8 +825,9 @@ struct BrowserPanelView: View {
|
|||
)
|
||||
.accessibilityIdentifier("BrowserWebViewSurface")
|
||||
// Keep the host stable for normal pane churn, but force a remount when
|
||||
// BrowserPanel replaces its underlying WKWebView after process termination.
|
||||
.id(panel.webViewInstanceID)
|
||||
// BrowserPanel replaces its underlying WKWebView after process termination
|
||||
// or when the browser moves to a different Bonsplit pane host.
|
||||
.id("\(panel.webViewInstanceID.uuidString)-\(paneId.id.uuidString)")
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityIdentifier(browserContentAccessibilityIdentifier)
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
|
|
@ -839,6 +852,8 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.layoutPriority(1)
|
||||
.zIndex(0)
|
||||
}
|
||||
|
||||
|
|
@ -3502,6 +3517,7 @@ private struct OmnibarSuggestionsView: View {
|
|||
/// NSViewRepresentable wrapper for WKWebView
|
||||
struct WebViewRepresentable: NSViewRepresentable {
|
||||
let panel: BrowserPanel
|
||||
let paneId: PaneID
|
||||
let shouldAttachWebView: Bool
|
||||
let shouldFocusWebView: Bool
|
||||
let isPanelFocused: Bool
|
||||
|
|
@ -4271,35 +4287,96 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
guard host.window != nil else { return }
|
||||
if anchorView.superview !== host {
|
||||
anchorView.removeFromSuperview()
|
||||
anchorView.frame = host.bounds
|
||||
anchorView.translatesAutoresizingMaskIntoConstraints = true
|
||||
anchorView.autoresizingMask = [.width, .height]
|
||||
anchorView.translatesAutoresizingMaskIntoConstraints = false
|
||||
host.addSubview(anchorView)
|
||||
} else if anchorView.frame != host.bounds {
|
||||
anchorView.frame = host.bounds
|
||||
NSLayoutConstraint.activate([
|
||||
anchorView.topAnchor.constraint(equalTo: host.topAnchor),
|
||||
anchorView.bottomAnchor.constraint(equalTo: host.bottomAnchor),
|
||||
anchorView.leadingAnchor.constraint(equalTo: host.leadingAnchor),
|
||||
anchorView.trailingAnchor.constraint(equalTo: host.trailingAnchor),
|
||||
])
|
||||
} else if anchorView.translatesAutoresizingMaskIntoConstraints {
|
||||
anchorView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
anchorView.topAnchor.constraint(equalTo: host.topAnchor),
|
||||
anchorView.bottomAnchor.constraint(equalTo: host.bottomAnchor),
|
||||
anchorView.leadingAnchor.constraint(equalTo: host.leadingAnchor),
|
||||
anchorView.trailingAnchor.constraint(equalTo: host.trailingAnchor),
|
||||
])
|
||||
}
|
||||
host.layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) {
|
||||
guard let host = nsView as? HostContainerView else { return }
|
||||
private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool {
|
||||
guard let host = nsView as? HostContainerView else { return false }
|
||||
|
||||
let coordinator = context.coordinator
|
||||
let paneDropContext = currentPaneDropContext()
|
||||
let isCurrentPaneOwner = paneDropContext?.paneId.id == paneId.id
|
||||
let hostId = ObjectIdentifier(host)
|
||||
let previousVisible = coordinator.desiredPortalVisibleInUI
|
||||
let previousZPriority = coordinator.desiredPortalZPriority
|
||||
coordinator.desiredPortalVisibleInUI = shouldAttachWebView
|
||||
coordinator.desiredPortalVisibleInUI = shouldAttachWebView && isCurrentPaneOwner
|
||||
coordinator.desiredPortalZPriority = portalZPriority
|
||||
coordinator.attachGeneration += 1
|
||||
let generation = coordinator.attachGeneration
|
||||
let paneDropContext = shouldAttachWebView ? currentPaneDropContext() : nil
|
||||
let activeSearchOverlay = shouldAttachWebView ? searchOverlay : nil
|
||||
let activePaneDropContext = coordinator.desiredPortalVisibleInUI ? paneDropContext : nil
|
||||
let activeSearchOverlay = coordinator.desiredPortalVisibleInUI ? searchOverlay : nil
|
||||
let portalAnchorView = panel.portalAnchorView
|
||||
if host.window != nil {
|
||||
let portalHideReason = !isCurrentPaneOwner ? "lostPaneOwnership" : "hidden"
|
||||
let didReleasePortalHost: Bool
|
||||
if !shouldAttachWebView || !isCurrentPaneOwner {
|
||||
didReleasePortalHost = panel.releasePortalHostIfOwned(
|
||||
hostId: hostId,
|
||||
reason: portalHideReason
|
||||
)
|
||||
// Only the host that currently owns the portal is allowed to hide it.
|
||||
// Older keep-alive hosts can still receive updates after a new owner binds.
|
||||
if didReleasePortalHost {
|
||||
BrowserWindowPortalRegistry.hide(
|
||||
webView: webView,
|
||||
source: "viewStateChanged.\(portalHideReason)"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
didReleasePortalHost = false
|
||||
}
|
||||
let portalHostAccepted =
|
||||
shouldAttachWebView &&
|
||||
isCurrentPaneOwner &&
|
||||
panel.claimPortalHost(
|
||||
hostId: hostId,
|
||||
paneId: paneId,
|
||||
inWindow: host.window != nil,
|
||||
bounds: host.bounds,
|
||||
reason: "update"
|
||||
)
|
||||
#if DEBUG
|
||||
if !isCurrentPaneOwner && (shouldAttachWebView || host.window != nil) {
|
||||
dlog(
|
||||
"browser.portal.owner.skip panel=\(panel.id.uuidString.prefix(5)) " +
|
||||
"viewPane=\(paneId.id.uuidString.prefix(5)) " +
|
||||
"currentPane=\(paneDropContext?.paneId.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"host=\(Self.objectID(host)) hostInWin=\(host.window != nil ? 1 : 0) " +
|
||||
"released=\(didReleasePortalHost ? 1 : 0)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
if host.window != nil, portalHostAccepted {
|
||||
Self.installPortalAnchorView(portalAnchorView, in: host)
|
||||
}
|
||||
|
||||
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator, weak portalAnchorView] in
|
||||
guard let host, let webView, let coordinator, let portalAnchorView else { return }
|
||||
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator, weak portalAnchorView, weak browserPanel = panel] in
|
||||
guard let host, let webView, let coordinator, let portalAnchorView, let browserPanel else { return }
|
||||
guard coordinator.attachGeneration == generation else { return }
|
||||
guard currentPaneDropContext()?.paneId.id == paneId.id else { return }
|
||||
guard browserPanel.claimPortalHost(
|
||||
hostId: ObjectIdentifier(host),
|
||||
paneId: paneId,
|
||||
inWindow: host.window != nil,
|
||||
bounds: host.bounds,
|
||||
reason: "didMoveToWindow"
|
||||
) else { return }
|
||||
guard host.window != nil else { return }
|
||||
Self.installPortalAnchorView(portalAnchorView, in: host)
|
||||
BrowserWindowPortalRegistry.bind(
|
||||
|
|
@ -4312,18 +4389,26 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
for: webView,
|
||||
height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0
|
||||
)
|
||||
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: paneDropContext)
|
||||
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: activePaneDropContext)
|
||||
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
|
||||
coordinator.lastPortalHostId = ObjectIdentifier(host)
|
||||
coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision
|
||||
}
|
||||
host.onGeometryChanged = { [weak host, weak webView, weak coordinator, weak portalAnchorView] in
|
||||
guard let host, let webView, let coordinator, let portalAnchorView else { return }
|
||||
host.onGeometryChanged = { [weak host, weak webView, weak coordinator, weak portalAnchorView, weak browserPanel = panel] in
|
||||
guard let host, let webView, let coordinator, let portalAnchorView, let browserPanel else { return }
|
||||
guard coordinator.attachGeneration == generation else { return }
|
||||
guard coordinator.lastPortalHostId == ObjectIdentifier(host) else { return }
|
||||
guard currentPaneDropContext()?.paneId.id == paneId.id else { return }
|
||||
guard browserPanel.claimPortalHost(
|
||||
hostId: ObjectIdentifier(host),
|
||||
paneId: paneId,
|
||||
inWindow: host.window != nil,
|
||||
bounds: host.bounds,
|
||||
reason: "geometryChanged"
|
||||
) else { return }
|
||||
guard host.window != nil else { return }
|
||||
let hostId = ObjectIdentifier(host)
|
||||
Self.installPortalAnchorView(portalAnchorView, in: host)
|
||||
if host.window != nil,
|
||||
if coordinator.lastPortalHostId != hostId ||
|
||||
!BrowserWindowPortalRegistry.isWebView(webView, boundTo: portalAnchorView) {
|
||||
BrowserWindowPortalRegistry.bind(
|
||||
webView: webView,
|
||||
|
|
@ -4335,9 +4420,9 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
for: webView,
|
||||
height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0
|
||||
)
|
||||
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: paneDropContext)
|
||||
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: activePaneDropContext)
|
||||
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
|
||||
coordinator.lastPortalHostId = ObjectIdentifier(host)
|
||||
coordinator.lastPortalHostId = hostId
|
||||
}
|
||||
BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView)
|
||||
coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision
|
||||
|
|
@ -4349,8 +4434,7 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
panel.syncDeveloperToolsPreferenceFromInspector()
|
||||
}
|
||||
|
||||
if host.window != nil {
|
||||
let hostId = ObjectIdentifier(host)
|
||||
if host.window != nil, portalHostAccepted {
|
||||
let geometryRevision = host.geometryRevision
|
||||
let portalEntryMissing = !BrowserWindowPortalRegistry.isWebView(webView, boundTo: portalAnchorView)
|
||||
let shouldBindNow =
|
||||
|
|
@ -4372,7 +4456,7 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
}
|
||||
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
|
||||
for: webView,
|
||||
height: shouldAttachWebView ? paneTopChromeHeight : 0
|
||||
height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0
|
||||
)
|
||||
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
|
||||
if !shouldBindNow,
|
||||
|
|
@ -4380,7 +4464,7 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView)
|
||||
coordinator.lastSynchronizedHostGeometryRevision = geometryRevision
|
||||
}
|
||||
} else {
|
||||
} else if portalHostAccepted {
|
||||
// Bind is deferred until host moves into a window. Keep the current
|
||||
// portal entry's desired state in sync so stale callbacks cannot keep
|
||||
// the previous anchor visible while this host is temporarily off-window.
|
||||
|
|
@ -4391,19 +4475,21 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
)
|
||||
}
|
||||
|
||||
BrowserWindowPortalRegistry.updateDropZoneOverlay(
|
||||
for: webView,
|
||||
zone: shouldAttachWebView ? paneDropZone : nil
|
||||
)
|
||||
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
|
||||
for: webView,
|
||||
height: shouldAttachWebView ? paneTopChromeHeight : 0
|
||||
)
|
||||
BrowserWindowPortalRegistry.updatePaneDropContext(
|
||||
for: webView,
|
||||
context: paneDropContext
|
||||
)
|
||||
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
|
||||
if portalHostAccepted {
|
||||
BrowserWindowPortalRegistry.updateDropZoneOverlay(
|
||||
for: webView,
|
||||
zone: coordinator.desiredPortalVisibleInUI ? paneDropZone : nil
|
||||
)
|
||||
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
|
||||
for: webView,
|
||||
height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0
|
||||
)
|
||||
BrowserWindowPortalRegistry.updatePaneDropContext(
|
||||
for: webView,
|
||||
context: activePaneDropContext
|
||||
)
|
||||
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
|
||||
}
|
||||
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
|
||||
|
|
@ -4416,11 +4502,13 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
details: Self.attachContext(webView: webView, host: host)
|
||||
)
|
||||
#endif
|
||||
return portalHostAccepted
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
let webView = panel.webView
|
||||
let coordinator = context.coordinator
|
||||
let isCurrentPaneOwner = currentPaneDropContext()?.paneId.id == paneId.id
|
||||
if let previousWebView = coordinator.webView, previousWebView !== webView {
|
||||
BrowserWindowPortalRegistry.detach(webView: previousWebView)
|
||||
coordinator.lastPortalHostId = nil
|
||||
|
|
@ -4428,21 +4516,21 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
}
|
||||
coordinator.panel = panel
|
||||
coordinator.webView = webView
|
||||
|
||||
Self.clearPortalCallbacks(for: nsView)
|
||||
let hostOwnsPortal = updateUsingWindowPortal(nsView, context: context, webView: webView)
|
||||
Self.applyWebViewFirstResponderPolicy(
|
||||
panel: panel,
|
||||
webView: webView,
|
||||
isPanelFocused: isPanelFocused
|
||||
isPanelFocused: isPanelFocused && isCurrentPaneOwner && hostOwnsPortal
|
||||
)
|
||||
|
||||
Self.clearPortalCallbacks(for: nsView)
|
||||
updateUsingWindowPortal(nsView, context: context, webView: webView)
|
||||
|
||||
Self.applyFocus(
|
||||
panel: panel,
|
||||
webView: webView,
|
||||
nsView: nsView,
|
||||
shouldFocusWebView: shouldFocusWebView,
|
||||
isPanelFocused: isPanelFocused
|
||||
shouldFocusWebView: shouldFocusWebView && isCurrentPaneOwner && hostOwnsPortal,
|
||||
isPanelFocused: isPanelFocused && isCurrentPaneOwner && hostOwnsPortal
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -4527,6 +4615,12 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
|
||||
coordinator.attachGeneration += 1
|
||||
clearPortalCallbacks(for: nsView)
|
||||
if let panel = coordinator.panel, let host = nsView as? HostContainerView {
|
||||
panel.releasePortalHostIfOwned(
|
||||
hostId: ObjectIdentifier(host),
|
||||
reason: "dismantle"
|
||||
)
|
||||
}
|
||||
|
||||
guard let webView = coordinator.webView else { return }
|
||||
let panel = coordinator.panel
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import SwiftUI
|
||||
import Foundation
|
||||
import Bonsplit
|
||||
|
||||
/// View that renders the appropriate panel view based on panel type
|
||||
struct PanelContentView: View {
|
||||
let panel: any Panel
|
||||
let paneId: PaneID
|
||||
let isFocused: Bool
|
||||
let isSelectedInPane: Bool
|
||||
let isVisibleInUI: Bool
|
||||
|
|
@ -35,6 +37,7 @@ struct PanelContentView: View {
|
|||
if let browserPanel = panel as? BrowserPanel {
|
||||
BrowserPanelView(
|
||||
panel: browserPanel,
|
||||
paneId: paneId,
|
||||
isFocused: isFocused,
|
||||
isVisibleInUI: isVisibleInUI,
|
||||
portalPriority: portalPriority,
|
||||
|
|
|
|||
|
|
@ -190,6 +190,10 @@ final class TerminalPanel: Panel, ObservableObject {
|
|||
hostedView.triggerFlash()
|
||||
}
|
||||
|
||||
func triggerNotificationDismissFlash() {
|
||||
hostedView.triggerFlash(style: .notificationDismiss)
|
||||
}
|
||||
|
||||
func applyWindowBackgroundIfActive() {
|
||||
surface.applyWindowBackgroundIfActive()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1121,6 +1121,16 @@ class TabManager: ObservableObject {
|
|||
tabs.insert(tab, at: insertIndex)
|
||||
}
|
||||
|
||||
func moveTabToTopForNotification(_ tabId: UUID) {
|
||||
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
|
||||
let pinnedCount = tabs.filter { $0.isPinned }.count
|
||||
guard index != pinnedCount else { return }
|
||||
let tab = tabs[index]
|
||||
guard !tab.isPinned else { return }
|
||||
tabs.remove(at: index)
|
||||
tabs.insert(tab, at: pinnedCount)
|
||||
}
|
||||
|
||||
func moveTabsToTop(_ tabIds: Set<UUID>) {
|
||||
guard !tabIds.isEmpty else { return }
|
||||
let selectedTabs = tabs.filter { tabIds.contains($0.id) }
|
||||
|
|
@ -1809,19 +1819,37 @@ class TabManager: ObservableObject {
|
|||
guard !shouldSuppressFlash else { return }
|
||||
guard AppFocusState.isAppActive() else { return }
|
||||
guard let panelId = focusedPanelId(for: tabId) else { return }
|
||||
markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId)
|
||||
_ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true)
|
||||
}
|
||||
|
||||
private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) {
|
||||
guard selectedTabId == tabId else { return }
|
||||
guard !suppressFocusFlash else { return }
|
||||
guard AppFocusState.isAppActive() else { return }
|
||||
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
|
||||
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: panelId) else { return }
|
||||
if let tab = tabs.first(where: { $0.id == tabId }) {
|
||||
_ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func dismissNotificationOnDirectInteraction(tabId: UUID, surfaceId: UUID?) -> Bool {
|
||||
dismissNotificationIfActive(tabId: tabId, surfaceId: surfaceId, triggerFlash: true)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func dismissNotificationIfActive(
|
||||
tabId: UUID,
|
||||
surfaceId: UUID?,
|
||||
triggerFlash: Bool
|
||||
) -> Bool {
|
||||
guard selectedTabId == tabId else { return false }
|
||||
guard AppFocusState.isAppActive() else { return false }
|
||||
guard let notificationStore = AppDelegate.shared?.notificationStore else { return false }
|
||||
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return false }
|
||||
if triggerFlash,
|
||||
let panelId = surfaceId,
|
||||
let tab = tabs.first(where: { $0.id == tabId }) {
|
||||
tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false)
|
||||
}
|
||||
notificationStore.markRead(forTabId: tabId, surfaceId: panelId)
|
||||
notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId)
|
||||
return true
|
||||
}
|
||||
|
||||
private func enqueuePanelTitleUpdate(tabId: UUID, panelId: UUID, title: String) {
|
||||
|
|
|
|||
|
|
@ -13626,6 +13626,7 @@ class TerminalController {
|
|||
|
||||
var lines: [String] = []
|
||||
lines.append("tab=\(tab.id.uuidString)")
|
||||
lines.append("color=\(tab.customColor ?? "none")")
|
||||
lines.append("cwd=\(tab.currentDirectory)")
|
||||
|
||||
if let focused = tab.focusedPanelId,
|
||||
|
|
|
|||
|
|
@ -671,6 +671,11 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
private var notificationSettingsURLOpener: (URL) -> Void = { url in
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
private var notificationDeliveryHandler: (TerminalNotificationStore, TerminalNotification) -> Void = {
|
||||
store,
|
||||
notification in
|
||||
store.scheduleUserNotification(notification)
|
||||
}
|
||||
private var indexes = NotificationIndexes()
|
||||
|
||||
private init() {
|
||||
|
|
@ -833,17 +838,10 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
let isFocusedSurface = surfaceId == nil || focusedSurfaceId == surfaceId
|
||||
let isFocusedPanel = isActiveTab && isFocusedSurface
|
||||
let isAppFocused = AppFocusState.isAppFocused()
|
||||
if isAppFocused && isFocusedPanel {
|
||||
if !idsToClear.isEmpty {
|
||||
notifications = updated
|
||||
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
||||
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
||||
}
|
||||
return
|
||||
}
|
||||
let shouldSuppressExternalDelivery = isAppFocused && isFocusedPanel
|
||||
|
||||
if WorkspaceAutoReorderSettings.isEnabled() {
|
||||
AppDelegate.shared?.tabManager?.moveTabToTop(tabId)
|
||||
AppDelegate.shared?.tabManager?.moveTabToTopForNotification(tabId)
|
||||
}
|
||||
|
||||
let notification = TerminalNotification(
|
||||
|
|
@ -862,7 +860,9 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
||||
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
||||
}
|
||||
scheduleUserNotification(notification)
|
||||
if !shouldSuppressExternalDelivery {
|
||||
notificationDeliveryHandler(self, notification)
|
||||
}
|
||||
}
|
||||
|
||||
func markRead(id: UUID) {
|
||||
|
|
@ -1233,6 +1233,18 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
hasPromptedForSettings = false
|
||||
}
|
||||
|
||||
func configureNotificationDeliveryHandlerForTesting(
|
||||
_ handler: @escaping (TerminalNotificationStore, TerminalNotification) -> Void
|
||||
) {
|
||||
notificationDeliveryHandler = handler
|
||||
}
|
||||
|
||||
func resetNotificationDeliveryHandlerForTesting() {
|
||||
notificationDeliveryHandler = { store, notification in
|
||||
store.scheduleUserNotification(notification)
|
||||
}
|
||||
}
|
||||
|
||||
func promptToEnableNotificationsForTesting() {
|
||||
promptToEnableNotifications()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2924,7 +2924,6 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
scheduleFocusReconcile()
|
||||
}
|
||||
scheduleTerminalGeometryReconcile()
|
||||
scheduleMovedBrowserRefresh(panelId: detached.panelId)
|
||||
|
||||
#if DEBUG
|
||||
dlog(
|
||||
|
|
@ -3261,7 +3260,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
if requiresSplit && !isSplit {
|
||||
return
|
||||
}
|
||||
terminalPanel.triggerFlash()
|
||||
terminalPanel.triggerNotificationDismissFlash()
|
||||
}
|
||||
|
||||
func triggerDebugFlash(panelId: UUID) {
|
||||
|
|
@ -3509,25 +3508,6 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
runRefreshPass(0.03)
|
||||
}
|
||||
|
||||
private func scheduleMovedBrowserRefresh(panelId: UUID) {
|
||||
guard browserPanel(for: panelId) != nil else { return }
|
||||
|
||||
let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||
guard let self, let browser = self.browserPanel(for: panelId) else { return }
|
||||
BrowserWindowPortalRegistry.refresh(
|
||||
webView: browser.webView,
|
||||
reason: "workspace.movedBrowserRefresh"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror terminal moved-surface refreshes so round-trip pane drags get
|
||||
// another render pass after bonsplit has settled its reparenting.
|
||||
runRefreshPass(0)
|
||||
runRefreshPass(0.03)
|
||||
}
|
||||
|
||||
private func closeTabs(_ tabIds: [TabID], skipPinned: Bool = true) {
|
||||
for tabId in tabIds {
|
||||
if skipPinned,
|
||||
|
|
@ -4195,7 +4175,6 @@ extension Workspace: BonsplitDelegate {
|
|||
#endif
|
||||
if let movedPanelId = panelIdFromSurfaceId(tab.id) {
|
||||
scheduleMovedTerminalRefresh(panelId: movedPanelId)
|
||||
scheduleMovedBrowserRefresh(panelId: movedPanelId)
|
||||
}
|
||||
#if DEBUG
|
||||
let selectedAfter = controller.selectedTab(inPane: destination)
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ struct WorkspaceContentView: View {
|
|||
)
|
||||
PanelContentView(
|
||||
panel: panel,
|
||||
paneId: paneId,
|
||||
isFocused: isFocused,
|
||||
isSelectedInPane: isSelectedInPane,
|
||||
isVisibleInUI: isVisibleInUI,
|
||||
|
|
|
|||
|
|
@ -2513,6 +2513,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
|
|||
|
||||
func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsVisible() {
|
||||
let (panel, _) = makePanelWithInspector()
|
||||
let paneId = PaneID(id: UUID())
|
||||
XCTAssertTrue(panel.showDeveloperTools())
|
||||
|
||||
let window = NSWindow(
|
||||
|
|
@ -2534,6 +2535,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
|
|||
|
||||
let representable = WebViewRepresentable(
|
||||
panel: panel,
|
||||
paneId: paneId,
|
||||
shouldAttachWebView: true,
|
||||
shouldFocusWebView: false,
|
||||
isPanelFocused: true,
|
||||
|
|
@ -2552,6 +2554,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
|
|||
|
||||
func testWebViewDismantleKeepsPortalHostedWebViewAttachedWhenDeveloperToolsIntentIsHidden() {
|
||||
let (panel, _) = makePanelWithInspector()
|
||||
let paneId = PaneID(id: UUID())
|
||||
XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
|
||||
|
||||
let window = NSWindow(
|
||||
|
|
@ -2573,6 +2576,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
|
|||
|
||||
let representable = WebViewRepresentable(
|
||||
panel: panel,
|
||||
paneId: paneId,
|
||||
shouldAttachWebView: true,
|
||||
shouldFocusWebView: false,
|
||||
isPanelFocused: true,
|
||||
|
|
@ -4229,6 +4233,58 @@ final class WorkspaceReorderTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WorkspaceNotificationReorderTests: XCTestCase {
|
||||
func testNotificationAutoReorderDoesNotMovePinnedWorkspace() {
|
||||
let appDelegate = AppDelegate.shared ?? AppDelegate()
|
||||
let manager = TabManager()
|
||||
let notificationStore = TerminalNotificationStore.shared
|
||||
|
||||
let originalTabManager = appDelegate.tabManager
|
||||
let originalNotificationStore = appDelegate.notificationStore
|
||||
let defaults = UserDefaults.standard
|
||||
let originalAutoReorderSetting = defaults.object(forKey: WorkspaceAutoReorderSettings.key)
|
||||
let originalAppFocusOverride = AppFocusState.overrideIsFocused
|
||||
|
||||
notificationStore.replaceNotificationsForTesting([])
|
||||
notificationStore.configureNotificationDeliveryHandlerForTesting { _, _ in }
|
||||
appDelegate.tabManager = manager
|
||||
appDelegate.notificationStore = notificationStore
|
||||
defaults.set(true, forKey: WorkspaceAutoReorderSettings.key)
|
||||
AppFocusState.overrideIsFocused = false
|
||||
|
||||
defer {
|
||||
notificationStore.replaceNotificationsForTesting([])
|
||||
notificationStore.resetNotificationDeliveryHandlerForTesting()
|
||||
appDelegate.tabManager = originalTabManager
|
||||
appDelegate.notificationStore = originalNotificationStore
|
||||
AppFocusState.overrideIsFocused = originalAppFocusOverride
|
||||
if let originalAutoReorderSetting {
|
||||
defaults.set(originalAutoReorderSetting, forKey: WorkspaceAutoReorderSettings.key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: WorkspaceAutoReorderSettings.key)
|
||||
}
|
||||
}
|
||||
|
||||
let firstPinned = manager.tabs[0]
|
||||
manager.setPinned(firstPinned, pinned: true)
|
||||
let secondPinned = manager.addWorkspace()
|
||||
manager.setPinned(secondPinned, pinned: true)
|
||||
let unpinned = manager.addWorkspace()
|
||||
let expectedOrder = [firstPinned.id, secondPinned.id, unpinned.id]
|
||||
|
||||
notificationStore.addNotification(
|
||||
tabId: secondPinned.id,
|
||||
surfaceId: nil,
|
||||
title: "Build finished",
|
||||
subtitle: "",
|
||||
body: "Pinned workspaces should stay put"
|
||||
)
|
||||
|
||||
XCTAssertEqual(manager.tabs.map(\.id), expectedOrder)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TabManagerChildExitCloseTests: XCTestCase {
|
||||
func testChildExitOnLastPanelClosesSelectedWorkspaceAndKeepsIndexStable() {
|
||||
|
|
@ -7390,6 +7446,118 @@ final class NotificationDockBadgeTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TerminalNotificationDirectInteractionTests: XCTestCase {
|
||||
private func makeWindow() -> NSWindow {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 480, height: 320),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.contentView = NSView(frame: window.contentRect(forFrameRect: window.frame))
|
||||
return window
|
||||
}
|
||||
|
||||
private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent {
|
||||
guard let event = NSEvent.mouseEvent(
|
||||
with: type,
|
||||
location: location,
|
||||
modifierFlags: [],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
eventNumber: 0,
|
||||
clickCount: 1,
|
||||
pressure: 1.0
|
||||
) else {
|
||||
fatalError("Failed to create \(type) mouse event")
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> NSView? {
|
||||
hostedView.subviews
|
||||
.compactMap { $0 as? NSScrollView }
|
||||
.first?
|
||||
.documentView?
|
||||
.subviews
|
||||
.first
|
||||
}
|
||||
|
||||
func testTerminalMouseDownDismissesUnreadWhenSurfaceIsAlreadyFirstResponder() {
|
||||
let appDelegate = AppDelegate.shared ?? AppDelegate()
|
||||
let manager = TabManager()
|
||||
let store = TerminalNotificationStore.shared
|
||||
let window = makeWindow()
|
||||
|
||||
let originalTabManager = appDelegate.tabManager
|
||||
let originalNotificationStore = appDelegate.notificationStore
|
||||
let originalAppFocusOverride = AppFocusState.overrideIsFocused
|
||||
|
||||
store.replaceNotificationsForTesting([])
|
||||
store.configureNotificationDeliveryHandlerForTesting { _, _ in }
|
||||
appDelegate.tabManager = manager
|
||||
appDelegate.notificationStore = store
|
||||
|
||||
defer {
|
||||
store.replaceNotificationsForTesting([])
|
||||
store.resetNotificationDeliveryHandlerForTesting()
|
||||
appDelegate.tabManager = originalTabManager
|
||||
appDelegate.notificationStore = originalNotificationStore
|
||||
AppFocusState.overrideIsFocused = originalAppFocusOverride
|
||||
window.orderOut(nil)
|
||||
}
|
||||
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let terminalPanel = workspace.focusedTerminalPanel else {
|
||||
XCTFail("Expected an initial focused terminal panel")
|
||||
return
|
||||
}
|
||||
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let hostedView = terminalPanel.hostedView
|
||||
hostedView.frame = contentView.bounds
|
||||
hostedView.autoresizingMask = [.width, .height]
|
||||
contentView.addSubview(hostedView)
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
hostedView.layoutSubtreeIfNeeded()
|
||||
|
||||
guard let surfaceView = surfaceView(in: hostedView) else {
|
||||
XCTFail("Expected terminal surface view")
|
||||
return
|
||||
}
|
||||
|
||||
GhosttySurfaceScrollView.resetFlashCounts()
|
||||
AppFocusState.overrideIsFocused = true
|
||||
XCTAssertTrue(window.makeFirstResponder(surfaceView))
|
||||
|
||||
store.addNotification(
|
||||
tabId: workspace.id,
|
||||
surfaceId: terminalPanel.id,
|
||||
title: "Unread",
|
||||
subtitle: "",
|
||||
body: ""
|
||||
)
|
||||
XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))
|
||||
|
||||
AppFocusState.overrideIsFocused = true
|
||||
let pointInWindow = surfaceView.convert(NSPoint(x: 20, y: 20), to: nil)
|
||||
let event = makeMouseEvent(type: .leftMouseDown, location: pointInWindow, window: window)
|
||||
surfaceView.mouseDown(with: event)
|
||||
let drained = expectation(description: "flash drained")
|
||||
DispatchQueue.main.async { drained.fulfill() }
|
||||
wait(for: [drained], timeout: 1.0)
|
||||
|
||||
XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))
|
||||
XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final class MenuBarBadgeLabelFormatterTests: XCTestCase {
|
||||
func testBadgeLabelFormatting() {
|
||||
|
|
@ -10859,6 +11027,72 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase {
|
|||
XCTAssertTrue(webView.superview === slot, "Hiding should preserve the hosted WKWebView attachment")
|
||||
XCTAssertTrue(slot.isHidden, "Hiding should immediately hide the existing portal slot")
|
||||
}
|
||||
|
||||
func testHiddenPortalEntrySurvivesAnchorRemovalUntilWorkspaceRebind() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
defer { window.orderOut(nil) }
|
||||
realizeWindowLayout(window)
|
||||
let portal = WindowBrowserPortal(window: window)
|
||||
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let anchorFrame = NSRect(x: 40, y: 24, width: 220, height: 160)
|
||||
let oldAnchor = NSView(frame: anchorFrame)
|
||||
contentView.addSubview(oldAnchor)
|
||||
|
||||
let webView = TrackingPortalWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
||||
portal.bind(webView: webView, to: oldAnchor, visibleInUI: true)
|
||||
portal.synchronizeWebViewForAnchor(oldAnchor)
|
||||
advanceAnimations()
|
||||
|
||||
guard let slot = webView.superview as? WindowBrowserSlotView else {
|
||||
XCTFail("Expected browser slot")
|
||||
return
|
||||
}
|
||||
|
||||
portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0)
|
||||
portal.synchronizeWebViewForAnchor(oldAnchor)
|
||||
advanceAnimations()
|
||||
XCTAssertTrue(slot.isHidden, "Workspace handoff should hide the retiring browser before unmount")
|
||||
|
||||
oldAnchor.removeFromSuperview()
|
||||
portal.synchronizeWebViewForAnchor(oldAnchor)
|
||||
advanceAnimations()
|
||||
|
||||
XCTAssertTrue(
|
||||
webView.superview === slot,
|
||||
"Hidden workspace browsers should stay attached while their SwiftUI anchor is temporarily unmounted"
|
||||
)
|
||||
XCTAssertTrue(slot.isHidden, "Unmounted hidden workspace browser should remain hidden until rebound")
|
||||
XCTAssertEqual(portal.debugEntryCount(), 1, "Workspace handoff should keep the hidden browser portal entry alive")
|
||||
|
||||
let displayCountBeforeRebind = webView.displayIfNeededCount
|
||||
let newAnchor = NSView(frame: anchorFrame)
|
||||
contentView.addSubview(newAnchor)
|
||||
portal.bind(webView: webView, to: newAnchor, visibleInUI: true)
|
||||
portal.synchronizeWebViewForAnchor(newAnchor)
|
||||
advanceAnimations()
|
||||
|
||||
XCTAssertTrue(
|
||||
webView.superview === slot,
|
||||
"Selecting the workspace again should reuse the existing hidden browser portal slot"
|
||||
)
|
||||
XCTAssertFalse(slot.isHidden, "Rebinding the workspace browser should reveal the existing portal slot")
|
||||
XCTAssertEqual(portal.debugEntryCount(), 1)
|
||||
XCTAssertGreaterThan(
|
||||
webView.displayIfNeededCount,
|
||||
displayCountBeforeRebind,
|
||||
"Workspace rebind should refresh the preserved browser without recreating its portal slot"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
|
|||
|
|
@ -939,52 +939,6 @@ final class RecentlyClosedBrowserStackTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class TabManagerNotificationOrderingSourceTests: XCTestCase {
|
||||
func testGhosttyDidSetTitleObserverDoesNotHopThroughTask() throws {
|
||||
let projectRoot = findProjectRoot()
|
||||
let tabManagerURL = projectRoot.appendingPathComponent("Sources/TabManager.swift")
|
||||
let source = try String(contentsOf: tabManagerURL, encoding: .utf8)
|
||||
|
||||
guard let titleObserverStart = source.range(of: "forName: .ghosttyDidSetTitle"),
|
||||
let focusObserverStart = source.range(
|
||||
of: "forName: .ghosttyDidFocusSurface",
|
||||
range: titleObserverStart.upperBound..<source.endIndex
|
||||
) else {
|
||||
XCTFail("Failed to locate TabManager notification observer block in Sources/TabManager.swift")
|
||||
return
|
||||
}
|
||||
|
||||
let block = String(source[titleObserverStart.lowerBound..<focusObserverStart.lowerBound])
|
||||
XCTAssertFalse(
|
||||
block.contains("Task {"),
|
||||
"""
|
||||
The .ghosttyDidSetTitle observer must update model state in the notification callback.
|
||||
Using Task can reorder updates and leave titlebar/toolbar one event behind.
|
||||
"""
|
||||
)
|
||||
XCTAssertTrue(
|
||||
block.contains("MainActor.assumeIsolated"),
|
||||
"Expected .ghosttyDidSetTitle observer to run synchronously on MainActor."
|
||||
)
|
||||
XCTAssertTrue(
|
||||
block.contains("enqueuePanelTitleUpdate"),
|
||||
"Expected .ghosttyDidSetTitle observer to enqueue panel title updates."
|
||||
)
|
||||
}
|
||||
|
||||
private func findProjectRoot() -> URL {
|
||||
var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent()
|
||||
for _ in 0..<10 {
|
||||
let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj")
|
||||
if FileManager.default.fileExists(atPath: marker.path) {
|
||||
return dir
|
||||
}
|
||||
dir = dir.deletingLastPathComponent()
|
||||
}
|
||||
return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||
}
|
||||
}
|
||||
|
||||
final class SocketControlSettingsTests: XCTestCase {
|
||||
func testMigrateModeSupportsExpandedSocketModes() {
|
||||
XCTAssertEqual(SocketControlSettings.migrateMode("off"), .off)
|
||||
|
|
@ -1339,4 +1293,169 @@ final class GhosttyMouseFocusTests: XCTestCase {
|
|||
)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - CJK Font Fallback
|
||||
|
||||
private func withTempConfig(
|
||||
_ contents: String,
|
||||
body: (String) -> Void
|
||||
) throws {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-test-cjk-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
|
||||
let file = dir.appendingPathComponent("config")
|
||||
try contents.write(to: file, atomically: true, encoding: .utf8)
|
||||
body(file.path)
|
||||
}
|
||||
|
||||
// MARK: cjkFontMappings
|
||||
|
||||
func testCJKFontMappingsReturnsHiraginoWithKanaForJapanese() {
|
||||
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "en-US"])!
|
||||
let fonts = Set(mappings.map(\.1))
|
||||
let ranges = mappings.map(\.0)
|
||||
|
||||
XCTAssertTrue(fonts.contains("Hiragino Sans"))
|
||||
XCTAssertTrue(ranges.contains("U+3040-U+309F"), "Should include Hiragana")
|
||||
XCTAssertTrue(ranges.contains("U+30A0-U+30FF"), "Should include Katakana")
|
||||
XCTAssertTrue(ranges.contains("U+4E00-U+9FFF"), "Should include CJK Ideographs")
|
||||
XCTAssertFalse(ranges.contains("U+AC00-U+D7AF"), "Should NOT include Hangul")
|
||||
}
|
||||
|
||||
func testCJKFontMappingsReturnsAppleSDGothicNeoWithHangulForKorean() {
|
||||
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ko-KR"])!
|
||||
let fonts = Set(mappings.map(\.1))
|
||||
let ranges = mappings.map(\.0)
|
||||
|
||||
XCTAssertTrue(fonts.contains("Apple SD Gothic Neo"))
|
||||
XCTAssertTrue(ranges.contains("U+AC00-U+D7AF"), "Should include Hangul Syllables")
|
||||
XCTAssertTrue(ranges.contains("U+1100-U+11FF"), "Should include Hangul Jamo")
|
||||
XCTAssertTrue(ranges.contains("U+4E00-U+9FFF"), "Should include CJK Ideographs")
|
||||
XCTAssertFalse(ranges.contains("U+3040-U+309F"), "Should NOT include Hiragana")
|
||||
}
|
||||
|
||||
func testCJKFontMappingsReturnsPingFangForChinese() {
|
||||
let mappingsTW = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hant-TW"])!
|
||||
XCTAssertTrue(mappingsTW.contains { $0.1 == "PingFang TC" })
|
||||
|
||||
let mappingsCN = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-Hans-CN"])!
|
||||
XCTAssertTrue(mappingsCN.contains { $0.1 == "PingFang SC" })
|
||||
|
||||
let mappingsHK = GhosttyApp.cjkFontMappings(preferredLanguages: ["zh-HK"])!
|
||||
XCTAssertTrue(mappingsHK.contains { $0.1 == "PingFang TC" })
|
||||
}
|
||||
|
||||
func testCJKFontMappingsReturnsNilForNonCJKLanguages() {
|
||||
XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: ["en-US", "fr-FR"]))
|
||||
XCTAssertNil(GhosttyApp.cjkFontMappings(preferredLanguages: []))
|
||||
}
|
||||
|
||||
func testCJKFontMappingsMultiLanguageMapsScriptSpecificRanges() {
|
||||
let mappings = GhosttyApp.cjkFontMappings(preferredLanguages: ["ja-JP", "ko-KR"])!
|
||||
|
||||
let hiraginoRanges = mappings.filter { $0.1 == "Hiragino Sans" }.map(\.0)
|
||||
let sdGothicRanges = mappings.filter { $0.1 == "Apple SD Gothic Neo" }.map(\.0)
|
||||
|
||||
XCTAssertTrue(hiraginoRanges.contains("U+3040-U+309F"), "Hiragana → Hiragino")
|
||||
XCTAssertTrue(hiraginoRanges.contains("U+4E00-U+9FFF"), "Shared CJK → first lang font")
|
||||
XCTAssertTrue(sdGothicRanges.contains("U+AC00-U+D7AF"), "Hangul → Apple SD Gothic Neo")
|
||||
XCTAssertFalse(hiraginoRanges.contains("U+AC00-U+D7AF"), "Hangul NOT in Hiragino")
|
||||
}
|
||||
|
||||
// MARK: userConfigContainsCJKCodepointMap
|
||||
|
||||
func testUserConfigContainsCJKCodepointMapDetectsPresence() throws {
|
||||
try withTempConfig("font-family = Menlo\nfont-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in
|
||||
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
|
||||
}
|
||||
}
|
||||
|
||||
func testUserConfigContainsCJKCodepointMapReturnsFalseWhenAbsent() throws {
|
||||
try withTempConfig("font-family = Menlo\nfont-size = 14\n") { path in
|
||||
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
|
||||
}
|
||||
}
|
||||
|
||||
func testUserConfigContainsCJKCodepointMapIgnoresComments() throws {
|
||||
try withTempConfig("# font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n") { path in
|
||||
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path]))
|
||||
}
|
||||
}
|
||||
|
||||
func testUserConfigContainsCJKCodepointMapReturnsFalseForMissingFiles() {
|
||||
let path = NSTemporaryDirectory() + "cmux-nonexistent-\(UUID().uuidString)/config"
|
||||
XCTAssertFalse(
|
||||
GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [path])
|
||||
)
|
||||
}
|
||||
|
||||
func testUserConfigContainsCJKCodepointMapFollowsConfigFileIncludes() throws {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-test-cjk-include-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
|
||||
let included = dir.appendingPathComponent("fonts.conf")
|
||||
try "font-codepoint-map = U+3000-U+9FFF=Hiragino Sans\n"
|
||||
.write(to: included, atomically: true, encoding: .utf8)
|
||||
|
||||
let main = dir.appendingPathComponent("config")
|
||||
try "font-family = Menlo\nconfig-file = \(included.path)\n"
|
||||
.write(to: main, atomically: true, encoding: .utf8)
|
||||
|
||||
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
|
||||
}
|
||||
|
||||
func testUserConfigContainsCJKCodepointMapFollowsRelativeIncludes() throws {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-test-cjk-rel-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
|
||||
let included = dir.appendingPathComponent("fonts.conf")
|
||||
try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n"
|
||||
.write(to: included, atomically: true, encoding: .utf8)
|
||||
|
||||
let main = dir.appendingPathComponent("config")
|
||||
try "config-file = fonts.conf\n"
|
||||
.write(to: main, atomically: true, encoding: .utf8)
|
||||
|
||||
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
|
||||
}
|
||||
|
||||
func testUserConfigContainsCJKCodepointMapHandlesOptionalInclude() throws {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-test-cjk-opt-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
|
||||
let included = dir.appendingPathComponent("fonts.conf")
|
||||
try "font-codepoint-map = U+4E00-U+9FFF=Hiragino Sans\n"
|
||||
.write(to: included, atomically: true, encoding: .utf8)
|
||||
|
||||
let main = dir.appendingPathComponent("config")
|
||||
try "config-file = \(included.path)?\n"
|
||||
.write(to: main, atomically: true, encoding: .utf8)
|
||||
|
||||
XCTAssertTrue(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [main.path]))
|
||||
}
|
||||
|
||||
func testUserConfigContainsCJKCodepointMapHandlesCyclicIncludes() throws {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-test-cjk-cycle-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
|
||||
let fileA = dir.appendingPathComponent("a.conf")
|
||||
let fileB = dir.appendingPathComponent("b.conf")
|
||||
try "config-file = \(fileB.path)\n"
|
||||
.write(to: fileA, atomically: true, encoding: .utf8)
|
||||
try "config-file = \(fileA.path)\n"
|
||||
.write(to: fileB, atomically: true, encoding: .utf8)
|
||||
|
||||
// Should not hang; should return false since neither file has font-codepoint-map
|
||||
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [fileA.path]))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,101 +8,6 @@ import AppKit
|
|||
@testable import cmux
|
||||
#endif
|
||||
|
||||
/// Regression test: ensures UpdatePill is never gated behind #if DEBUG in production code paths.
|
||||
/// This prevents accidentally hiding the update UI in Release builds.
|
||||
final class UpdatePillReleaseVisibilityTests: XCTestCase {
|
||||
|
||||
/// Source files that must show UpdatePill without #if DEBUG guards.
|
||||
private let filesToCheck = [
|
||||
"Sources/Update/UpdateTitlebarAccessory.swift",
|
||||
"Sources/ContentView.swift",
|
||||
]
|
||||
|
||||
func testUpdatePillNotGatedBehindDebug() throws {
|
||||
let projectRoot = findProjectRoot()
|
||||
|
||||
for relativePath in filesToCheck {
|
||||
let url = projectRoot.appendingPathComponent(relativePath)
|
||||
let source = try String(contentsOf: url, encoding: .utf8)
|
||||
let lines = source.components(separatedBy: .newlines)
|
||||
|
||||
// Track #if DEBUG nesting depth.
|
||||
var debugDepth = 0
|
||||
|
||||
for (index, line) in lines.enumerated() {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
if trimmed == "#if DEBUG" || trimmed.hasPrefix("#if DEBUG ") {
|
||||
debugDepth += 1
|
||||
} else if trimmed == "#endif" && debugDepth > 0 {
|
||||
debugDepth -= 1
|
||||
} else if trimmed == "#else" && debugDepth > 0 {
|
||||
// #else inside #if DEBUG means we're in the non-debug branch — that's fine.
|
||||
// But UpdatePill in the #if DEBUG branch (before #else) is the problem.
|
||||
// We handle this by only flagging UpdatePill when debugDepth > 0 and we haven't
|
||||
// hit #else yet. For simplicity, treat #else as flipping out of the guarded section.
|
||||
debugDepth -= 1
|
||||
}
|
||||
|
||||
if debugDepth > 0 && trimmed.contains("UpdatePill") {
|
||||
XCTFail(
|
||||
"""
|
||||
\(relativePath):\(index + 1) — UpdatePill is inside #if DEBUG. \
|
||||
This hides the update UI in Release builds. Remove the #if DEBUG guard \
|
||||
or move UpdatePill to the #else branch.
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func findProjectRoot() -> URL {
|
||||
// Walk up from the test bundle to find the project root (contains GhosttyTabs.xcodeproj).
|
||||
var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent()
|
||||
for _ in 0..<10 {
|
||||
let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj")
|
||||
if FileManager.default.fileExists(atPath: marker.path) {
|
||||
return dir
|
||||
}
|
||||
dir = dir.deletingLastPathComponent()
|
||||
}
|
||||
// Fallback: assume CWD is project root.
|
||||
return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test: ensure WKWebView can load HTTP development URLs (e.g. *.localtest.me).
|
||||
final class AppTransportSecurityTests: XCTestCase {
|
||||
func testInfoPlistAllowsArbitraryLoadsInWebContent() throws {
|
||||
let projectRoot = findProjectRoot()
|
||||
let infoPlistURL = projectRoot.appendingPathComponent("Resources/Info.plist")
|
||||
let data = try Data(contentsOf: infoPlistURL)
|
||||
var format = PropertyListSerialization.PropertyListFormat.xml
|
||||
let plist = try XCTUnwrap(
|
||||
PropertyListSerialization.propertyList(from: data, options: [], format: &format) as? [String: Any]
|
||||
)
|
||||
let ats = try XCTUnwrap(plist["NSAppTransportSecurity"] as? [String: Any])
|
||||
XCTAssertEqual(
|
||||
ats["NSAllowsArbitraryLoadsInWebContent"] as? Bool,
|
||||
true,
|
||||
"Resources/Info.plist must allow HTTP loads in WKWebView for local dev hostnames."
|
||||
)
|
||||
}
|
||||
|
||||
private func findProjectRoot() -> URL {
|
||||
var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent()
|
||||
for _ in 0..<10 {
|
||||
let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj")
|
||||
if FileManager.default.fileExists(atPath: marker.path) {
|
||||
return dir
|
||||
}
|
||||
dir = dir.deletingLastPathComponent()
|
||||
}
|
||||
return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||
}
|
||||
}
|
||||
|
||||
final class BrowserInsecureHTTPSettingsTests: XCTestCase {
|
||||
func testDefaultAllowlistPatternsArePresent() {
|
||||
XCTAssertEqual(
|
||||
|
|
@ -272,45 +177,3 @@ final class TitlebarControlsHoverPolicyTests: XCTestCase {
|
|||
XCTAssertFalse(titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyle.softButtons.config))
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test: ensure new terminal windows are born in full-size content mode so
|
||||
/// titlebar/content offsets are correct before the first resize.
|
||||
final class MainWindowLayoutStyleTests: XCTestCase {
|
||||
func testCreateMainWindowUsesFullSizeContentViewStyleMask() throws {
|
||||
let projectRoot = findProjectRoot()
|
||||
let appDelegateURL = projectRoot.appendingPathComponent("Sources/AppDelegate.swift")
|
||||
let source = try String(contentsOf: appDelegateURL, encoding: .utf8)
|
||||
|
||||
guard let start = source.range(of: "func createMainWindow("),
|
||||
let end = source.range(of: "@objc func checkForUpdates", range: start.upperBound..<source.endIndex) else {
|
||||
XCTFail("Could not locate createMainWindow block in Sources/AppDelegate.swift")
|
||||
return
|
||||
}
|
||||
|
||||
let block = String(source[start.lowerBound..<end.lowerBound])
|
||||
let regex = try NSRegularExpression(
|
||||
pattern: #"styleMask:\s*\[[^\]]*\.fullSizeContentView"#,
|
||||
options: [.dotMatchesLineSeparators]
|
||||
)
|
||||
let range = NSRange(block.startIndex..<block.endIndex, in: block)
|
||||
XCTAssertNotNil(
|
||||
regex.firstMatch(in: block, options: [], range: range),
|
||||
"""
|
||||
createMainWindow must include `.fullSizeContentView` in the NSWindow style mask.
|
||||
Without it, initial titlebar/content offsets can be wrong until a manual resize.
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
private func findProjectRoot() -> URL {
|
||||
var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent()
|
||||
for _ in 0..<10 {
|
||||
let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj")
|
||||
if FileManager.default.fileExists(atPath: marker.path) {
|
||||
return dir
|
||||
}
|
||||
dir = dir.deletingLastPathComponent()
|
||||
}
|
||||
return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,126 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression guards for browser chrome contrast in mixed theme setups."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def extract_block(source: str, signature: str) -> str:
|
||||
start = source.find(signature)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing signature: {signature}")
|
||||
|
||||
brace_start = source.find("{", start)
|
||||
if brace_start < 0:
|
||||
raise ValueError(f"Missing opening brace for: {signature}")
|
||||
|
||||
depth = 0
|
||||
for idx in range(brace_start, len(source)):
|
||||
char = source[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[brace_start : idx + 1]
|
||||
|
||||
raise ValueError(f"Unbalanced braces for: {signature}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift"
|
||||
source = view_path.read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
|
||||
try:
|
||||
browser_panel_view_block = extract_block(source, "struct BrowserPanelView: View")
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
browser_panel_view_block = ""
|
||||
|
||||
try:
|
||||
resolver_block = extract_block(source, "func resolvedBrowserChromeColorScheme(")
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
resolver_block = ""
|
||||
|
||||
if resolver_block:
|
||||
if "backgroundColor.isLightColor ? .light : .dark" not in resolver_block:
|
||||
failures.append(
|
||||
"resolvedBrowserChromeColorScheme must map luminance to a light/dark ColorScheme"
|
||||
)
|
||||
|
||||
try:
|
||||
chrome_scheme_block = extract_block(
|
||||
browser_panel_view_block,
|
||||
"private var browserChromeColorScheme: ColorScheme",
|
||||
)
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
chrome_scheme_block = ""
|
||||
|
||||
if chrome_scheme_block and "resolvedBrowserChromeColorScheme(" not in chrome_scheme_block:
|
||||
failures.append("browserChromeColorScheme must use resolvedBrowserChromeColorScheme")
|
||||
|
||||
try:
|
||||
omnibar_background_block = extract_block(
|
||||
browser_panel_view_block,
|
||||
"private var omnibarPillBackgroundColor: NSColor",
|
||||
)
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
omnibar_background_block = ""
|
||||
|
||||
if omnibar_background_block and "for: browserChromeColorScheme" not in omnibar_background_block:
|
||||
failures.append("omnibar pill background must use browserChromeColorScheme")
|
||||
|
||||
try:
|
||||
address_bar_block = extract_block(
|
||||
browser_panel_view_block,
|
||||
"private var addressBar: some View",
|
||||
)
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
address_bar_block = ""
|
||||
|
||||
if address_bar_block and ".environment(\\.colorScheme, browserChromeColorScheme)" not in address_bar_block:
|
||||
failures.append("addressBar must apply browserChromeColorScheme via environment")
|
||||
|
||||
try:
|
||||
body_block = extract_block(browser_panel_view_block, "var body: some View")
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
body_block = ""
|
||||
|
||||
if body_block:
|
||||
if "OmnibarSuggestionsView(" not in body_block:
|
||||
failures.append("Expected OmnibarSuggestionsView block in BrowserPanelView body")
|
||||
elif ".environment(\\.colorScheme, browserChromeColorScheme)" not in body_block:
|
||||
failures.append("Omnibar suggestions must apply browserChromeColorScheme via environment")
|
||||
|
||||
if failures:
|
||||
print("FAIL: browser chrome contrast regression guards failed")
|
||||
for failure in failures:
|
||||
print(f" - {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: browser chrome contrast regression guards are in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression guard for browser console/errors CLI output formatting.
|
||||
|
||||
Ensures non-JSON `browser console list` and `browser errors list` do not fall
|
||||
back to unconditional `OK` when logs exist.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def extract_block(source: str, signature: str) -> str:
|
||||
start = source.find(signature)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing signature: {signature}")
|
||||
brace_start = source.find("{", start)
|
||||
if brace_start < 0:
|
||||
raise ValueError(f"Missing opening brace for: {signature}")
|
||||
depth = 0
|
||||
for idx in range(brace_start, len(source)):
|
||||
char = source[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[brace_start : idx + 1]
|
||||
raise ValueError(f"Unbalanced braces for: {signature}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
cli_path = root / "CLI" / "cmux.swift"
|
||||
cli_source = cli_path.read_text(encoding="utf-8")
|
||||
browser_block = extract_block(cli_source, "private func runBrowserCommand(")
|
||||
|
||||
if "func displayBrowserLogItems(_ value: Any?) -> String?" not in browser_block:
|
||||
failures.append("runBrowserCommand() is missing displayBrowserLogItems() helper")
|
||||
else:
|
||||
helper_block = extract_block(browser_block, "func displayBrowserLogItems(_ value: Any?) -> String?")
|
||||
if "return \"[\\(level)] \\(text)\"" not in helper_block:
|
||||
failures.append("displayBrowserLogItems() no longer renders level-prefixed log lines")
|
||||
if "return \"[error] \\(message)\"" not in helper_block:
|
||||
failures.append("displayBrowserLogItems() no longer renders concise JS error messages")
|
||||
if "return displayBrowserValue(dict)" not in helper_block:
|
||||
failures.append("displayBrowserLogItems() no longer falls back to structured formatting")
|
||||
|
||||
console_block = extract_block(browser_block, 'if subcommand == "console"')
|
||||
if 'displayBrowserLogItems(payload["entries"])' not in console_block:
|
||||
failures.append("browser console path no longer formats entries for non-JSON output")
|
||||
if 'output(payload, fallback: "OK")' in console_block:
|
||||
failures.append("browser console path regressed to unconditional OK output")
|
||||
|
||||
errors_block = extract_block(browser_block, 'if subcommand == "errors"')
|
||||
if 'displayBrowserLogItems(payload["errors"])' not in errors_block:
|
||||
failures.append("browser errors path no longer formats errors for non-JSON output")
|
||||
if 'output(payload, fallback: "OK")' in errors_block:
|
||||
failures.append("browser errors path regressed to unconditional OK output")
|
||||
|
||||
if failures:
|
||||
print("FAIL: browser console/errors CLI output regression guard failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: browser console/errors CLI output regression guard is in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression checks for browser DevTools/portal review fixes.
|
||||
|
||||
Guards two follow-up fixes:
|
||||
1) DevTools toggle path must retry restore when inspector show is transiently ignored.
|
||||
2) Browser portal visibility must propagate even if host is temporarily off-window.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def extract_block(source: str, signature: str) -> str:
|
||||
start = source.find(signature)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing signature: {signature}")
|
||||
brace_start = source.find("{", start)
|
||||
if brace_start < 0:
|
||||
raise ValueError(f"Missing opening brace for: {signature}")
|
||||
depth = 0
|
||||
for idx in range(brace_start, len(source)):
|
||||
char = source[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[brace_start : idx + 1]
|
||||
raise ValueError(f"Unbalanced braces for: {signature}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
panel_path = root / "Sources" / "Panels" / "BrowserPanel.swift"
|
||||
panel_source = panel_path.read_text(encoding="utf-8")
|
||||
toggle_block = extract_block(panel_source, "func toggleDeveloperTools() -> Bool")
|
||||
if "visibleAfterToggle" not in toggle_block:
|
||||
failures.append("toggleDeveloperTools() no longer re-checks inspector visibility")
|
||||
if "scheduleDeveloperToolsRestoreRetry()" not in toggle_block:
|
||||
failures.append("toggleDeveloperTools() no longer schedules a DevTools restore retry")
|
||||
|
||||
view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift"
|
||||
view_source = view_path.read_text(encoding="utf-8")
|
||||
portal_update_block = extract_block(view_source, "private func updateUsingWindowPortal(")
|
||||
if "BrowserWindowPortalRegistry.updateEntryVisibility(" not in portal_update_block:
|
||||
failures.append("BrowserPanelView.updateUsingWindowPortal() is missing deferred portal visibility propagation")
|
||||
if "zPriority: coordinator.desiredPortalZPriority" not in portal_update_block:
|
||||
failures.append("BrowserPanelView deferred portal update no longer propagates zPriority")
|
||||
|
||||
portal_path = root / "Sources" / "BrowserWindowPortal.swift"
|
||||
portal_source = portal_path.read_text(encoding="utf-8")
|
||||
if not re.search(
|
||||
r"func\s+updateEntryVisibility\s*\(\s*forWebViewId\s+webViewId:\s*ObjectIdentifier,\s*visibleInUI:\s*Bool,\s*zPriority:\s*Int\s*\)",
|
||||
portal_source,
|
||||
flags=re.MULTILINE,
|
||||
):
|
||||
failures.append("WindowBrowserPortal is missing updateEntryVisibility(forWebViewId:visibleInUI:zPriority:)")
|
||||
if not re.search(
|
||||
r"static\s+func\s+updateEntryVisibility\s*\(\s*for\s+webView:\s*WKWebView,\s*visibleInUI:\s*Bool,\s*zPriority:\s*Int\s*\)",
|
||||
portal_source,
|
||||
flags=re.MULTILINE,
|
||||
):
|
||||
failures.append("BrowserWindowPortalRegistry is missing updateEntryVisibility(for:visibleInUI:zPriority:)")
|
||||
|
||||
if failures:
|
||||
print("FAIL: browser devtools/portal regression guards failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: browser devtools/portal regression guards are in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression guard for browser eval async wrapping + telemetry injection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def extract_block(source: str, signature: str) -> str:
|
||||
start = source.find(signature)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing signature: {signature}")
|
||||
brace_start = source.find("{", start)
|
||||
if brace_start < 0:
|
||||
raise ValueError(f"Missing opening brace for: {signature}")
|
||||
depth = 0
|
||||
for idx in range(brace_start, len(source)):
|
||||
char = source[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[brace_start : idx + 1]
|
||||
raise ValueError(f"Unbalanced braces for: {signature}")
|
||||
|
||||
|
||||
def extract_span(source: str, start_marker: str, end_marker: str) -> str:
|
||||
start = source.find(start_marker)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing start marker: {start_marker}")
|
||||
end = source.find(end_marker, start)
|
||||
if end < 0:
|
||||
raise ValueError(f"Missing end marker: {end_marker}")
|
||||
return source[start:end]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
terminal_path = root / "Sources" / "TerminalController.swift"
|
||||
panel_path = root / "Sources" / "Panels" / "BrowserPanel.swift"
|
||||
terminal_source = terminal_path.read_text(encoding="utf-8")
|
||||
panel_source = panel_path.read_text(encoding="utf-8")
|
||||
|
||||
if "preferAsync: Bool = false" not in terminal_source:
|
||||
failures.append("v2RunJavaScript() no longer exposes preferAsync toggle")
|
||||
run_js_block = extract_block(terminal_source, "private func v2RunJavaScript(")
|
||||
if "callAsyncJavaScript" not in run_js_block:
|
||||
failures.append("v2RunJavaScript() no longer uses callAsyncJavaScript for async JS")
|
||||
|
||||
run_browser_js_block = extract_block(terminal_source, "private func v2RunBrowserJavaScript(")
|
||||
required_wrapper_tokens = [
|
||||
"let asyncFunctionBody =",
|
||||
"__cmuxMaybeAwait",
|
||||
"__cmux_t",
|
||||
"__cmux_v",
|
||||
"return await __cmuxEvalInFrame();",
|
||||
"preferAsync: true",
|
||||
]
|
||||
for token in required_wrapper_tokens:
|
||||
if token not in run_browser_js_block:
|
||||
failures.append(f"v2RunBrowserJavaScript() missing async eval wrapper token: {token}")
|
||||
|
||||
if "v2BrowserUndefinedSentinel" not in terminal_source:
|
||||
failures.append("TerminalController is missing undefined sentinel handling")
|
||||
if "v2BrowserEvalEnvelopeTypeUndefined" not in terminal_source:
|
||||
failures.append("TerminalController is missing undefined envelope decode constant")
|
||||
|
||||
hook_block = extract_block(terminal_source, "private func v2BrowserEnsureTelemetryHooks(")
|
||||
if "BrowserPanel.telemetryHookBootstrapScriptSource" not in hook_block:
|
||||
failures.append("v2BrowserEnsureTelemetryHooks() no longer uses shared BrowserPanel telemetry source")
|
||||
|
||||
if "static let telemetryHookBootstrapScriptSource" not in panel_source:
|
||||
failures.append("BrowserPanel is missing telemetryHookBootstrapScriptSource")
|
||||
if "static let dialogTelemetryHookBootstrapScriptSource" not in panel_source:
|
||||
failures.append("BrowserPanel is missing dialogTelemetryHookBootstrapScriptSource")
|
||||
|
||||
base_script_span = extract_span(
|
||||
panel_source,
|
||||
"static let telemetryHookBootstrapScriptSource =",
|
||||
"static let dialogTelemetryHookBootstrapScriptSource =",
|
||||
)
|
||||
if "window.alert = function(message)" in base_script_span:
|
||||
failures.append("Document-start telemetry script should not override alert dialogs")
|
||||
if "window.confirm = function(message)" in base_script_span:
|
||||
failures.append("Document-start telemetry script should not override confirm dialogs")
|
||||
if "window.prompt = function(message, defaultValue)" in base_script_span:
|
||||
failures.append("Document-start telemetry script should not override prompt dialogs")
|
||||
|
||||
panel_init_block = extract_block(
|
||||
panel_source,
|
||||
"init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil)",
|
||||
)
|
||||
required_init_tokens = [
|
||||
"config.userContentController.addUserScript(",
|
||||
"source: Self.telemetryHookBootstrapScriptSource",
|
||||
"injectionTime: .atDocumentStart",
|
||||
]
|
||||
for token in required_init_tokens:
|
||||
if token not in panel_init_block:
|
||||
failures.append(f"BrowserPanel init() missing telemetry user-script token: {token}")
|
||||
|
||||
if failures:
|
||||
print("FAIL: browser eval async wrapper / telemetry injection regression guard failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: browser eval async wrapper / telemetry injection regression guard is in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression guard for browser eval CLI output formatting.
|
||||
|
||||
Ensures `cmux browser <surface> eval <script>` prints the evaluated value
|
||||
instead of always printing `OK`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def extract_block(source: str, signature: str) -> str:
|
||||
start = source.find(signature)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing signature: {signature}")
|
||||
brace_start = source.find("{", start)
|
||||
if brace_start < 0:
|
||||
raise ValueError(f"Missing opening brace for: {signature}")
|
||||
depth = 0
|
||||
for idx in range(brace_start, len(source)):
|
||||
char = source[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[brace_start : idx + 1]
|
||||
raise ValueError(f"Unbalanced braces for: {signature}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
cli_path = root / "CLI" / "cmux.swift"
|
||||
cli_source = cli_path.read_text(encoding="utf-8")
|
||||
browser_block = extract_block(cli_source, "private func runBrowserCommand(")
|
||||
|
||||
if "func displayBrowserValue(_ value: Any) -> String" not in browser_block:
|
||||
failures.append("runBrowserCommand() is missing displayBrowserValue() helper")
|
||||
else:
|
||||
value_block = extract_block(browser_block, "func displayBrowserValue(_ value: Any) -> String")
|
||||
if 'dict["__cmux_t"] as? String' not in value_block or 'type == "undefined"' not in value_block:
|
||||
failures.append("displayBrowserValue() no longer maps __cmux_t=undefined to literal 'undefined'")
|
||||
required_guards = [
|
||||
"if value is NSNull",
|
||||
"if let string = value as? String",
|
||||
"if let bool = value as? Bool",
|
||||
"if let number = value as? NSNumber",
|
||||
]
|
||||
for guard in required_guards:
|
||||
if guard not in value_block:
|
||||
failures.append(f"displayBrowserValue() no longer handles: {guard}")
|
||||
|
||||
eval_block = extract_block(browser_block, 'if subcommand == "eval"')
|
||||
if 'let payload = try client.sendV2(method: "browser.eval"' not in eval_block:
|
||||
failures.append("browser eval path no longer calls browser.eval v2 method")
|
||||
if 'if let value = payload["value"]' not in eval_block:
|
||||
failures.append("browser eval path no longer reads payload value")
|
||||
if "fallback = displayBrowserValue(value)" not in eval_block:
|
||||
failures.append("browser eval path no longer formats payload value for CLI output")
|
||||
if 'output(payload, fallback: "OK")' in eval_block:
|
||||
failures.append("browser eval path regressed to unconditional OK output")
|
||||
|
||||
if failures:
|
||||
print("FAIL: browser eval CLI output regression guard failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: browser eval CLI output regression guard is in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression checks for favicon sync during browser navigation.
|
||||
|
||||
Guards the race fix where stale async favicon fetches must not overwrite the
|
||||
icon after the user navigates (including back/forward and same-URL reloads),
|
||||
while still allowing same-document URL changes (pushState/hash updates).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def extract_block(source: str, signature: str) -> str:
|
||||
start = source.find(signature)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing signature: {signature}")
|
||||
brace_start = source.find("{", start)
|
||||
if brace_start < 0:
|
||||
raise ValueError(f"Missing opening brace for: {signature}")
|
||||
depth = 0
|
||||
for idx in range(brace_start, len(source)):
|
||||
char = source[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[brace_start : idx + 1]
|
||||
raise ValueError(f"Unbalanced braces for: {signature}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
panel_path = root / "Sources" / "Panels" / "BrowserPanel.swift"
|
||||
panel_source = panel_path.read_text(encoding="utf-8")
|
||||
|
||||
if "private var faviconRefreshGeneration: Int = 0" not in panel_source:
|
||||
failures.append("BrowserPanel is missing faviconRefreshGeneration state")
|
||||
|
||||
refresh_block = extract_block(panel_source, "private func refreshFavicon(from webView: WKWebView)")
|
||||
if refresh_block.count("isCurrentFaviconRefresh(") < 3:
|
||||
failures.append("refreshFavicon() no longer checks staleness at each async stage")
|
||||
|
||||
current_guard_block = extract_block(panel_source, "private func isCurrentFaviconRefresh(")
|
||||
if "generation == faviconRefreshGeneration" not in current_guard_block:
|
||||
failures.append("isCurrentFaviconRefresh() no longer validates refresh generation")
|
||||
if "webView.url?.absoluteString == pageURLString" in current_guard_block:
|
||||
failures.append("isCurrentFaviconRefresh() still blocks same-document history URL changes")
|
||||
|
||||
loading_block = extract_block(panel_source, "private func handleWebViewLoadingChanged(_ newValue: Bool)")
|
||||
if "faviconRefreshGeneration &+= 1" not in loading_block:
|
||||
failures.append("handleWebViewLoadingChanged() no longer invalidates old favicon refreshes")
|
||||
if "faviconTask?.cancel()" not in loading_block:
|
||||
failures.append("handleWebViewLoadingChanged() no longer cancels stale favicon tasks")
|
||||
if "lastFaviconURLString = nil" not in loading_block:
|
||||
failures.append("handleWebViewLoadingChanged() no longer resets favicon URL cache on new loads")
|
||||
|
||||
if failures:
|
||||
print("FAIL: browser favicon navigation regression guard failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: browser favicon navigation guard is in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression guards for browser Cmd+F overlay layering in portal mode."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from regression_helpers import extract_block, repo_root
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift"
|
||||
panel_path = root / "Sources" / "Panels" / "BrowserPanel.swift"
|
||||
overlay_path = root / "Sources" / "Find" / "BrowserSearchOverlay.swift"
|
||||
source = view_path.read_text(encoding="utf-8")
|
||||
panel_source = panel_path.read_text(encoding="utf-8")
|
||||
overlay_source = overlay_path.read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
|
||||
try:
|
||||
browser_panel_view_block = extract_block(
|
||||
source, "struct BrowserPanelView: View"
|
||||
)
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
browser_panel_view_block = ""
|
||||
|
||||
try:
|
||||
body_block = extract_block(browser_panel_view_block, "var body: some View")
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
body_block = ""
|
||||
|
||||
fallback_signature = (
|
||||
"if !panel.shouldRenderWebView, let searchState = panel.searchState {"
|
||||
)
|
||||
fallback_block = ""
|
||||
if body_block:
|
||||
try:
|
||||
fallback_block = extract_block(body_block, fallback_signature)
|
||||
except ValueError:
|
||||
failures.append(
|
||||
"BrowserPanelView must provide BrowserSearchOverlay fallback for new-tab state "
|
||||
"(when WKWebView is not mounted)"
|
||||
)
|
||||
if fallback_block and "BrowserSearchOverlay(" not in fallback_block:
|
||||
failures.append(
|
||||
"BrowserPanelView fallback branch must mount BrowserSearchOverlay for new-tab state"
|
||||
)
|
||||
|
||||
try:
|
||||
webview_repr_block = extract_block(
|
||||
source, "struct WebViewRepresentable: NSViewRepresentable"
|
||||
)
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
webview_repr_block = ""
|
||||
|
||||
if webview_repr_block:
|
||||
if "let browserSearchState: BrowserSearchState?" not in webview_repr_block:
|
||||
failures.append(
|
||||
"WebViewRepresentable must include browserSearchState so Cmd+F state changes trigger updates"
|
||||
)
|
||||
if (
|
||||
"var searchOverlayHostingView: NSHostingView<BrowserSearchOverlay>?"
|
||||
not in webview_repr_block
|
||||
):
|
||||
failures.append(
|
||||
"WebViewRepresentable.Coordinator must own a BrowserSearchOverlay hosting view"
|
||||
)
|
||||
if "private static func updateSearchOverlay(" not in webview_repr_block:
|
||||
failures.append(
|
||||
"WebViewRepresentable must define updateSearchOverlay helper"
|
||||
)
|
||||
if "containerView: webView.superview" not in webview_repr_block:
|
||||
failures.append(
|
||||
"Portal updates must sync BrowserSearchOverlay against the web view container"
|
||||
)
|
||||
if "removeSearchOverlay(from: coordinator)" not in webview_repr_block:
|
||||
failures.append(
|
||||
"WebViewRepresentable must remove browser search overlays during teardown/rebind"
|
||||
)
|
||||
|
||||
if "browserSearchState: panel.searchState" not in source:
|
||||
failures.append(
|
||||
"BrowserPanelView must pass panel.searchState into WebViewRepresentable"
|
||||
)
|
||||
|
||||
try:
|
||||
update_ns_view_block = extract_block(
|
||||
webview_repr_block, "func updateNSView(_ nsView: NSView, context: Context)"
|
||||
)
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
update_ns_view_block = ""
|
||||
|
||||
if "updateSearchOverlay(" in update_ns_view_block:
|
||||
failures.append(
|
||||
"updateNSView must not re-run updateSearchOverlay outside portal lifecycle paths"
|
||||
)
|
||||
|
||||
try:
|
||||
suppress_focus_block = extract_block(
|
||||
panel_source, "func shouldSuppressWebViewFocus() -> Bool"
|
||||
)
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
suppress_focus_block = ""
|
||||
|
||||
if "if searchState != nil {" not in suppress_focus_block:
|
||||
failures.append(
|
||||
"BrowserPanel.shouldSuppressWebViewFocus must suppress focus while find-in-page is active"
|
||||
)
|
||||
|
||||
try:
|
||||
start_find_block = extract_block(panel_source, "func startFind()")
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
start_find_block = ""
|
||||
|
||||
if start_find_block:
|
||||
if "postBrowserSearchFocusNotification()" not in start_find_block:
|
||||
failures.append(
|
||||
"BrowserPanel.startFind must publish browserSearchFocus notifications"
|
||||
)
|
||||
if "DispatchQueue.main.async {" not in start_find_block:
|
||||
failures.append(
|
||||
"BrowserPanel.startFind must re-post focus on next runloop to avoid mount races"
|
||||
)
|
||||
if "DispatchQueue.main.asyncAfter" not in start_find_block:
|
||||
failures.append(
|
||||
"BrowserPanel.startFind must re-post focus shortly after to avoid portal mount races"
|
||||
)
|
||||
|
||||
try:
|
||||
init_block = extract_block(panel_source, "init(workspaceId: UUID")
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
init_block = ""
|
||||
|
||||
if init_block:
|
||||
if (
|
||||
"self?.searchState = nil" in init_block
|
||||
or "self.searchState = nil" in init_block
|
||||
):
|
||||
failures.append(
|
||||
"BrowserPanel navigation callbacks must not clear searchState entirely to avoid losing find bar focus"
|
||||
)
|
||||
if "restoreFindStateAfterNavigation(replaySearch: true)" not in init_block:
|
||||
failures.append(
|
||||
"BrowserPanel.didFinish must preserve find state and replay search on the new page"
|
||||
)
|
||||
if "restoreFindStateAfterNavigation(replaySearch: false)" not in init_block:
|
||||
failures.append(
|
||||
"BrowserPanel.didFailNavigation must preserve find state without replaying search"
|
||||
)
|
||||
|
||||
try:
|
||||
restore_find_state_block = extract_block(
|
||||
panel_source, "private func restoreFindStateAfterNavigation(replaySearch: Bool)"
|
||||
)
|
||||
except ValueError as error:
|
||||
failures.append(str(error))
|
||||
restore_find_state_block = ""
|
||||
|
||||
if restore_find_state_block:
|
||||
if "state.total = nil" not in restore_find_state_block:
|
||||
failures.append(
|
||||
"BrowserPanel restoreFindStateAfterNavigation must clear stale find total count"
|
||||
)
|
||||
if "state.selected = nil" not in restore_find_state_block:
|
||||
failures.append(
|
||||
"BrowserPanel restoreFindStateAfterNavigation must clear stale selected match"
|
||||
)
|
||||
if "if replaySearch, !state.needle.isEmpty {" not in restore_find_state_block:
|
||||
failures.append(
|
||||
"BrowserPanel restoreFindStateAfterNavigation must only replay search for successful navigations"
|
||||
)
|
||||
if "postBrowserSearchFocusNotification()" not in restore_find_state_block:
|
||||
failures.append(
|
||||
"BrowserPanel restoreFindStateAfterNavigation must reassert find field focus"
|
||||
)
|
||||
|
||||
if "private func requestSearchFieldFocus(" not in overlay_source:
|
||||
failures.append(
|
||||
"BrowserSearchOverlay must define requestSearchFieldFocus retry helper"
|
||||
)
|
||||
if "requestSearchFieldFocus()" not in overlay_source:
|
||||
failures.append(
|
||||
"BrowserSearchOverlay must request text focus from appear/notification paths"
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: browser find overlay portal regression guards failed")
|
||||
for failure in failures:
|
||||
print(f" - {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: browser find overlay remains mounted in portal-hosted AppKit layer")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression guards for compact browser omnibar sizing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def extract_block(source: str, signature: str) -> str:
|
||||
start = source.find(signature)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing signature: {signature}")
|
||||
brace_start = source.find("{", start)
|
||||
if brace_start < 0:
|
||||
raise ValueError(f"Missing opening brace for: {signature}")
|
||||
depth = 0
|
||||
for idx in range(brace_start, len(source)):
|
||||
char = source[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[brace_start : idx + 1]
|
||||
raise ValueError(f"Unbalanced braces for: {signature}")
|
||||
|
||||
|
||||
def parse_cgfloat_constant(source: str, name: str) -> float | None:
|
||||
match = re.search(
|
||||
rf"private let {re.escape(name)}: CGFloat = ([0-9]+(?:\.[0-9]+)?)",
|
||||
source,
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
return float(match.group(1))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift"
|
||||
view_source = view_path.read_text(encoding="utf-8")
|
||||
|
||||
hit_size = parse_cgfloat_constant(view_source, "addressBarButtonHitSize")
|
||||
if hit_size is None:
|
||||
failures.append("addressBarButtonHitSize constant is missing")
|
||||
elif hit_size > 26:
|
||||
failures.append(
|
||||
f"addressBarButtonHitSize regressed to {hit_size:g}; expected <= 26 for compact omnibar height"
|
||||
)
|
||||
|
||||
vertical_padding = parse_cgfloat_constant(view_source, "addressBarVerticalPadding")
|
||||
if vertical_padding is None:
|
||||
failures.append("addressBarVerticalPadding constant is missing")
|
||||
elif vertical_padding > 4:
|
||||
failures.append(
|
||||
f"addressBarVerticalPadding regressed to {vertical_padding:g}; expected <= 4 for compact omnibar height"
|
||||
)
|
||||
|
||||
omnibar_corner_radius = parse_cgfloat_constant(view_source, "omnibarPillCornerRadius")
|
||||
if omnibar_corner_radius is None:
|
||||
failures.append("omnibarPillCornerRadius constant is missing")
|
||||
elif omnibar_corner_radius > 10:
|
||||
failures.append(
|
||||
f"omnibarPillCornerRadius regressed to {omnibar_corner_radius:g}; expected <= 10 to keep a squircle profile"
|
||||
)
|
||||
|
||||
address_bar_block = extract_block(view_source, "private var addressBar: some View")
|
||||
if ".padding(.vertical, addressBarVerticalPadding)" not in address_bar_block:
|
||||
failures.append("addressBar no longer applies compact vertical padding via addressBarVerticalPadding")
|
||||
|
||||
omnibar_field_block = extract_block(view_source, "private var omnibarField: some View")
|
||||
if omnibar_field_block.count(
|
||||
"RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)"
|
||||
) < 2:
|
||||
failures.append(
|
||||
"omnibarField no longer uses continuous rounded-rectangle background+stroke tied to omnibarPillCornerRadius"
|
||||
)
|
||||
|
||||
button_bar_block = extract_block(view_source, "private var addressBarButtonBar: some View")
|
||||
hit_frame_uses = button_bar_block.count("addressBarButtonHitSize")
|
||||
if hit_frame_uses < 3:
|
||||
failures.append(
|
||||
"navigation buttons no longer consistently use addressBarButtonHitSize frames (padding may be lost)"
|
||||
)
|
||||
|
||||
extract_block(view_source, "private struct OmnibarAddressButtonStyle: ButtonStyle")
|
||||
style_body_block = extract_block(view_source, "private struct OmnibarAddressButtonStyleBody: View")
|
||||
if "configuration.isPressed" not in style_body_block:
|
||||
failures.append("OmnibarAddressButtonStyleBody is missing pressed-state styling")
|
||||
if "isHovered" not in style_body_block or ".onHover" not in style_body_block:
|
||||
failures.append("OmnibarAddressButtonStyleBody is missing hover-state styling")
|
||||
|
||||
style_uses = view_source.count(".buttonStyle(OmnibarAddressButtonStyle())")
|
||||
if style_uses < 4:
|
||||
failures.append(
|
||||
"address bar buttons no longer consistently use OmnibarAddressButtonStyle"
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: browser omnibar compact layout regression guards failed")
|
||||
for failure in failures:
|
||||
print(f" - {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: browser omnibar compact layout regression guards are in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression checks for deterministic browser lifecycle architecture.
|
||||
|
||||
Guards the long-term browser mounting design:
|
||||
1) BrowserPanelView updateNSView must use a single portal-based mount path.
|
||||
2) Legacy attach-retry and direct attach/detach churn helpers stay removed.
|
||||
3) BrowserPanel handles WebContent termination via deterministic webview replacement,
|
||||
not blind `webView.reload()`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
git_path = shutil.which("git")
|
||||
git_command = git_path if git_path else "git"
|
||||
result = subprocess.run(
|
||||
[git_command, "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def extract_block(source: str, signature: str) -> str:
|
||||
start = source.find(signature)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing signature: {signature}")
|
||||
brace_start = source.find("{", start)
|
||||
if brace_start < 0:
|
||||
raise ValueError(f"Missing opening brace for: {signature}")
|
||||
depth = 0
|
||||
for idx in range(brace_start, len(source)):
|
||||
char = source[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[brace_start : idx + 1]
|
||||
raise ValueError(f"Unbalanced braces for: {signature}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift"
|
||||
view_source = view_path.read_text(encoding="utf-8")
|
||||
|
||||
if "updateUsingWindowPortal(nsView, context: context, webView: webView)" not in view_source:
|
||||
failures.append("updateNSView no longer routes through updateUsingWindowPortal")
|
||||
if "scheduleAttachRetry(" in view_source:
|
||||
failures.append("Legacy attach retry helper still present in BrowserPanelView")
|
||||
if "attachRetryWorkItem" in view_source:
|
||||
failures.append("Legacy attachRetryWorkItem state still present in BrowserPanelView")
|
||||
if "usesWindowPortal" in view_source:
|
||||
failures.append("Dual portal/non-portal lifecycle state still present in BrowserPanelView")
|
||||
|
||||
panel_path = root / "Sources" / "Panels" / "BrowserPanel.swift"
|
||||
panel_source = panel_path.read_text(encoding="utf-8")
|
||||
|
||||
if "@Published private(set) var webViewInstanceID" not in panel_source:
|
||||
failures.append("BrowserPanel is missing webViewInstanceID for deterministic instance remounting")
|
||||
if "replaceWebViewAfterContentProcessTermination" not in panel_source:
|
||||
failures.append("BrowserPanel is missing deterministic WebContent termination replacement path")
|
||||
|
||||
terminate_delegate = extract_block(
|
||||
panel_source,
|
||||
"func webViewWebContentProcessDidTerminate(_ webView: WKWebView)",
|
||||
)
|
||||
if "didTerminateWebContentProcess?(webView)" not in terminate_delegate:
|
||||
failures.append("webContentProcessDidTerminate no longer delegates to deterministic replacement handler")
|
||||
if "webView.reload()" in terminate_delegate:
|
||||
failures.append("webContentProcessDidTerminate still does blind webView.reload()")
|
||||
|
||||
if failures:
|
||||
print("FAIL: browser lifecycle architecture regression guards failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: browser lifecycle architecture regression guards are in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test: CLI socket Sentry telemetry must apply to all commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle not in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def reject(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
cli_path = repo_root / "CLI" / "cmux.swift"
|
||||
if not cli_path.exists():
|
||||
print(f"FAIL: missing expected file: {cli_path}")
|
||||
return 1
|
||||
|
||||
content = cli_path.read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
|
||||
require(
|
||||
content,
|
||||
"private final class CLISocketSentryTelemetry {",
|
||||
"Missing CLISocketSentryTelemetry definition",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" ||',
|
||||
"Missing CMUX_CLI_SENTRY_DISABLED kill switch",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1"',
|
||||
"Missing backwards-compatible CMUX_CLAUDE_HOOK_SENTRY_DISABLED kill switch",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"private var shouldEmit: Bool {\n !disabledByEnv\n }",
|
||||
"Telemetry scope should be command-agnostic (only disabled by env kill switch)",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'let crumb = Breadcrumb(level: .info, category: "cmux.cli")',
|
||||
"Telemetry breadcrumb category should be cmux.cli",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'"command": command,',
|
||||
"Base telemetry context must include command name",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"let cliTelemetry = CLISocketSentryTelemetry(",
|
||||
"CLI should initialize generic socket telemetry",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'cliTelemetry.breadcrumb(\n "socket.connect.attempt",',
|
||||
"CLI should emit socket.connect.attempt breadcrumb for commands",
|
||||
failures,
|
||||
)
|
||||
|
||||
reject(
|
||||
content,
|
||||
"self.enabled = command == \"claude-hook\"",
|
||||
"Telemetry regressed to claude-hook-only scope",
|
||||
failures,
|
||||
)
|
||||
reject(
|
||||
content,
|
||||
"enabled && !disabledByEnv",
|
||||
"Telemetry still depends on legacy enabled flag",
|
||||
failures,
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: CLI socket telemetry scope regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: CLI socket telemetry scope is command-agnostic")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression tests for CLI subcommand help coverage and accuracy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle not in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def extract_switch_commands(content: str, start_index: int = 0) -> tuple[set[str], int]:
|
||||
marker = "switch command {"
|
||||
marker_index = content.find(marker, start_index)
|
||||
if marker_index == -1:
|
||||
return set(), -1
|
||||
|
||||
open_brace = content.find("{", marker_index)
|
||||
if open_brace == -1:
|
||||
return set(), -1
|
||||
|
||||
depth = 1
|
||||
cursor = open_brace + 1
|
||||
while cursor < len(content) and depth > 0:
|
||||
char = content[cursor]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
cursor += 1
|
||||
|
||||
block = content[open_brace + 1:cursor - 1]
|
||||
commands: set[str] = set()
|
||||
collecting_case = False
|
||||
case_lines: list[str] = []
|
||||
|
||||
for line in block.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("case "):
|
||||
collecting_case = True
|
||||
case_lines = [line]
|
||||
elif collecting_case:
|
||||
case_lines.append(line)
|
||||
|
||||
if collecting_case and ":" in line:
|
||||
case_text = "\n".join(case_lines)
|
||||
commands.update(re.findall(r'"([^"]+)"', case_text))
|
||||
collecting_case = False
|
||||
case_lines = []
|
||||
|
||||
return commands, cursor
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
cli_path = repo_root / "CLI" / "cmux.swift"
|
||||
if not cli_path.exists():
|
||||
print(f"FAIL: missing expected file: {cli_path}")
|
||||
return 1
|
||||
|
||||
content = cli_path.read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
|
||||
require(
|
||||
content,
|
||||
'if commandArgs.contains("--help") || commandArgs.contains("-h") {',
|
||||
"Subcommand help pre-dispatch gate is missing",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'if dispatchSubcommandHelp(command: command, commandArgs: commandArgs) {',
|
||||
"Subcommand help dispatch call is missing",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"print(\"Unknown command '\\(command)'. Run 'cmux help' to see available commands.\")",
|
||||
"Subcommand help fallback unknown-command line is missing",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"print(\"Unknown command '\\(command)'. Run 'cmux help' to see available commands.\")\n return",
|
||||
"Subcommand help fallback must return before command execution",
|
||||
failures,
|
||||
)
|
||||
|
||||
dispatch_commands, next_index = extract_switch_commands(content, 0)
|
||||
subcommand_usage_commands, _ = extract_switch_commands(content, next_index if next_index != -1 else 0)
|
||||
if not dispatch_commands:
|
||||
failures.append("Failed to parse main dispatch switch command list")
|
||||
if not subcommand_usage_commands:
|
||||
failures.append("Failed to parse subcommandUsage switch command list")
|
||||
|
||||
missing_help_entries = sorted(dispatch_commands - subcommand_usage_commands)
|
||||
if missing_help_entries:
|
||||
failures.append(
|
||||
"Missing subcommandUsage entries for dispatch command(s): "
|
||||
+ ", ".join(missing_help_entries)
|
||||
)
|
||||
|
||||
# Regression checks for concrete help text that previously drifted from dispatch logic.
|
||||
for needle, message in [
|
||||
('case "help":', "Missing subcommandUsage entry for help"),
|
||||
("Usage: cmux help", "help subcommand usage text is missing"),
|
||||
("Usage: cmux move-workspace-to-window --workspace <id|ref|index> --window <id|ref|index>", "move-workspace-to-window help must document index handles"),
|
||||
("--tab <id|ref|index> Target tab (accepts tab:<n> or surface:<n>; default: $CMUX_TAB_ID, then $CMUX_SURFACE_ID, then focused tab)", "tab-action help must document CMUX_TAB_ID/CMUX_SURFACE_ID fallback"),
|
||||
("--workspace <id|ref|index> Workspace to rename (default: current/$CMUX_WORKSPACE_ID)", "rename-workspace help must document CMUX_WORKSPACE_ID fallback"),
|
||||
("text|html|value|count|box|styles|attr: [--selector <css> | <css>]", "browser get help must document --selector"),
|
||||
("attr: [--attr <name> | <name>]", "browser get attr help must document --attr"),
|
||||
("styles: [--property <name>]", "browser get styles help must document --property"),
|
||||
("role: [--name <text>] [--exact] <role>", "browser find role help must document --name/--exact"),
|
||||
("text|label|placeholder|alt|title|testid: [--exact] <text>", "browser find text-like help must document --exact"),
|
||||
("nth: [--index <n> | <n>] [--selector <css> | <css>]", "browser find nth help must document --index/--selector"),
|
||||
("route <pattern> [--abort] [--body <text>]", "browser network route help must document --abort/--body"),
|
||||
]:
|
||||
require(content, needle, message, failures)
|
||||
|
||||
if failures:
|
||||
print("FAIL: CLI subcommand help regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: CLI subcommand help coverage and flag/env documentation are present")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test: `cmux tree` command wiring and output contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle not in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
cli_path = repo_root / "CLI" / "cmux.swift"
|
||||
controller_path = repo_root / "Sources" / "TerminalController.swift"
|
||||
if not cli_path.exists():
|
||||
print(f"FAIL: missing expected file: {cli_path}")
|
||||
return 1
|
||||
if not controller_path.exists():
|
||||
print(f"FAIL: missing expected file: {controller_path}")
|
||||
return 1
|
||||
|
||||
content = cli_path.read_text(encoding="utf-8")
|
||||
controller_content = controller_path.read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
|
||||
require(
|
||||
content,
|
||||
'case "tree":\n try runTreeCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat)',
|
||||
"Missing `tree` command dispatch",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"tree [--all] [--workspace <id|ref|index>]",
|
||||
"Top-level usage text missing tree command",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"Usage: cmux tree [flags]",
|
||||
"Subcommand help for `cmux tree --help` is missing",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"Known flags: --all --workspace <id|ref|index> --json",
|
||||
"Tree flag validation for --all/--workspace is missing",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"--json Structured JSON output",
|
||||
"Tree help text should document --json",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'print(jsonString(formatIDs(payload, mode: idFormat)))',
|
||||
"Tree command JSON output should honor --id-format conversion",
|
||||
failures,
|
||||
)
|
||||
|
||||
# Data sources needed for full hierarchy + browser URLs.
|
||||
for method in [
|
||||
'method: "system.tree"',
|
||||
'method: "system.identify"',
|
||||
'method: "window.list"',
|
||||
'method: "workspace.list"',
|
||||
'method: "pane.list"',
|
||||
'method: "surface.list"',
|
||||
'method: "browser.tab.list"',
|
||||
'method: "browser.url.get"',
|
||||
]:
|
||||
require(
|
||||
content,
|
||||
method,
|
||||
f"Tree command is missing expected API call: {method}",
|
||||
failures,
|
||||
)
|
||||
|
||||
# Text tree rendering contract.
|
||||
for glyph in ['"├── "', '"└── "', '"│ "']:
|
||||
require(
|
||||
content,
|
||||
glyph,
|
||||
f"Tree output missing box-drawing glyph: {glyph}",
|
||||
failures,
|
||||
)
|
||||
|
||||
for marker in ["[current]", "[selected]", "[focused]", "◀ active", "◀ here"]:
|
||||
require(
|
||||
content,
|
||||
marker,
|
||||
f"Tree output missing required marker: {marker}",
|
||||
failures,
|
||||
)
|
||||
|
||||
require(
|
||||
content,
|
||||
'surfaceType.lowercased() == "browser"',
|
||||
"Tree surface rendering should special-case browser surfaces",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'let url = surface["url"] as? String',
|
||||
"Tree surface rendering should include browser URL when available",
|
||||
failures,
|
||||
)
|
||||
|
||||
# Server-side one-shot hierarchy path for performance.
|
||||
for needle, message in [
|
||||
('case "system.tree":', "Socket router is missing system.tree dispatch"),
|
||||
('"system.tree"', "Capabilities list should advertise system.tree"),
|
||||
("private func v2SystemTree(params: [String: Any]) -> V2CallResult {", "Missing v2SystemTree implementation"),
|
||||
('"active":', "system.tree payload should include focused path"),
|
||||
('"caller":', "system.tree payload should include caller path"),
|
||||
('"windows":', "system.tree payload should include hierarchy windows"),
|
||||
]:
|
||||
require(controller_content, needle, message, failures)
|
||||
|
||||
if failures:
|
||||
print("FAIL: cmux tree command regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: cmux tree command wiring and output contract are present")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test: CLI version output wiring keeps commit metadata support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle not in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
cli_path = repo_root / "CLI" / "cmux.swift"
|
||||
if not cli_path.exists():
|
||||
print(f"FAIL: missing expected file: {cli_path}")
|
||||
return 1
|
||||
|
||||
content = cli_path.read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
|
||||
require(
|
||||
content,
|
||||
'let commit = info["CMUXCommit"].flatMap { normalizedCommitHash($0) }',
|
||||
"versionSummary no longer reads CMUXCommit metadata",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'return "\\(baseSummary) [\\(commit)]"',
|
||||
"versionSummary no longer appends commit metadata",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'if let commit = dictionary["CMUXCommit"] as? String,',
|
||||
"Info.plist parsing no longer reads CMUXCommit",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"if let commit = gitCommitHash(at: current) {",
|
||||
"Project fallback no longer probes git commit hash",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'["git", "-C", directory.path, "rev-parse", "--short=9", "HEAD"]',
|
||||
"Git commit probe command changed unexpectedly",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'normalizedCommitHash(ProcessInfo.processInfo.environment["CMUX_COMMIT"])',
|
||||
"Environment commit fallback (CMUX_COMMIT) is missing",
|
||||
failures,
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: CLI version commit metadata regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: CLI version commit metadata wiring is intact")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
196
tests/test_cli_version_memory_guard.py
Normal file
196
tests/test_cli_version_memory_guard.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: `cmux --version` must not scan huge sibling app lists just to
|
||||
resolve optional version metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import plistlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
|
||||
JUNK_APP_COUNT = 40000
|
||||
RSS_LIMIT_KB = 64 * 1024
|
||||
TIMEOUT_SECONDS = 10.0
|
||||
EXPECTED_STDOUT = "cmux 9.9.9 (999)"
|
||||
|
||||
|
||||
def resolve_cmux_cli() -> str:
|
||||
explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI")
|
||||
if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK):
|
||||
return explicit
|
||||
|
||||
candidates: list[str] = []
|
||||
candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux")))
|
||||
candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux"))
|
||||
candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)]
|
||||
if candidates:
|
||||
candidates.sort(key=os.path.getmtime, reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
in_path = shutil.which("cmux")
|
||||
if in_path:
|
||||
return in_path
|
||||
|
||||
raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.")
|
||||
|
||||
|
||||
def copy_runtime_frameworks(cli_path: str, fixture_contents: str) -> None:
|
||||
frameworks_dir = os.path.join(fixture_contents, "Frameworks")
|
||||
os.makedirs(frameworks_dir, exist_ok=True)
|
||||
|
||||
search_roots: list[str] = []
|
||||
current = os.path.dirname(cli_path)
|
||||
for _ in range(4):
|
||||
search_roots.append(os.path.join(current, "Frameworks"))
|
||||
search_roots.append(os.path.join(current, "PackageFrameworks"))
|
||||
parent = os.path.dirname(current)
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
|
||||
for search_root in search_roots:
|
||||
sentry_framework = os.path.join(search_root, "Sentry.framework")
|
||||
if os.path.isdir(sentry_framework):
|
||||
shutil.copytree(sentry_framework, os.path.join(frameworks_dir, "Sentry.framework"))
|
||||
return
|
||||
|
||||
|
||||
def build_fixture(root: str, cli_path: str) -> str:
|
||||
app_path = os.path.join(root, "cmux.app")
|
||||
contents_path = os.path.join(app_path, "Contents")
|
||||
resources_path = os.path.join(contents_path, "Resources")
|
||||
bin_path = os.path.join(resources_path, "bin")
|
||||
os.makedirs(bin_path, exist_ok=True)
|
||||
|
||||
fixture_cli = os.path.join(bin_path, "cmux")
|
||||
shutil.copy2(cli_path, fixture_cli)
|
||||
copy_runtime_frameworks(cli_path, contents_path)
|
||||
|
||||
info = {
|
||||
"CFBundleExecutable": "cmux",
|
||||
"CFBundleIdentifier": "test.cmux.version-memory-guard",
|
||||
"CFBundlePackageType": "APPL",
|
||||
"CFBundleShortVersionString": "9.9.9",
|
||||
"CFBundleVersion": "999",
|
||||
}
|
||||
with open(os.path.join(contents_path, "Info.plist"), "wb") as handle:
|
||||
plistlib.dump(info, handle)
|
||||
|
||||
for index in range(JUNK_APP_COUNT):
|
||||
open(os.path.join(resources_path, f"junk-{index:05d}.app"), "wb").close()
|
||||
|
||||
return fixture_cli
|
||||
|
||||
|
||||
def run_with_limits(cli_path: str, *args: str) -> dict[str, object]:
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_COMMIT", None)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[cli_path, *args],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
started = time.time()
|
||||
peak_rss_kb = 0
|
||||
failure_reason: str | None = None
|
||||
|
||||
while True:
|
||||
exit_code = proc.poll()
|
||||
if exit_code is not None:
|
||||
stdout, stderr = proc.communicate()
|
||||
return {
|
||||
"exit_code": exit_code,
|
||||
"stdout": stdout.strip(),
|
||||
"stderr": stderr.strip(),
|
||||
"elapsed": time.time() - started,
|
||||
"peak_rss_kb": peak_rss_kb,
|
||||
"failure_reason": None,
|
||||
}
|
||||
|
||||
try:
|
||||
rss_kb = int(
|
||||
subprocess.check_output(
|
||||
["ps", "-o", "rss=", "-p", str(proc.pid)],
|
||||
text=True,
|
||||
).strip()
|
||||
or "0"
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
rss_kb = 0
|
||||
|
||||
peak_rss_kb = max(peak_rss_kb, rss_kb)
|
||||
elapsed = time.time() - started
|
||||
|
||||
if rss_kb > RSS_LIMIT_KB:
|
||||
failure_reason = f"rss limit exceeded ({rss_kb} KB > {RSS_LIMIT_KB} KB)"
|
||||
elif elapsed > TIMEOUT_SECONDS:
|
||||
failure_reason = f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)"
|
||||
|
||||
if failure_reason:
|
||||
proc.kill()
|
||||
stdout, stderr = proc.communicate()
|
||||
return {
|
||||
"exit_code": proc.returncode,
|
||||
"stdout": stdout.strip(),
|
||||
"stderr": stderr.strip(),
|
||||
"elapsed": elapsed,
|
||||
"peak_rss_kb": peak_rss_kb,
|
||||
"failure_reason": failure_reason,
|
||||
}
|
||||
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
cli_path = resolve_cmux_cli()
|
||||
except Exception as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
return 1
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="cmux-version-memory-guard-") as root:
|
||||
fixture_cli = build_fixture(root, cli_path)
|
||||
result = run_with_limits(fixture_cli, "--version")
|
||||
|
||||
if result["failure_reason"]:
|
||||
print("FAIL: `cmux --version` exceeded runtime guard")
|
||||
print(f"reason={result['failure_reason']}")
|
||||
print(f"elapsed={result['elapsed']:.2f}s")
|
||||
print(f"peak_rss_kb={result['peak_rss_kb']}")
|
||||
print(f"stdout={result['stdout']}")
|
||||
print(f"stderr={result['stderr']}")
|
||||
return 1
|
||||
|
||||
if result["exit_code"] != 0:
|
||||
print("FAIL: `cmux --version` exited non-zero")
|
||||
print(f"exit={result['exit_code']}")
|
||||
print(f"stdout={result['stdout']}")
|
||||
print(f"stderr={result['stderr']}")
|
||||
return 1
|
||||
|
||||
if result["stdout"] != EXPECTED_STDOUT:
|
||||
print("FAIL: unexpected version output")
|
||||
print(f"stdout={result['stdout']!r}")
|
||||
print(f"expected={EXPECTED_STDOUT!r}")
|
||||
return 1
|
||||
|
||||
print(
|
||||
"PASS: `cmux --version` exits within memory/time limits "
|
||||
f"(peak_rss_kb={result['peak_rss_kb']}, elapsed={result['elapsed']:.2f}s)"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test for command-palette socket-listener restart command wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle not in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
content_view_path = repo_root / "Sources" / "ContentView.swift"
|
||||
app_delegate_path = repo_root / "Sources" / "AppDelegate.swift"
|
||||
|
||||
missing_paths = [
|
||||
str(path)
|
||||
for path in [content_view_path, app_delegate_path]
|
||||
if not path.exists()
|
||||
]
|
||||
if missing_paths:
|
||||
print("Missing expected files:")
|
||||
for path in missing_paths:
|
||||
print(f" - {path}")
|
||||
return 1
|
||||
|
||||
content_view = read_text(content_view_path)
|
||||
app_delegate = read_text(app_delegate_path)
|
||||
|
||||
failures: list[str] = []
|
||||
|
||||
require(
|
||||
content_view,
|
||||
'commandId: "palette.restartSocketListener"',
|
||||
"Missing `palette.restartSocketListener` command contribution",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content_view,
|
||||
'title: constant("Restart CLI Listener")',
|
||||
"Missing `Restart CLI Listener` command title",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content_view,
|
||||
'registry.register(commandId: "palette.restartSocketListener") {',
|
||||
"Missing command handler registration for `palette.restartSocketListener`",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content_view,
|
||||
"AppDelegate.shared?.restartSocketListener(nil)",
|
||||
"Socket restart command handler does not call `AppDelegate.restartSocketListener`",
|
||||
failures,
|
||||
)
|
||||
|
||||
require(
|
||||
app_delegate,
|
||||
"@objc func restartSocketListener(_ sender: Any?) {",
|
||||
"Missing `AppDelegate.restartSocketListener` action",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
app_delegate,
|
||||
"private func socketListenerConfigurationIfEnabled() -> (mode: SocketControlMode, path: String)? {",
|
||||
"Missing shared socket listener configuration helper",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
app_delegate,
|
||||
'restartSocketListenerIfEnabled(source: "menu.command")',
|
||||
"`restartSocketListener` no longer delegates to restart helper",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
app_delegate,
|
||||
"TerminalController.shared.stop()",
|
||||
"`restartSocketListenerIfEnabled` no longer stops current listener before restart",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
app_delegate,
|
||||
"TerminalController.shared.start(tabManager: tabManager, socketPath: config.path, accessMode: config.mode)",
|
||||
"`restartSocketListenerIfEnabled` no longer starts listener with current settings",
|
||||
failures,
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: command-palette socket restart command regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: command-palette socket restart command wiring is intact")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test for command-palette update command wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def expect_regex(content: str, pattern: str, message: str, failures: list[str]) -> None:
|
||||
if re.search(pattern, content, flags=re.DOTALL) is None:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
content_view_path = repo_root / "Sources" / "ContentView.swift"
|
||||
app_delegate_path = repo_root / "Sources" / "AppDelegate.swift"
|
||||
controller_path = repo_root / "Sources" / "Update" / "UpdateController.swift"
|
||||
|
||||
missing_paths = [
|
||||
str(path)
|
||||
for path in [content_view_path, app_delegate_path, controller_path]
|
||||
if not path.exists()
|
||||
]
|
||||
if missing_paths:
|
||||
print("Missing expected files:")
|
||||
for path in missing_paths:
|
||||
print(f" - {path}")
|
||||
return 1
|
||||
|
||||
content_view = read_text(content_view_path)
|
||||
app_delegate = read_text(app_delegate_path)
|
||||
controller = read_text(controller_path)
|
||||
|
||||
failures: list[str] = []
|
||||
|
||||
expect_regex(
|
||||
content_view,
|
||||
r'static\s+let\s+updateHasAvailable\s*=\s*"update\.hasAvailable"',
|
||||
"Missing `CommandPaletteContextKeys.updateHasAvailable`",
|
||||
failures,
|
||||
)
|
||||
expect_regex(
|
||||
content_view,
|
||||
r'if\s+case\s+\.updateAvailable\s*=\s*updateViewModel\.effectiveState\s*\{\s*snapshot\.setBool\(CommandPaletteContextKeys\.updateHasAvailable,\s*true\)\s*\}',
|
||||
"Command palette context no longer tracks update-available state",
|
||||
failures,
|
||||
)
|
||||
expect_regex(
|
||||
content_view,
|
||||
r'commandId:\s*"palette\.applyUpdateIfAvailable".*?title:\s*constant\("Apply Update \(If Available\)"\).*?keywords:\s*\[[^\]]*"apply"[^\]]*"install"[^\]]*"update"[^\]]*"available"[^\]]*\].*?when:\s*\{\s*\$0\.bool\(CommandPaletteContextKeys\.updateHasAvailable\)\s*\}',
|
||||
"Missing or incomplete `palette.applyUpdateIfAvailable` contribution visibility gating",
|
||||
failures,
|
||||
)
|
||||
expect_regex(
|
||||
content_view,
|
||||
r'commandId:\s*"palette\.attemptUpdate".*?title:\s*constant\("Attempt Update"\).*?keywords:\s*\[[^\]]*"attempt"[^\]]*"check"[^\]]*"update"[^\]]*\]',
|
||||
"Missing or incomplete `palette.attemptUpdate` contribution",
|
||||
failures,
|
||||
)
|
||||
expect_regex(
|
||||
content_view,
|
||||
r'registry\.register\(commandId:\s*"palette\.applyUpdateIfAvailable"\)\s*\{\s*AppDelegate\.shared\?\.applyUpdateIfAvailable\(nil\)\s*\}',
|
||||
"Missing handler registration for `palette.applyUpdateIfAvailable`",
|
||||
failures,
|
||||
)
|
||||
expect_regex(
|
||||
content_view,
|
||||
r'registry\.register\(commandId:\s*"palette\.attemptUpdate"\)\s*\{\s*AppDelegate\.shared\?\.attemptUpdate\(nil\)\s*\}',
|
||||
"Missing handler registration for `palette.attemptUpdate`",
|
||||
failures,
|
||||
)
|
||||
|
||||
expect_regex(
|
||||
app_delegate,
|
||||
r'@objc\s+func\s+applyUpdateIfAvailable\(_\s+sender:\s+Any\?\)\s*\{\s*updateViewModel\.overrideState\s*=\s*nil\s*updateController\.installUpdate\(\)\s*\}',
|
||||
"`AppDelegate.applyUpdateIfAvailable` is missing or does not call `updateController.installUpdate()`",
|
||||
failures,
|
||||
)
|
||||
expect_regex(
|
||||
app_delegate,
|
||||
r'@objc\s+func\s+attemptUpdate\(_\s+sender:\s+Any\?\)\s*\{\s*updateViewModel\.overrideState\s*=\s*nil\s*updateController\.attemptUpdate\(\)\s*\}',
|
||||
"`AppDelegate.attemptUpdate` is missing or does not call `updateController.attemptUpdate()`",
|
||||
failures,
|
||||
)
|
||||
|
||||
expect_regex(
|
||||
controller,
|
||||
r'func\s+attemptUpdate\(\)\s*\{',
|
||||
"`UpdateController.attemptUpdate()` is missing",
|
||||
failures,
|
||||
)
|
||||
if "state.confirm()" not in controller:
|
||||
failures.append("`UpdateController.attemptUpdate()` no longer auto-confirms update installation")
|
||||
if "checkForUpdates()" not in controller:
|
||||
failures.append("`UpdateController.attemptUpdate()` no longer triggers a check before install")
|
||||
|
||||
if failures:
|
||||
print("FAIL: command-palette update command regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: command-palette update commands expose apply + attempt wiring")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Automated test for ctrl+enter keybind using real keystrokes.
|
||||
|
||||
Requires:
|
||||
- cmux running
|
||||
- Accessibility permissions for System Events (osascript)
|
||||
- keybind = ctrl+enter=text:\\r (or \\n/\\x0d) configured in Ghostty config
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Add the directory containing cmux.py to the path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
def run_osascript(script: str) -> subprocess.CompletedProcess[str]:
|
||||
# Use capture_output so we can detect common permission failures and skip.
|
||||
result = subprocess.run(
|
||||
["osascript", "-e", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise subprocess.CalledProcessError(
|
||||
result.returncode,
|
||||
result.args,
|
||||
output=result.stdout,
|
||||
stderr=result.stderr,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def is_keystroke_permission_error(err: subprocess.CalledProcessError) -> bool:
|
||||
text = f"{getattr(err, 'stderr', '') or ''}\n{getattr(err, 'output', '') or ''}"
|
||||
return "not allowed to send keystrokes" in text or "(1002)" in text
|
||||
|
||||
|
||||
def has_ctrl_enter_keybind(config_text: str) -> bool:
|
||||
for line in config_text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
if "ctrl+enter" in stripped and "text:" in stripped:
|
||||
if "\\r" in stripped or "\\n" in stripped or "\\x0d" in stripped:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_config_with_keybind() -> Optional[Path]:
|
||||
home = Path.home()
|
||||
candidates = [
|
||||
home / "Library/Application Support/com.mitchellh.ghostty/config.ghostty",
|
||||
home / "Library/Application Support/com.mitchellh.ghostty/config",
|
||||
home / ".config/ghostty/config.ghostty",
|
||||
home / ".config/ghostty/config",
|
||||
]
|
||||
for path in candidates:
|
||||
if not path.exists():
|
||||
continue
|
||||
try:
|
||||
if has_ctrl_enter_keybind(path.read_text(encoding="utf-8")):
|
||||
return path
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def test_ctrl_enter_keybind(client: cmux) -> tuple[bool, str]:
|
||||
marker = Path("/tmp") / f"ghostty_ctrl_enter_{os.getpid()}"
|
||||
marker.unlink(missing_ok=True)
|
||||
|
||||
# Create a fresh tab to avoid interfering with existing sessions
|
||||
new_tab_id = client.new_tab()
|
||||
client.select_tab(new_tab_id)
|
||||
time.sleep(0.3)
|
||||
try:
|
||||
# Make sure the app is focused for keystrokes
|
||||
bundle_id = cmux.default_bundle_id()
|
||||
run_osascript(f'tell application id "{bundle_id}" to activate')
|
||||
time.sleep(0.2)
|
||||
|
||||
# Clear any running command
|
||||
try:
|
||||
client.send_key("ctrl-c")
|
||||
time.sleep(0.2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Type the command (without pressing Enter)
|
||||
run_osascript(f'tell application "System Events" to keystroke "touch {marker}"')
|
||||
time.sleep(0.1)
|
||||
|
||||
# Send Ctrl+Enter (key code 36 = Return)
|
||||
run_osascript('tell application "System Events" to key code 36 using control down')
|
||||
time.sleep(0.5)
|
||||
|
||||
ok = marker.exists()
|
||||
return ok, ("Ctrl+Enter keybind executed command" if ok else "Marker not created by Ctrl+Enter")
|
||||
finally:
|
||||
if marker.exists():
|
||||
marker.unlink(missing_ok=True)
|
||||
try:
|
||||
client.close_tab(new_tab_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_tests() -> int:
|
||||
print("=" * 60)
|
||||
print("cmux Ctrl+Enter Keybind Test")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
socket_path = cmux.default_socket_path()
|
||||
if not os.path.exists(socket_path):
|
||||
print(f"SKIP: Socket not found at {socket_path}")
|
||||
print("Tip: start cmux first (or set CMUX_TAG / CMUX_SOCKET_PATH).")
|
||||
return 0
|
||||
|
||||
config_path = find_config_with_keybind()
|
||||
if not config_path:
|
||||
print("SKIP: Required keybind not found in Ghostty config.")
|
||||
print("Expected a line like: keybind = ctrl+enter=text:\\r")
|
||||
return 0
|
||||
|
||||
print(f"Using keybind from: {config_path}")
|
||||
print()
|
||||
|
||||
try:
|
||||
with cmux() as client:
|
||||
ok, message = test_ctrl_enter_keybind(client)
|
||||
status = "✅" if ok else "❌"
|
||||
print(f"{status} {message}")
|
||||
return 0 if ok else 1
|
||||
except cmuxError as e:
|
||||
print(f"SKIP: {e}")
|
||||
return 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
if is_keystroke_permission_error(e):
|
||||
print("SKIP: osascript/System Events not allowed to send keystrokes (Accessibility permission missing)")
|
||||
return 0
|
||||
print(f"Error: osascript failed: {e}")
|
||||
if getattr(e, "stderr", None):
|
||||
print(e.stderr.strip())
|
||||
if getattr(e, "output", None):
|
||||
print(e.output.strip())
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run_tests())
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression checks for re-entrant terminal focus guard.
|
||||
|
||||
Guards the fix for split-drag focus churn where:
|
||||
becomeFirstResponder -> onFocus -> Workspace.focusPanel -> refocus side-effects
|
||||
could repeatedly re-enter and spike CPU.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
workspace_path = root / "Sources" / "Workspace.swift"
|
||||
workspace_source = workspace_path.read_text(encoding="utf-8")
|
||||
|
||||
required_workspace_snippets = [
|
||||
"enum FocusPanelTrigger {",
|
||||
"case terminalFirstResponder",
|
||||
"trigger: FocusPanelTrigger = .standard",
|
||||
"let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged",
|
||||
"if let targetPaneId, !shouldSuppressReentrantRefocus {",
|
||||
"reason=firstResponderAlreadyConverged",
|
||||
]
|
||||
for snippet in required_workspace_snippets:
|
||||
if snippet not in workspace_source:
|
||||
failures.append(f"Workspace focus guard missing snippet: {snippet}")
|
||||
|
||||
workspace_content_view_path = root / "Sources" / "WorkspaceContentView.swift"
|
||||
workspace_content_view_source = workspace_content_view_path.read_text(encoding="utf-8")
|
||||
focus_callback_snippet = "workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)"
|
||||
if focus_callback_snippet not in workspace_content_view_source:
|
||||
failures.append(
|
||||
"WorkspaceContentView terminal onFocus callback no longer passes .terminalFirstResponder trigger"
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: focus-panel re-entrant guard regression checks failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: focus-panel re-entrant guard is in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression guard for issue #494 (post-wake sidebar git updates freezing)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle not in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
zsh_path = repo_root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh"
|
||||
bash_path = repo_root / "Resources" / "shell-integration" / "cmux-bash-integration.bash"
|
||||
app_delegate_path = repo_root / "Sources" / "AppDelegate.swift"
|
||||
|
||||
required_paths = [zsh_path, bash_path, app_delegate_path]
|
||||
missing_paths = [str(path) for path in required_paths if not path.exists()]
|
||||
if missing_paths:
|
||||
print("Missing expected files:")
|
||||
for path in missing_paths:
|
||||
print(f" - {path}")
|
||||
return 1
|
||||
|
||||
zsh_content = read_text(zsh_path)
|
||||
bash_content = read_text(bash_path)
|
||||
app_delegate = read_text(app_delegate_path)
|
||||
|
||||
failures: list[str] = []
|
||||
|
||||
require(
|
||||
zsh_content,
|
||||
"_CMUX_GIT_JOB_STARTED_AT",
|
||||
"zsh integration is missing git probe start tracking",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
zsh_content,
|
||||
"_CMUX_PR_JOB_STARTED_AT",
|
||||
"zsh integration is missing PR probe start tracking",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
zsh_content,
|
||||
"_CMUX_ASYNC_JOB_TIMEOUT",
|
||||
"zsh integration is missing async probe timeout guard",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
zsh_content,
|
||||
"now - _CMUX_GIT_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT",
|
||||
"zsh integration no longer clears stale git probe PID after timeout",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
zsh_content,
|
||||
"now - _CMUX_PR_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT",
|
||||
"zsh integration no longer clears stale PR probe PID after timeout",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
zsh_content,
|
||||
"ncat -w 1 -U \"$CMUX_SOCKET_PATH\" --send-only",
|
||||
"zsh integration missing ncat socket timeout",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
zsh_content,
|
||||
"socat -T 1 - \"UNIX-CONNECT:$CMUX_SOCKET_PATH\"",
|
||||
"zsh integration missing socat socket timeout",
|
||||
failures,
|
||||
)
|
||||
|
||||
require(
|
||||
bash_content,
|
||||
"_CMUX_GIT_JOB_STARTED_AT",
|
||||
"bash integration is missing git probe start tracking",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
bash_content,
|
||||
"_CMUX_PR_JOB_STARTED_AT",
|
||||
"bash integration is missing PR probe start tracking",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
bash_content,
|
||||
"_CMUX_ASYNC_JOB_TIMEOUT",
|
||||
"bash integration is missing async probe timeout guard",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
bash_content,
|
||||
"now - _CMUX_GIT_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT",
|
||||
"bash integration no longer clears stale git probe PID after timeout",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
bash_content,
|
||||
"now - _CMUX_PR_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT",
|
||||
"bash integration no longer clears stale PR probe PID after timeout",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
bash_content,
|
||||
"ncat -w 1 -U \"$CMUX_SOCKET_PATH\" --send-only",
|
||||
"bash integration missing ncat socket timeout",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
bash_content,
|
||||
"socat -T 1 - \"UNIX-CONNECT:$CMUX_SOCKET_PATH\"",
|
||||
"bash integration missing socat socket timeout",
|
||||
failures,
|
||||
)
|
||||
|
||||
require(
|
||||
app_delegate,
|
||||
"NSWorkspace.didWakeNotification",
|
||||
"AppDelegate is missing wake observer for socket listener recovery",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
app_delegate,
|
||||
"restartSocketListenerIfEnabled(source: \"workspace.didWake\")",
|
||||
"Wake observer no longer re-arms the socket listener",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
app_delegate,
|
||||
"private func restartSocketListenerIfEnabled(source: String)",
|
||||
"Missing shared socket-listener restart helper",
|
||||
failures,
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: issue #494 regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: issue #494 sleep/wake recovery guards are present")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression guard for issue #582 (sidebar git branch updates stalling)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def extract_function(content: str, signature: str) -> str:
|
||||
start = content.find(signature)
|
||||
if start < 0:
|
||||
return ""
|
||||
brace = content.find("{", start)
|
||||
if brace < 0:
|
||||
return ""
|
||||
depth = 0
|
||||
for idx in range(brace, len(content)):
|
||||
ch = content[idx]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return content[start : idx + 1]
|
||||
return ""
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle not in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
terminal_controller_path = repo_root / "Sources" / "TerminalController.swift"
|
||||
if not terminal_controller_path.exists():
|
||||
print(f"Missing expected file: {terminal_controller_path}")
|
||||
return 1
|
||||
|
||||
terminal_controller = terminal_controller_path.read_text(encoding="utf-8")
|
||||
report_body = extract_function(terminal_controller, "private func reportGitBranch(_ args: String) -> String")
|
||||
clear_body = extract_function(terminal_controller, "private func clearGitBranch(_ args: String) -> String")
|
||||
|
||||
failures: list[str] = []
|
||||
|
||||
if not report_body:
|
||||
failures.append("Unable to locate reportGitBranch implementation")
|
||||
if not clear_body:
|
||||
failures.append("Unable to locate clearGitBranch implementation")
|
||||
|
||||
if report_body:
|
||||
require(
|
||||
report_body,
|
||||
"if let scope = Self.explicitSocketScope(options: parsed.options)",
|
||||
"reportGitBranch is missing explicit-scope fast path",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
report_body,
|
||||
"DispatchQueue.main.async",
|
||||
"reportGitBranch no longer schedules explicit-scope updates with main.async",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
report_body,
|
||||
"tab.updatePanelGitBranch(panelId: scope.panelId",
|
||||
"reportGitBranch fast path no longer writes branch state to the scoped panel",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
report_body,
|
||||
"DispatchQueue.main.sync",
|
||||
"reportGitBranch lost sync fallback path for non-explicit/manual calls",
|
||||
failures,
|
||||
)
|
||||
|
||||
if clear_body:
|
||||
require(
|
||||
clear_body,
|
||||
"if let scope = Self.explicitSocketScope(options: parsed.options)",
|
||||
"clearGitBranch is missing explicit-scope fast path",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
clear_body,
|
||||
"DispatchQueue.main.async",
|
||||
"clearGitBranch no longer schedules explicit-scope clears with main.async",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
clear_body,
|
||||
"tab.clearPanelGitBranch(panelId: scope.panelId)",
|
||||
"clearGitBranch fast path no longer clears branch state for the scoped panel",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
clear_body,
|
||||
"DispatchQueue.main.sync",
|
||||
"clearGitBranch lost sync fallback path for non-explicit/manual calls",
|
||||
failures,
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: issue #582 regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: issue #582 git branch socket fast path guards are present")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression guard for issue #666 (sidebar branch stuck after checkout)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle not in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
zsh_path = repo_root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh"
|
||||
bash_path = repo_root / "Resources" / "shell-integration" / "cmux-bash-integration.bash"
|
||||
|
||||
required_paths = [zsh_path, bash_path]
|
||||
missing_paths = [str(path) for path in required_paths if not path.exists()]
|
||||
if missing_paths:
|
||||
print("Missing expected files:")
|
||||
for path in missing_paths:
|
||||
print(f" - {path}")
|
||||
return 1
|
||||
|
||||
zsh_content = zsh_path.read_text(encoding="utf-8")
|
||||
bash_content = bash_path.read_text(encoding="utf-8")
|
||||
|
||||
failures: list[str] = []
|
||||
|
||||
require(
|
||||
zsh_content,
|
||||
"_CMUX_GIT_HEAD_SIGNATURE",
|
||||
"zsh integration is missing git HEAD signature tracking",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
zsh_content,
|
||||
"_cmux_git_head_signature",
|
||||
"zsh integration is missing git HEAD signature helper",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
zsh_content,
|
||||
'"$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE"',
|
||||
"zsh integration no longer compares git HEAD signatures",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
zsh_content,
|
||||
"_CMUX_GIT_FORCE=1",
|
||||
"zsh integration no longer forces git probe refresh on HEAD changes",
|
||||
failures,
|
||||
)
|
||||
|
||||
require(
|
||||
bash_content,
|
||||
"_CMUX_GIT_HEAD_SIGNATURE",
|
||||
"bash integration is missing git HEAD signature tracking",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
bash_content,
|
||||
"_cmux_git_head_signature",
|
||||
"bash integration is missing git HEAD signature helper",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
bash_content,
|
||||
"git_head_changed=1",
|
||||
"bash integration no longer flags HEAD changes for immediate refresh",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
bash_content,
|
||||
'|| "$git_head_changed" == "1"',
|
||||
"bash integration no longer restarts running git probes on HEAD change",
|
||||
failures,
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: issue #666 regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: issue #666 checkout refresh guards are present")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression guard for issue #952 (flaky CLI socket connections)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
"""Return the repository root for source inspections."""
|
||||
fallback_root = Path(__file__).resolve().parents[1]
|
||||
git_path = shutil.which("git")
|
||||
if git_path is None:
|
||||
return fallback_root
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[git_path, "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except OSError:
|
||||
return fallback_root
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return fallback_root
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str], *, regex: bool = False) -> None:
|
||||
"""Record a failure when a required source pattern is missing."""
|
||||
matched = re.search(needle, content, re.MULTILINE) is not None if regex else needle in content
|
||||
if not matched:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def collect_failures() -> list[str]:
|
||||
"""Collect missing source-level guards for the socket listener recovery fix."""
|
||||
repo_root = get_repo_root()
|
||||
terminal_controller_path = repo_root / "Sources" / "TerminalController.swift"
|
||||
app_delegate_path = repo_root / "Sources" / "AppDelegate.swift"
|
||||
failures: list[str] = []
|
||||
|
||||
missing_paths = [
|
||||
str(path) for path in [terminal_controller_path, app_delegate_path] if not path.exists()
|
||||
]
|
||||
if missing_paths:
|
||||
for path in missing_paths:
|
||||
failures.append(f"Missing expected file: {path}")
|
||||
return failures
|
||||
|
||||
terminal_controller = terminal_controller_path.read_text(encoding="utf-8")
|
||||
app_delegate = app_delegate_path.read_text(encoding="utf-8")
|
||||
|
||||
require(
|
||||
terminal_controller,
|
||||
"let socketProbePerformed: Bool",
|
||||
"Socket health snapshot no longer tracks whether connectability was probed",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
terminal_controller,
|
||||
"let socketConnectable: Bool?",
|
||||
"Socket health snapshot no longer distinguishes unprobed vs connectable sockets",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
terminal_controller,
|
||||
"let socketConnectErrno: Int32?",
|
||||
"Socket health snapshot no longer preserves probe errno",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
terminal_controller,
|
||||
"signals.append(\"socket_unreachable\")",
|
||||
"Socket health failures no longer flag unreachable listeners",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
terminal_controller,
|
||||
r"private\s+nonisolated\s+static\s+func\s+probeSocketConnectability\s*\(\s*path:\s*String\s*\)",
|
||||
"Missing active socket connectability probe helper",
|
||||
failures,
|
||||
regex=True,
|
||||
)
|
||||
require(
|
||||
terminal_controller,
|
||||
r"connect\s*\(\s*probeSocket\s*,\s*sockaddrPtr\s*,\s*socklen_t\s*\(\s*MemoryLayout<sockaddr_un>\.size\s*\)\s*\)",
|
||||
"Socket health probe no longer performs a real connect() check",
|
||||
failures,
|
||||
regex=True,
|
||||
)
|
||||
require(
|
||||
terminal_controller,
|
||||
"stage: \"bind_path_too_long\"",
|
||||
"Socket listener start no longer reports overlong Unix socket paths",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
terminal_controller,
|
||||
"Self.unixSocketPathMaxLength",
|
||||
"Socket listener path-length telemetry was removed",
|
||||
failures,
|
||||
)
|
||||
|
||||
require(
|
||||
app_delegate,
|
||||
"private static let socketListenerHealthCheckInterval: DispatchTimeInterval = .seconds(2)",
|
||||
"Socket health timer interval drifted from the fast recovery setting",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
app_delegate,
|
||||
"\"socketProbePerformed\": health.socketProbePerformed ? 1 : 0",
|
||||
"Health telemetry no longer records whether a connectability probe ran",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
app_delegate,
|
||||
"if let socketConnectable = health.socketConnectable {",
|
||||
"Health telemetry no longer gates connectability on an actual probe result",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
app_delegate,
|
||||
"data[\"socketConnectable\"] = socketConnectable ? 1 : 0",
|
||||
"Health telemetry no longer includes connectability when a probe ran",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
app_delegate,
|
||||
"if let socketConnectErrno = health.socketConnectErrno {",
|
||||
"Health telemetry no longer records connect probe errno when available",
|
||||
failures,
|
||||
)
|
||||
return failures
|
||||
|
||||
|
||||
def test_issue_952_socket_listener_recovery() -> None:
|
||||
"""Keep the source-level recovery guards for issue #952 in CI."""
|
||||
failures = collect_failures()
|
||||
assert not failures, "issue #952 regression(s) detected:\n- " + "\n- ".join(failures)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run the regression guard without requiring pytest to be installed."""
|
||||
failures = collect_failures()
|
||||
if failures:
|
||||
print("FAIL: issue #952 regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: issue #952 socket listener recovery guards are present")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Lint test to catch SwiftUI patterns that cause performance issues.
|
||||
|
||||
This test checks for:
|
||||
1. Text(_:style:) with auto-updating date styles (.time, .timer, .relative)
|
||||
These cause continuous view updates and can lead to high CPU usage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
|
||||
def get_repo_root():
|
||||
"""Get the repository root directory."""
|
||||
# Try git first
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
|
||||
# Fall back to finding GhosttyTabs directory
|
||||
cwd = Path.cwd()
|
||||
if cwd.name == "GhosttyTabs" or (cwd / "Sources").exists():
|
||||
return cwd
|
||||
if (cwd.parent / "GhosttyTabs").exists():
|
||||
return cwd.parent / "GhosttyTabs"
|
||||
|
||||
# Last resort: use current directory
|
||||
return cwd
|
||||
|
||||
|
||||
def find_swift_files(repo_root: Path) -> List[Path]:
|
||||
"""Find all Swift files in Sources directory (excluding vendored code)."""
|
||||
sources_dir = repo_root / "Sources"
|
||||
if not sources_dir.exists():
|
||||
return []
|
||||
return list(sources_dir.rglob("*.swift"))
|
||||
|
||||
|
||||
def check_autoupdating_text_styles(files: List[Path]) -> List[Tuple[Path, int, str]]:
|
||||
"""
|
||||
Check for Text(_:style:) with auto-updating date styles.
|
||||
|
||||
These patterns cause continuous SwiftUI view updates:
|
||||
- Text(date, style: .time) - updates every second/minute
|
||||
- Text(date, style: .timer) - updates continuously
|
||||
- Text(date, style: .relative) - updates periodically
|
||||
- Text(date, style: .offset) - updates periodically
|
||||
|
||||
Instead, use static formatting:
|
||||
- Text(date.formatted(date: .omitted, time: .shortened))
|
||||
"""
|
||||
violations = []
|
||||
|
||||
# Patterns that indicate auto-updating Text with Date
|
||||
# The key patterns are: Text(something, style: .time/timer/relative/offset)
|
||||
problematic_patterns = [
|
||||
"style: .time",
|
||||
"style: .timer",
|
||||
"style: .relative",
|
||||
"style: .offset",
|
||||
"style:.time",
|
||||
"style:.timer",
|
||||
"style:.relative",
|
||||
"style:.offset",
|
||||
]
|
||||
|
||||
for file_path in files:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
lines = content.split('\n')
|
||||
|
||||
for line_num, line in enumerate(lines, start=1):
|
||||
# Skip comments
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("//"):
|
||||
continue
|
||||
|
||||
for pattern in problematic_patterns:
|
||||
if pattern in line:
|
||||
violations.append((file_path, line_num, line.strip()))
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not read {file_path}: {e}", file=sys.stderr)
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def check_command_palette_caret_tint(repo_root: Path) -> List[str]:
|
||||
"""Ensure command palette text inputs keep a white caret tint."""
|
||||
content_view = repo_root / "Sources" / "ContentView.swift"
|
||||
if not content_view.exists():
|
||||
return [f"Missing expected file: {content_view}"]
|
||||
|
||||
try:
|
||||
content = content_view.read_text()
|
||||
except Exception as e:
|
||||
return [f"Could not read {content_view}: {e}"]
|
||||
|
||||
checks = [
|
||||
(
|
||||
"search input",
|
||||
r"TextField\(commandPaletteSearchPlaceholder, text: \$commandPaletteQuery\)(?P<body>.*?)"
|
||||
r"\.focused\(\$isCommandPaletteSearchFocused\)",
|
||||
),
|
||||
(
|
||||
"rename input",
|
||||
r"TextField\(target\.placeholder, text: \$commandPaletteRenameDraft\)(?P<body>.*?)"
|
||||
r"\.focused\(\$isCommandPaletteRenameFocused\)",
|
||||
),
|
||||
]
|
||||
|
||||
violations: List[str] = []
|
||||
for label, pattern in checks:
|
||||
match = re.search(pattern, content, flags=re.DOTALL)
|
||||
if not match:
|
||||
violations.append(
|
||||
f"Could not locate command palette {label} TextField block in Sources/ContentView.swift"
|
||||
)
|
||||
continue
|
||||
|
||||
body = match.group("body")
|
||||
if ".tint(.white)" not in body:
|
||||
violations.append(
|
||||
f"Command palette {label} TextField must use `.tint(.white)` in Sources/ContentView.swift"
|
||||
)
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the lint checks."""
|
||||
repo_root = get_repo_root()
|
||||
swift_files = find_swift_files(repo_root)
|
||||
|
||||
print(f"Checking {len(swift_files)} Swift files for performance issues...")
|
||||
|
||||
# Check for auto-updating Text styles
|
||||
style_violations = check_autoupdating_text_styles(swift_files)
|
||||
tint_violations = check_command_palette_caret_tint(repo_root)
|
||||
has_failures = False
|
||||
|
||||
if style_violations:
|
||||
has_failures = True
|
||||
print("\n❌ LINT FAILURES: Auto-updating Text styles found")
|
||||
print("=" * 60)
|
||||
print("These patterns cause continuous SwiftUI view updates and high CPU usage:")
|
||||
print()
|
||||
|
||||
for file_path, line_num, line in style_violations:
|
||||
rel_path = file_path.relative_to(repo_root)
|
||||
print(f" {rel_path}:{line_num}")
|
||||
print(f" {line}")
|
||||
print()
|
||||
|
||||
print("FIX: Replace with static formatting:")
|
||||
print(" Instead of: Text(date, style: .time)")
|
||||
print(" Use: Text(date.formatted(date: .omitted, time: .shortened))")
|
||||
print()
|
||||
|
||||
if tint_violations:
|
||||
has_failures = True
|
||||
print("\n❌ LINT FAILURES: Command palette caret tint drifted")
|
||||
print("=" * 60)
|
||||
print("The command palette search and rename text fields must keep a white caret:")
|
||||
print()
|
||||
for message in tint_violations:
|
||||
print(f" {message}")
|
||||
print()
|
||||
print("FIX: Set command palette TextField tint modifiers to `.white`.")
|
||||
print()
|
||||
|
||||
if has_failures:
|
||||
return 1
|
||||
|
||||
print("✅ No linted SwiftUI pattern regressions found")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression tests for markdown-open CLI parsing/help behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle not in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
cli_path = repo_root / "CLI" / "cmux.swift"
|
||||
panel_path = repo_root / "Sources" / "Panels" / "MarkdownPanel.swift"
|
||||
|
||||
if not cli_path.exists():
|
||||
print(f"FAIL: missing expected file: {cli_path}")
|
||||
return 1
|
||||
if not panel_path.exists():
|
||||
print(f"FAIL: missing expected file: {panel_path}")
|
||||
return 1
|
||||
|
||||
cli_content = cli_path.read_text(encoding="utf-8")
|
||||
panel_content = panel_path.read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
|
||||
# CLI parser behavior.
|
||||
require(
|
||||
cli_content,
|
||||
'if let first = args.first, first.lowercased() == "open" {',
|
||||
"markdown parser should explicitly support the 'open' subcommand",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
cli_content,
|
||||
"args.count == 1",
|
||||
"markdown parser should accept single-arg shorthand path",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
cli_content,
|
||||
"args.count == 1, let first = args.first, !first.hasPrefix(\"-\")",
|
||||
"markdown parser should reject option-like single args from shorthand path mode",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
cli_content,
|
||||
"let trailingArgs = Array(subArgs.dropFirst())",
|
||||
"markdown parser should validate trailing arguments",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
cli_content,
|
||||
'trailingArgs.first(where: { $0.hasPrefix("-") })',
|
||||
"markdown parser should detect unknown trailing flags",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
cli_content,
|
||||
"markdown open: unexpected argument",
|
||||
"markdown parser should error on unexpected trailing args",
|
||||
failures,
|
||||
)
|
||||
|
||||
# Help text should document shorthand and full index handle support.
|
||||
require(
|
||||
cli_content,
|
||||
"Usage: cmux markdown open <path> [options]\n cmux markdown <path> (shorthand for 'open')",
|
||||
"markdown subcommand help should include shorthand usage",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
cli_content,
|
||||
"--window <id|ref|index> Target window",
|
||||
"markdown subcommand help should document window index handles",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
cli_content,
|
||||
"markdown [open] <path> (open markdown file in formatted viewer panel with live reload)",
|
||||
"top-level help should include markdown shorthand syntax",
|
||||
failures,
|
||||
)
|
||||
|
||||
# Session restore edge case: file missing at startup should still attempt reconnect.
|
||||
require(
|
||||
panel_content,
|
||||
"if isFileUnavailable && fileWatchSource == nil {",
|
||||
"MarkdownPanel should schedule reattach when watcher cannot start at init",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
panel_content,
|
||||
"scheduleReattach(attempt: 1)",
|
||||
"MarkdownPanel should trigger reattach retries for missing files",
|
||||
failures,
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: markdown-open regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: markdown-open CLI/help/reattach regression checks are present")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test: cmux advertises media-capture access metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import plistlib
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def load_plist(path: Path, failures: list[str]) -> dict:
|
||||
if not path.exists():
|
||||
failures.append(f"Missing expected file: {path}")
|
||||
return {}
|
||||
with path.open("rb") as f:
|
||||
return plistlib.load(f)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
info = load_plist(repo_root / "Resources" / "Info.plist", failures)
|
||||
entitlements = load_plist(repo_root / "cmux.entitlements", failures)
|
||||
|
||||
mic_usage = info.get("NSMicrophoneUsageDescription")
|
||||
camera_usage = info.get("NSCameraUsageDescription")
|
||||
if not isinstance(mic_usage, str) or not mic_usage.strip():
|
||||
failures.append(
|
||||
"Resources/Info.plist must define a non-empty NSMicrophoneUsageDescription"
|
||||
)
|
||||
elif mic_usage.strip() != "A program running within cmux would like to use your microphone.":
|
||||
failures.append(
|
||||
"Resources/Info.plist NSMicrophoneUsageDescription should match the Ghostty-style wording"
|
||||
)
|
||||
|
||||
if entitlements.get("com.apple.security.device.audio-input") is not True:
|
||||
failures.append(
|
||||
"cmux.entitlements must set com.apple.security.device.audio-input to true"
|
||||
)
|
||||
|
||||
if not isinstance(camera_usage, str) or not camera_usage.strip():
|
||||
failures.append(
|
||||
"Resources/Info.plist must define a non-empty NSCameraUsageDescription"
|
||||
)
|
||||
elif camera_usage.strip() != "A program running within cmux would like to use your camera.":
|
||||
failures.append(
|
||||
"Resources/Info.plist NSCameraUsageDescription should match the Ghostty-style wording"
|
||||
)
|
||||
|
||||
if entitlements.get("com.apple.security.device.camera") is not True:
|
||||
failures.append(
|
||||
"cmux.entitlements must set com.apple.security.device.camera to true"
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: media-capture metadata regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: microphone/camera usage descriptions and entitlements are present")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test for the default sidebar active workspace indicator style.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
tab_manager = repo_root / "Sources" / "TabManager.swift"
|
||||
|
||||
if not tab_manager.exists():
|
||||
print(f"FAIL: Missing file {tab_manager}")
|
||||
return 1
|
||||
|
||||
content = tab_manager.read_text(encoding="utf-8")
|
||||
pattern = r"static let defaultStyle:\s*SidebarActiveTabIndicatorStyle\s*=\s*\.leftRail\b"
|
||||
|
||||
if re.search(pattern, content) is None:
|
||||
rel = tab_manager.relative_to(repo_root)
|
||||
print(f"FAIL: Expected default style `.leftRail` in {rel}")
|
||||
return 1
|
||||
|
||||
print("PASS: sidebar indicator default style is left rail")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression checks for terminal tiny-pane resize/overflow fixes.
|
||||
|
||||
Guards the key invariants for issue #348:
|
||||
1) Terminal portal sync must stabilize layout and clamp hosted frames to host bounds.
|
||||
2) Surface sizing must prefer live bounds over stale pending values when available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def extract_block(source: str, signature: str) -> str:
|
||||
start = source.find(signature)
|
||||
if start < 0:
|
||||
raise ValueError(f"Missing signature: {signature}")
|
||||
brace_start = source.find("{", start)
|
||||
if brace_start < 0:
|
||||
raise ValueError(f"Missing opening brace for: {signature}")
|
||||
|
||||
depth = 0
|
||||
for idx in range(brace_start, len(source)):
|
||||
char = source[idx]
|
||||
if char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[brace_start : idx + 1]
|
||||
raise ValueError(f"Unbalanced braces for: {signature}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
portal_path = root / "Sources" / "TerminalWindowPortal.swift"
|
||||
portal_source = portal_path.read_text(encoding="utf-8")
|
||||
|
||||
if "hostView.layer?.masksToBounds = true" not in portal_source:
|
||||
failures.append("WindowTerminalPortal init no longer enables hostView layer clipping")
|
||||
if "hostView.postsFrameChangedNotifications = true" not in portal_source:
|
||||
failures.append("WindowTerminalPortal init no longer enables hostView frame-change notifications")
|
||||
if "hostView.postsBoundsChangedNotifications = true" not in portal_source:
|
||||
failures.append("WindowTerminalPortal init no longer enables hostView bounds-change notifications")
|
||||
|
||||
if "private func synchronizeLayoutHierarchy()" not in portal_source:
|
||||
failures.append("WindowTerminalPortal missing synchronizeLayoutHierarchy()")
|
||||
if "private func synchronizeHostFrameToReference() -> Bool" not in portal_source:
|
||||
failures.append("WindowTerminalPortal missing synchronizeHostFrameToReference()")
|
||||
if "hostedView.reconcileGeometryNow()" not in extract_block(
|
||||
portal_source,
|
||||
"func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0)",
|
||||
):
|
||||
failures.append("bind() no longer pre-reconciles hosted geometry before attach")
|
||||
|
||||
sync_block = extract_block(portal_source, "private func synchronizeHostedView(withId hostedId: ObjectIdentifier)")
|
||||
for required in [
|
||||
"let hostBounds = hostView.bounds",
|
||||
"let clampedFrame = frameInHost.intersection(hostBounds)",
|
||||
"let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost",
|
||||
"hostedView.reconcileGeometryNow()",
|
||||
"hostedView.refreshSurfaceNow()",
|
||||
]:
|
||||
if required not in sync_block:
|
||||
failures.append(f"terminal portal sync missing: {required}")
|
||||
|
||||
if (
|
||||
"scheduleDeferredFullSynchronizeAll()" not in sync_block
|
||||
and "scheduleTransientRecoveryRetryIfNeeded(" not in sync_block
|
||||
):
|
||||
failures.append(
|
||||
"terminal portal sync no longer schedules deferred recovery for transient geometry states"
|
||||
)
|
||||
|
||||
terminal_view_path = root / "Sources" / "GhosttyTerminalView.swift"
|
||||
terminal_view_source = terminal_view_path.read_text(encoding="utf-8")
|
||||
|
||||
resolved_block = extract_block(terminal_view_source, "private func resolvedSurfaceSize(preferred size: CGSize?) -> CGSize")
|
||||
bounds_index = resolved_block.find("let currentBounds = bounds.size")
|
||||
pending_index = resolved_block.find("if let pending = pendingSurfaceSize")
|
||||
if bounds_index < 0 or pending_index < 0 or bounds_index > pending_index:
|
||||
failures.append("resolvedSurfaceSize() no longer prefers bounds before pendingSurfaceSize")
|
||||
|
||||
update_block = extract_block(terminal_view_source, "private func updateSurfaceSize(size: CGSize? = nil)")
|
||||
if "let size = resolvedSurfaceSize(preferred: size)" not in update_block:
|
||||
failures.append("updateSurfaceSize() no longer resolves size via resolvedSurfaceSize()")
|
||||
|
||||
if failures:
|
||||
print("FAIL: terminal resize/portal regression guards failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: terminal resize/portal regression guards are in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Verify update UI timing constants so update indicators are visible long enough.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TIMING_FILE = ROOT / "Sources" / "Update" / "UpdateTiming.swift"
|
||||
|
||||
|
||||
def read_constants(text: str) -> dict[str, float]:
|
||||
constants = {}
|
||||
pattern = re.compile(r"static let (\w+): TimeInterval = ([0-9.]+)")
|
||||
for match in pattern.finditer(text):
|
||||
constants[match.group(1)] = float(match.group(2))
|
||||
return constants
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not TIMING_FILE.exists():
|
||||
print(f"Missing {TIMING_FILE}")
|
||||
return 1
|
||||
|
||||
constants = read_constants(TIMING_FILE.read_text())
|
||||
required = {
|
||||
"minimumCheckDisplayDuration": 2.0,
|
||||
"noUpdateDisplayDuration": 5.0,
|
||||
}
|
||||
|
||||
failures = []
|
||||
for name, expected in required.items():
|
||||
actual = constants.get(name)
|
||||
if actual is None:
|
||||
failures.append(f"{name} missing")
|
||||
continue
|
||||
if actual != expected:
|
||||
failures.append(f"{name} = {actual} (expected {expected})")
|
||||
|
||||
if failures:
|
||||
print("Update timing test failed:")
|
||||
for failure in failures:
|
||||
print(f" - {failure}")
|
||||
return 1
|
||||
|
||||
print("Update timing test passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -26,6 +26,36 @@ export interface VersionMedia {
|
|||
}
|
||||
|
||||
export const changelogMedia: Record<string, VersionMedia> = {
|
||||
"0.62.0": {
|
||||
title: "Markdown Viewer, Browser Find, Vi Copy Mode, and Localization",
|
||||
features: [
|
||||
{
|
||||
title: "Markdown Viewer",
|
||||
description:
|
||||
"Open Markdown files in their own panel and keep them live with file watching. Notes, READMEs, and docs refresh automatically as the file changes on disk.",
|
||||
},
|
||||
{
|
||||
title: "Find in Browser",
|
||||
description:
|
||||
"Browser panels now support Cmd+F with inline find controls, so you can search long docs, dashboards, and issue threads without leaving cmux.",
|
||||
},
|
||||
{
|
||||
title: "Vi Copy Mode",
|
||||
description:
|
||||
"Terminal scrollback now has a keyboard copy mode with vi-style navigation, making it much easier to inspect and copy from large output buffers.",
|
||||
},
|
||||
{
|
||||
title: "Custom Notification Sounds",
|
||||
description:
|
||||
"Choose from bundled sounds or pick your own audio file so background task notifications are easier to notice and easier to personalize.",
|
||||
},
|
||||
{
|
||||
title: "Expanded Localization",
|
||||
description:
|
||||
"cmux now includes Japanese plus 16 additional languages, and a per-app language override lets you change the UI language without changing macOS system settings.",
|
||||
},
|
||||
],
|
||||
},
|
||||
"0.61.0": {
|
||||
title: "Tab Colors, Command Palette, Pin Workspaces",
|
||||
features: [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue