Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue-915-terminal-not-loaded
This commit is contained in:
commit
7af383c3d0
45 changed files with 5302 additions and 501 deletions
3
.github/workflows/ci-macos-compat.yml
vendored
3
.github/workflows/ci-macos-compat.yml
vendored
|
|
@ -94,6 +94,9 @@ 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
|
|
@ -122,6 +122,9 @@ 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
|
||||
|
|
@ -244,6 +247,9 @@ 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
|
||||
|
|
|
|||
50
.github/workflows/claude.yml
vendored
Normal file
50
.github/workflows/claude.yml
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
3
.github/workflows/test-depot.yml
vendored
3
.github/workflows/test-depot.yml
vendored
|
|
@ -122,6 +122,9 @@ 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: |
|
||||
|
|
|
|||
44
CLAUDE.md
44
CLAUDE.md
|
|
@ -34,10 +34,16 @@ When reporting a tagged reload result in chat, use the format for your agent typ
|
|||
|
||||
Never use `/tmp/cmux-<tag>/...` app links in chat output. If the expected DerivedData path is missing, resolve the real `.app` path and report that `file://` URL.
|
||||
|
||||
After making code changes, always run the build:
|
||||
After making code changes, always use `reload.sh --tag` to build and launch. **Never run bare `xcodebuild` or `open` an untagged `cmux DEV.app`.** Untagged builds share the default debug socket and bundle ID with other agents, causing conflicts and stealing focus.
|
||||
|
||||
```bash
|
||||
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' build
|
||||
./scripts/reload.sh --tag <your-branch-slug>
|
||||
```
|
||||
|
||||
If you only need to verify the build compiles (no launch), use a tagged derivedDataPath:
|
||||
|
||||
```bash
|
||||
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/cmux-<your-tag> build
|
||||
```
|
||||
|
||||
When rebuilding GhosttyKit.xcframework, always use Release optimizations:
|
||||
|
|
@ -107,6 +113,15 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug
|
|||
- Focus events: `focus.panel`, `focus.bonsplit`, `focus.firstResponder`, `focus.moveFocus`
|
||||
- Bonsplit events: `tab.select`, `tab.close`, `tab.dragStart`, `tab.drop`, `pane.focus`, `pane.drop`, `divider.dragStart`
|
||||
|
||||
## Regression test commit policy
|
||||
|
||||
When adding a regression test for a bug fix, use a two-commit structure so CI proves the test catches the bug:
|
||||
|
||||
1. **Commit 1:** Add the failing test only (no fix). CI should go red.
|
||||
2. **Commit 2:** Add the fix. CI should go green.
|
||||
|
||||
This makes it visible in the GitHub PR UI (Commits tab, check statuses) that the test genuinely fails without the fix.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Custom UTTypes** for drag-and-drop must be declared in `Resources/Info.plist` under `UTExportedTypeDeclarations` (e.g. `com.splittabbar.tabtransfer`, `com.cmux.sidebar-tab-reorder`).
|
||||
|
|
@ -115,6 +130,12 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug
|
|||
- **Submodule safety:** When modifying a submodule (ghostty, vendor/bonsplit, etc.), always push the submodule commit to its remote `main` branch BEFORE committing the updated pointer in the parent repo. Never commit on a detached HEAD or temporary branch — the commit will be orphaned and lost. Verify with: `cd <submodule> && git merge-base --is-ancestor HEAD origin/main`.
|
||||
- **All user-facing strings must be localized.** Use `String(localized: "key.name", defaultValue: "English text")` for every string shown in the UI (labels, buttons, menus, dialogs, tooltips, error messages). Keys go in `Resources/Localizable.xcstrings` with translations for all supported languages (currently English and Japanese). Never use bare string literals in SwiftUI `Text()`, `Button()`, alert titles, etc.
|
||||
|
||||
## Test quality policy
|
||||
|
||||
- Do not add tests that only verify source code text, method signatures, AST fragments, or grep-style patterns.
|
||||
- Tests must verify observable runtime behavior through executable paths (unit/integration/e2e/CLI), not implementation shape.
|
||||
- If a behavior cannot be exercised end-to-end yet, add a small runtime seam or harness first, then test through that seam.
|
||||
|
||||
## Socket command threading policy
|
||||
|
||||
- Do not use `DispatchQueue.main.sync` for high-frequency socket telemetry commands (`report_*`, `ports_kick`, status/progress/log metadata updates).
|
||||
|
|
@ -131,21 +152,14 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug
|
|||
- Only explicit focus-intent commands may mutate in-app focus/selection (`window.focus`, `workspace.select/next/previous/last`, `surface.focus`, `pane.focus/last`, browser focus commands, and v1 focus equivalents).
|
||||
- All non-focus commands should preserve current user focus context while still applying data/model changes.
|
||||
|
||||
## E2E mac UI tests
|
||||
## Testing policy
|
||||
|
||||
Run UI tests on the UTM macOS VM (never on the host machine). Always run e2e UI tests via `ssh cmux-vm`:
|
||||
**Never run tests locally.** All tests (E2E, UI, python socket tests) run via GitHub Actions or on the VM.
|
||||
|
||||
```bash
|
||||
ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test'
|
||||
```
|
||||
|
||||
## Basic tests
|
||||
|
||||
Run basic automated tests on the UTM macOS VM (never on the host machine):
|
||||
|
||||
```bash
|
||||
ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" build && pkill -x "cmux DEV" || true && APP=$(find /Users/cmux/Library/Developer/Xcode/DerivedData -path "*/Build/Products/Debug/cmux DEV.app" -print -quit) && open "$APP" --env CMUX_SOCKET_MODE=allowAll && for i in {1..20}; do [ -S /tmp/cmux-debug.sock ] && break; sleep 0.5; done && python3 tests/test_update_timing.py && python3 tests/test_signals_auto.py && python3 tests/test_ctrl_socket.py && python3 tests/test_notifications.py'
|
||||
```
|
||||
- **E2E / UI tests:** trigger via `gh workflow run test-e2e.yml` (see cmuxterm-hq CLAUDE.md for details)
|
||||
- **Unit tests:** `xcodebuild -scheme cmux-unit` is safe (no app launch), but prefer CI
|
||||
- **Python socket tests (tests_v2/):** these connect to a running cmux instance's socket. Never launch an untagged `cmux DEV.app` to run them. If you must test locally, use a tagged build's socket (`/tmp/cmux-debug-<tag>.sock`) with `CMUX_SOCKET=/tmp/cmux-debug-<tag>.sock`
|
||||
- **Never `open` an untagged `cmux DEV.app`** from DerivedData. It conflicts with the user's running debug instance.
|
||||
|
||||
## Ghostty submodule workflow
|
||||
|
||||
|
|
|
|||
207
CLI/cmux.swift
207
CLI/cmux.swift
|
|
@ -1736,6 +1736,45 @@ struct CMUXCLI {
|
|||
return (cwd as NSString).appendingPathComponent(expanded)
|
||||
}
|
||||
|
||||
private func sanitizedFilenameComponent(_ raw: String) -> String {
|
||||
let sanitized = raw.replacingOccurrences(
|
||||
of: #"[^\p{L}\p{N}._-]+"#,
|
||||
with: "-",
|
||||
options: .regularExpression
|
||||
)
|
||||
let trimmed = sanitized.trimmingCharacters(in: CharacterSet(charactersIn: "-."))
|
||||
return trimmed.isEmpty ? "item" : trimmed
|
||||
}
|
||||
|
||||
private func bestEffortPruneTemporaryFiles(
|
||||
in directoryURL: URL,
|
||||
keepingMostRecent maxCount: Int = 50,
|
||||
maxAge: TimeInterval = 24 * 60 * 60
|
||||
) {
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(
|
||||
at: directoryURL,
|
||||
includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
let datedEntries = entries.compactMap { url -> (url: URL, date: Date)? in
|
||||
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey]),
|
||||
values.isRegularFile == true else {
|
||||
return nil
|
||||
}
|
||||
return (url, values.contentModificationDate ?? values.creationDate ?? .distantPast)
|
||||
}.sorted { $0.date > $1.date }
|
||||
|
||||
for (index, entry) in datedEntries.enumerated() {
|
||||
if index >= maxCount || now.timeIntervalSince(entry.date) > maxAge {
|
||||
try? FileManager.default.removeItem(at: entry.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Markdown Commands
|
||||
|
||||
private func runMarkdownCommand(
|
||||
|
|
@ -2623,6 +2662,29 @@ struct CMUXCLI {
|
|||
}
|
||||
}
|
||||
|
||||
func displaySnapshotText(_ payload: [String: Any]) -> String {
|
||||
let snapshotText = (payload["snapshot"] as? String) ?? "Empty page"
|
||||
guard snapshotText.contains("\n- (empty)") else {
|
||||
return snapshotText
|
||||
}
|
||||
|
||||
let url = ((payload["url"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let readyState = ((payload["ready_state"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var lines = [snapshotText]
|
||||
|
||||
if !url.isEmpty {
|
||||
lines.append("url: \(url)")
|
||||
}
|
||||
if !readyState.isEmpty {
|
||||
lines.append("ready_state: \(readyState)")
|
||||
}
|
||||
if url.isEmpty || url == "about:blank" {
|
||||
lines.append("hint: run 'cmux browser <surface> get url' to verify navigation")
|
||||
}
|
||||
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
func displayBrowserValue(_ value: Any) -> String {
|
||||
if let dict = value as? [String: Any],
|
||||
let type = dict["__cmux_t"] as? String,
|
||||
|
|
@ -2841,10 +2903,8 @@ struct CMUXCLI {
|
|||
let payload = try client.sendV2(method: "browser.snapshot", params: params)
|
||||
if jsonOutput {
|
||||
print(jsonString(formatIDs(payload, mode: idFormat)))
|
||||
} else if let text = payload["snapshot"] as? String {
|
||||
print(text)
|
||||
} else {
|
||||
print("Empty page")
|
||||
print(displaySnapshotText(payload))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -3052,17 +3112,139 @@ struct CMUXCLI {
|
|||
if subcommand == "screenshot" {
|
||||
let sid = try requireSurface()
|
||||
let (outPathOpt, _) = parseOption(subArgs, name: "--out")
|
||||
let payload = try client.sendV2(method: "browser.screenshot", params: ["surface_id": sid])
|
||||
if let outPathOpt,
|
||||
let b64 = payload["png_base64"] as? String,
|
||||
let data = Data(base64Encoded: b64) {
|
||||
try data.write(to: URL(fileURLWithPath: outPathOpt))
|
||||
let localJSONOutput = hasFlag(subArgs, name: "--json")
|
||||
let outputAsJSON = jsonOutput || localJSONOutput
|
||||
var payload = try client.sendV2(method: "browser.screenshot", params: ["surface_id": sid])
|
||||
|
||||
func fileURL(fromPath rawPath: String) -> URL {
|
||||
let resolvedPath = resolvePath(rawPath)
|
||||
return URL(fileURLWithPath: resolvedPath).standardizedFileURL
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
print(jsonString(formatIDs(payload, mode: idFormat)))
|
||||
func writeScreenshot(_ data: Data, to destinationURL: URL) throws {
|
||||
try FileManager.default.createDirectory(
|
||||
at: destinationURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try data.write(to: destinationURL, options: .atomic)
|
||||
}
|
||||
|
||||
func hasText(_ value: String?) -> Bool {
|
||||
guard let value else { return false }
|
||||
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var screenshotPath = payload["path"] as? String
|
||||
var screenshotURL = payload["url"] as? String
|
||||
|
||||
func syncScreenshotLocationFields() {
|
||||
if !hasText(screenshotPath),
|
||||
let rawURL = screenshotURL,
|
||||
let fileURL = URL(string: rawURL),
|
||||
fileURL.isFileURL,
|
||||
!fileURL.path.isEmpty {
|
||||
screenshotPath = fileURL.path
|
||||
}
|
||||
if !hasText(screenshotURL),
|
||||
let screenshotPath,
|
||||
hasText(screenshotPath) {
|
||||
screenshotURL = URL(fileURLWithPath: screenshotPath).standardizedFileURL.absoluteString
|
||||
}
|
||||
if let screenshotPath, hasText(screenshotPath) {
|
||||
payload["path"] = screenshotPath
|
||||
}
|
||||
if let screenshotURL, hasText(screenshotURL) {
|
||||
payload["url"] = screenshotURL
|
||||
}
|
||||
}
|
||||
|
||||
func persistPayloadScreenshot(to destinationURL: URL, allowFailure: Bool) throws -> Bool {
|
||||
if let sourcePath = screenshotPath, hasText(sourcePath) {
|
||||
let sourceURL = URL(fileURLWithPath: sourcePath).standardizedFileURL
|
||||
do {
|
||||
if sourceURL.path != destinationURL.path {
|
||||
try FileManager.default.createDirectory(
|
||||
at: destinationURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try? FileManager.default.removeItem(at: destinationURL)
|
||||
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
if payload["png_base64"] == nil {
|
||||
if allowFailure {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let b64 = payload["png_base64"] as? String,
|
||||
let data = Data(base64Encoded: b64) {
|
||||
do {
|
||||
try writeScreenshot(data, to: destinationURL)
|
||||
return true
|
||||
} catch {
|
||||
if allowFailure {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if let outPathOpt {
|
||||
let outputURL = fileURL(fromPath: outPathOpt)
|
||||
guard try persistPayloadScreenshot(to: outputURL, allowFailure: false) else {
|
||||
throw CLIError(message: "browser screenshot missing image data")
|
||||
}
|
||||
screenshotPath = outputURL.path
|
||||
screenshotURL = outputURL.absoluteString
|
||||
payload["path"] = screenshotPath
|
||||
payload["url"] = screenshotURL
|
||||
} else {
|
||||
syncScreenshotLocationFields()
|
||||
if !hasText(screenshotPath) && !hasText(screenshotURL) {
|
||||
let outputDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-browser-screenshots-cli", isDirectory: true)
|
||||
if (try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)) != nil {
|
||||
bestEffortPruneTemporaryFiles(in: outputDir)
|
||||
let timestampMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
let safeSid = sanitizedFilenameComponent(sid)
|
||||
let filename = "surface-\(safeSid)-\(timestampMs)-\(String(UUID().uuidString.prefix(8))).png"
|
||||
let outputURL = outputDir.appendingPathComponent(filename, isDirectory: false)
|
||||
if (try? persistPayloadScreenshot(to: outputURL, allowFailure: true)) == true {
|
||||
screenshotPath = outputURL.path
|
||||
screenshotURL = outputURL.absoluteString
|
||||
payload["path"] = screenshotPath
|
||||
payload["url"] = screenshotURL
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if outputAsJSON {
|
||||
let formattedPayload = formatIDs(payload, mode: idFormat)
|
||||
if var outputPayload = formattedPayload as? [String: Any] {
|
||||
if hasText(screenshotPath) || hasText(screenshotURL) {
|
||||
outputPayload.removeValue(forKey: "png_base64")
|
||||
}
|
||||
print(jsonString(outputPayload))
|
||||
} else {
|
||||
print(jsonString(formattedPayload))
|
||||
}
|
||||
} else if let outPathOpt {
|
||||
print("OK \(outPathOpt)")
|
||||
} else if let screenshotURL,
|
||||
hasText(screenshotURL) {
|
||||
print("OK \(screenshotURL)")
|
||||
} else if let screenshotPath,
|
||||
hasText(screenshotPath) {
|
||||
print("OK \(screenshotPath)")
|
||||
} else {
|
||||
print("OK")
|
||||
}
|
||||
|
|
@ -5511,8 +5693,10 @@ struct CMUXCLI {
|
|||
}
|
||||
|
||||
private func jsonString(_ object: Any) -> String {
|
||||
var options: JSONSerialization.WritingOptions = [.prettyPrinted]
|
||||
options.insert(.withoutEscapingSlashes)
|
||||
guard JSONSerialization.isValidJSONObject(object),
|
||||
let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]),
|
||||
let data = try? JSONSerialization.data(withJSONObject: object, options: options),
|
||||
let output = String(data: data, encoding: .utf8) else {
|
||||
return "{}"
|
||||
}
|
||||
|
|
@ -6797,6 +6981,7 @@ struct CMUXCLI {
|
|||
browser press|keydown|keyup <key> [--snapshot-after]
|
||||
browser select <selector> <value> [--snapshot-after]
|
||||
browser scroll [--selector <css>] [--dx <n>] [--dy <n>] [--snapshot-after]
|
||||
browser screenshot [--out <path>] [--json]
|
||||
browser get <url|title|text|html|value|attr|count|box|styles> [...]
|
||||
browser is <visible|enabled|checked> <selector>
|
||||
browser find <role|text|label|placeholder|alt|title|testid|first|last|nth> ...
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ _CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}"
|
|||
_CMUX_GIT_LAST_RUN="${_CMUX_GIT_LAST_RUN:-0}"
|
||||
_CMUX_GIT_JOB_PID="${_CMUX_GIT_JOB_PID:-}"
|
||||
_CMUX_GIT_JOB_STARTED_AT="${_CMUX_GIT_JOB_STARTED_AT:-0}"
|
||||
_CMUX_GIT_HEAD_LAST_PWD="${_CMUX_GIT_HEAD_LAST_PWD:-}"
|
||||
_CMUX_GIT_HEAD_PATH="${_CMUX_GIT_HEAD_PATH:-}"
|
||||
_CMUX_GIT_HEAD_SIGNATURE="${_CMUX_GIT_HEAD_SIGNATURE:-}"
|
||||
_CMUX_PR_LAST_PWD="${_CMUX_PR_LAST_PWD:-}"
|
||||
_CMUX_PR_LAST_RUN="${_CMUX_PR_LAST_RUN:-0}"
|
||||
_CMUX_PR_JOB_PID="${_CMUX_PR_JOB_PID:-}"
|
||||
|
|
@ -51,6 +54,41 @@ _CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
|
|||
_CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}"
|
||||
_CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}"
|
||||
|
||||
_cmux_git_resolve_head_path() {
|
||||
# Resolve the HEAD file path without invoking git (fast; works for worktrees).
|
||||
local dir="$PWD"
|
||||
while :; do
|
||||
if [[ -d "$dir/.git" ]]; then
|
||||
printf '%s\n' "$dir/.git/HEAD"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$dir/.git" ]]; then
|
||||
local line gitdir
|
||||
IFS= read -r line < "$dir/.git" || line=""
|
||||
if [[ "$line" == gitdir:* ]]; then
|
||||
gitdir="${line#gitdir:}"
|
||||
gitdir="${gitdir## }"
|
||||
gitdir="${gitdir%% }"
|
||||
[[ -n "$gitdir" ]] || return 1
|
||||
[[ "$gitdir" != /* ]] && gitdir="$dir/$gitdir"
|
||||
printf '%s\n' "$gitdir/HEAD"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
[[ "$dir" == "/" || -z "$dir" ]] && break
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
_cmux_git_head_signature() {
|
||||
local head_path="$1"
|
||||
[[ -n "$head_path" && -r "$head_path" ]] || return 1
|
||||
local line
|
||||
IFS= read -r line < "$head_path" || return 1
|
||||
printf '%s\n' "$line"
|
||||
}
|
||||
|
||||
_cmux_report_tty_once() {
|
||||
# Send the TTY name to the app once per session so the batched port scanner
|
||||
# knows which TTY belongs to this panel.
|
||||
|
|
@ -62,7 +100,7 @@ _cmux_report_tty_once() {
|
|||
_CMUX_TTY_REPORTED=1
|
||||
{
|
||||
_cmux_send "report_tty $_CMUX_TTY_NAME --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
} >/dev/null 2>&1 &
|
||||
} >/dev/null 2>&1 & disown
|
||||
}
|
||||
|
||||
_cmux_ports_kick() {
|
||||
|
|
@ -74,7 +112,7 @@ _cmux_ports_kick() {
|
|||
_CMUX_PORTS_LAST_RUN=$SECONDS
|
||||
{
|
||||
_cmux_send "ports_kick --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
} >/dev/null 2>&1 &
|
||||
} >/dev/null 2>&1 & disown
|
||||
}
|
||||
|
||||
_cmux_prompt_command() {
|
||||
|
|
@ -123,7 +161,26 @@ _cmux_prompt_command() {
|
|||
{
|
||||
local qpwd="${pwd//\"/\\\"}"
|
||||
_cmux_send "report_pwd \"${qpwd}\" --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
} >/dev/null 2>&1 &
|
||||
} >/dev/null 2>&1 & disown
|
||||
fi
|
||||
|
||||
# Branch can change via aliases/tools while an older probe is still in flight.
|
||||
# Track .git/HEAD content so we can restart stale probes immediately.
|
||||
local git_head_changed=0
|
||||
if [[ "$pwd" != "$_CMUX_GIT_HEAD_LAST_PWD" ]]; then
|
||||
_CMUX_GIT_HEAD_LAST_PWD="$pwd"
|
||||
_CMUX_GIT_HEAD_PATH="$(_cmux_git_resolve_head_path 2>/dev/null || true)"
|
||||
_CMUX_GIT_HEAD_SIGNATURE=""
|
||||
fi
|
||||
if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then
|
||||
local head_signature
|
||||
head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)"
|
||||
if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
|
||||
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
|
||||
git_head_changed=1
|
||||
# Also invalidate the PR probe so it refreshes with the new branch.
|
||||
_CMUX_PR_LAST_RUN=0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Git branch/dirty can change without a directory change (e.g. `git checkout`),
|
||||
|
|
@ -131,7 +188,7 @@ _cmux_prompt_command() {
|
|||
# When pwd changes (cd into a different repo), kill the old probe and start fresh
|
||||
# so the sidebar picks up the new branch immediately.
|
||||
if [[ -n "$_CMUX_GIT_JOB_PID" ]] && kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then
|
||||
if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]]; then
|
||||
if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" || "$git_head_changed" == "1" ]]; then
|
||||
kill "$_CMUX_GIT_JOB_PID" >/dev/null 2>&1 || true
|
||||
_CMUX_GIT_JOB_PID=""
|
||||
_CMUX_GIT_JOB_STARTED_AT=0
|
||||
|
|
@ -154,20 +211,21 @@ _cmux_prompt_command() {
|
|||
fi
|
||||
} >/dev/null 2>&1 &
|
||||
_CMUX_GIT_JOB_PID=$!
|
||||
disown
|
||||
_CMUX_GIT_JOB_STARTED_AT=$now
|
||||
fi
|
||||
|
||||
# Pull request metadata (number/state/url):
|
||||
# refresh on cwd change and periodically to avoid stale status.
|
||||
# refresh on cwd change, HEAD change, and periodically to avoid stale status.
|
||||
if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
|
||||
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then
|
||||
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" || "$git_head_changed" == "1" ]]; then
|
||||
kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true
|
||||
_CMUX_PR_JOB_PID=""
|
||||
_CMUX_PR_JOB_STARTED_AT=0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then
|
||||
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" || "$git_head_changed" == "1" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then
|
||||
if [[ -z "$_CMUX_PR_JOB_PID" ]] || ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
|
||||
_CMUX_PR_LAST_PWD="$pwd"
|
||||
_CMUX_PR_LAST_RUN=$now
|
||||
|
|
@ -197,6 +255,7 @@ _cmux_prompt_command() {
|
|||
fi
|
||||
} >/dev/null 2>&1 &
|
||||
_CMUX_PR_JOB_PID=$!
|
||||
disown
|
||||
_CMUX_PR_JOB_STARTED_AT=$now
|
||||
fi
|
||||
fi
|
||||
|
|
@ -205,6 +264,7 @@ _cmux_prompt_command() {
|
|||
if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then
|
||||
_cmux_ports_kick
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
_cmux_install_prompt_command() {
|
||||
|
|
|
|||
|
|
@ -45,9 +45,8 @@ typeset -g _CMUX_GIT_JOB_STARTED_AT=0
|
|||
typeset -g _CMUX_GIT_FORCE=0
|
||||
typeset -g _CMUX_GIT_HEAD_LAST_PWD=""
|
||||
typeset -g _CMUX_GIT_HEAD_PATH=""
|
||||
typeset -g _CMUX_GIT_HEAD_MTIME=0
|
||||
typeset -g _CMUX_GIT_HEAD_SIGNATURE=""
|
||||
typeset -g _CMUX_GIT_HEAD_WATCH_PID=""
|
||||
typeset -g _CMUX_HAVE_ZSTAT=0
|
||||
typeset -g _CMUX_PR_LAST_PWD=""
|
||||
typeset -g _CMUX_PR_LAST_RUN=0
|
||||
typeset -g _CMUX_PR_JOB_PID=""
|
||||
|
|
@ -60,19 +59,6 @@ typeset -g _CMUX_CMD_START=0
|
|||
typeset -g _CMUX_TTY_NAME=""
|
||||
typeset -g _CMUX_TTY_REPORTED=0
|
||||
|
||||
_cmux_ensure_zstat() {
|
||||
# zstat is substantially cheaper than spawning external `stat`.
|
||||
if (( _CMUX_HAVE_ZSTAT != 0 )); then
|
||||
return 0
|
||||
fi
|
||||
if zmodload -F zsh/stat b:zstat 2>/dev/null; then
|
||||
_CMUX_HAVE_ZSTAT=1
|
||||
return 0
|
||||
fi
|
||||
_CMUX_HAVE_ZSTAT=-1
|
||||
return 1
|
||||
}
|
||||
|
||||
_cmux_git_resolve_head_path() {
|
||||
# Resolve the HEAD file path without invoking git (fast; works for worktrees).
|
||||
local dir="$PWD"
|
||||
|
|
@ -100,27 +86,15 @@ _cmux_git_resolve_head_path() {
|
|||
return 1
|
||||
}
|
||||
|
||||
_cmux_git_head_mtime() {
|
||||
_cmux_git_head_signature() {
|
||||
local head_path="$1"
|
||||
[[ -n "$head_path" && -f "$head_path" ]] || { print -r -- 0; return 0; }
|
||||
|
||||
if _cmux_ensure_zstat; then
|
||||
typeset -A st
|
||||
if zstat -H st +mtime -- "$head_path" 2>/dev/null; then
|
||||
print -r -- "${st[mtime]:-0}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback for environments where zsh/stat isn't available.
|
||||
if command -v stat >/dev/null 2>&1; then
|
||||
local mtime
|
||||
mtime="$(stat -f %m "$head_path" 2>/dev/null || stat -c %Y "$head_path" 2>/dev/null || echo 0)"
|
||||
print -r -- "$mtime"
|
||||
[[ -n "$head_path" && -r "$head_path" ]] || return 1
|
||||
local line=""
|
||||
if IFS= read -r line < "$head_path"; then
|
||||
print -r -- "$line"
|
||||
return 0
|
||||
fi
|
||||
|
||||
print -r -- 0
|
||||
return 1
|
||||
}
|
||||
|
||||
_cmux_report_tty_once() {
|
||||
|
|
@ -184,23 +158,23 @@ _cmux_start_git_head_watch() {
|
|||
watch_head_path="$(_cmux_git_resolve_head_path 2>/dev/null || true)"
|
||||
[[ -n "$watch_head_path" ]] || return 0
|
||||
|
||||
local watch_head_mtime
|
||||
watch_head_mtime="$(_cmux_git_head_mtime "$watch_head_path" 2>/dev/null || echo 0)"
|
||||
local watch_head_signature
|
||||
watch_head_signature="$(_cmux_git_head_signature "$watch_head_path" 2>/dev/null || true)"
|
||||
|
||||
_CMUX_GIT_HEAD_LAST_PWD="$watch_pwd"
|
||||
_CMUX_GIT_HEAD_PATH="$watch_head_path"
|
||||
_CMUX_GIT_HEAD_MTIME="$watch_head_mtime"
|
||||
_CMUX_GIT_HEAD_SIGNATURE="$watch_head_signature"
|
||||
|
||||
_cmux_stop_git_head_watch
|
||||
{
|
||||
local last_mtime="$watch_head_mtime"
|
||||
local last_signature="$watch_head_signature"
|
||||
while true; do
|
||||
sleep 1
|
||||
|
||||
local mtime
|
||||
mtime="$(_cmux_git_head_mtime "$watch_head_path" 2>/dev/null || echo 0)"
|
||||
if [[ -n "$mtime" && "$mtime" != 0 && "$mtime" != "$last_mtime" ]]; then
|
||||
last_mtime="$mtime"
|
||||
local signature
|
||||
signature="$(_cmux_git_head_signature "$watch_head_path" 2>/dev/null || true)"
|
||||
if [[ -n "$signature" && "$signature" != "$last_signature" ]]; then
|
||||
last_signature="$signature"
|
||||
_cmux_report_git_branch_for_path "$watch_pwd"
|
||||
fi
|
||||
done
|
||||
|
|
@ -299,13 +273,13 @@ _cmux_precmd() {
|
|||
if [[ "$pwd" != "$_CMUX_GIT_HEAD_LAST_PWD" ]]; then
|
||||
_CMUX_GIT_HEAD_LAST_PWD="$pwd"
|
||||
_CMUX_GIT_HEAD_PATH="$(_cmux_git_resolve_head_path 2>/dev/null || true)"
|
||||
_CMUX_GIT_HEAD_MTIME=0
|
||||
_CMUX_GIT_HEAD_SIGNATURE=""
|
||||
fi
|
||||
if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then
|
||||
local head_mtime
|
||||
head_mtime="$(_cmux_git_head_mtime "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || echo 0)"
|
||||
if [[ -n "$head_mtime" && "$head_mtime" != 0 && "$head_mtime" != "$_CMUX_GIT_HEAD_MTIME" ]]; then
|
||||
_CMUX_GIT_HEAD_MTIME="$head_mtime"
|
||||
local head_signature
|
||||
head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)"
|
||||
if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
|
||||
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
|
||||
# Treat HEAD file change like a git command — force-replace any
|
||||
# running probe so the sidebar picks up the new branch immediately.
|
||||
_CMUX_GIT_FORCE=1
|
||||
|
|
|
|||
|
|
@ -1005,14 +1005,31 @@ func shouldDispatchBrowserReturnViaFirstResponderKeyDown(
|
|||
func shouldToggleMainWindowFullScreenForCommandControlFShortcut(
|
||||
flags: NSEvent.ModifierFlags,
|
||||
chars: String,
|
||||
keyCode: UInt16
|
||||
keyCode: UInt16,
|
||||
layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:)
|
||||
) -> Bool {
|
||||
let normalizedFlags = flags
|
||||
.intersection(.deviceIndependentFlagsMask)
|
||||
.subtracting([.numericPad, .function, .capsLock])
|
||||
guard normalizedFlags == [.command, .control] else { return false }
|
||||
let normalizedChars = chars.lowercased()
|
||||
return normalizedChars == "f" || keyCode == 3
|
||||
if normalizedChars == "f" {
|
||||
return true
|
||||
}
|
||||
let charsAreControlSequence = !normalizedChars.isEmpty
|
||||
&& normalizedChars.unicodeScalars.allSatisfy { CharacterSet.controlCharacters.contains($0) }
|
||||
if !normalizedChars.isEmpty && !charsAreControlSequence {
|
||||
return false
|
||||
}
|
||||
|
||||
// Fallback to layout translation only when characters are unavailable (for
|
||||
// synthetic/key-equivalent paths that can report an empty string).
|
||||
if let translatedCharacter = layoutCharacterProvider(keyCode, flags), !translatedCharacter.isEmpty {
|
||||
return translatedCharacter == "f"
|
||||
}
|
||||
|
||||
// Keep ANSI fallback as a final safety net when layout translation is unavailable.
|
||||
return keyCode == 3
|
||||
}
|
||||
|
||||
func commandPaletteSelectionDeltaForKeyboardNavigation(
|
||||
|
|
@ -1449,6 +1466,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
weak var sidebarState: SidebarState?
|
||||
weak var fullscreenControlsViewModel: TitlebarControlsViewModel?
|
||||
weak var sidebarSelectionState: SidebarSelectionState?
|
||||
var shortcutLayoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:)
|
||||
private var workspaceObserver: NSObjectProtocol?
|
||||
private var lifecycleSnapshotObservers: [NSObjectProtocol] = []
|
||||
private var windowKeyObserver: NSObjectProtocol?
|
||||
|
|
@ -1559,7 +1577,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
private var isApplyingStartupSessionRestore = false
|
||||
private var sessionAutosaveTimer: DispatchSourceTimer?
|
||||
private var socketListenerHealthTimer: DispatchSourceTimer?
|
||||
private static let socketListenerHealthCheckInterval: DispatchTimeInterval = .seconds(5)
|
||||
private var socketListenerHealthCheckInFlight = false
|
||||
private static let socketListenerHealthCheckInterval: DispatchTimeInterval = .seconds(2)
|
||||
private var lastSocketListenerUnhealthyCaptureAt: Date = .distantPast
|
||||
private static let socketListenerUnhealthyCaptureCooldown: TimeInterval = 60
|
||||
private let sessionPersistenceQueue = DispatchQueue(
|
||||
|
|
@ -1819,7 +1838,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
let tab = tabManager.tabs.first(where: { $0.id == tabId }) {
|
||||
tab.triggerNotificationFocusFlash(panelId: surfaceId, requiresSplit: false, shouldFocus: false)
|
||||
}
|
||||
notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId)
|
||||
}
|
||||
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
|
|
@ -2491,25 +2509,57 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
private func stopSocketListenerHealthMonitor() {
|
||||
socketListenerHealthTimer?.cancel()
|
||||
socketListenerHealthTimer = nil
|
||||
socketListenerHealthCheckInFlight = false
|
||||
}
|
||||
|
||||
private func restartSocketListenerIfNeededForHealthCheck(source: String) {
|
||||
guard let config = socketListenerConfigurationIfEnabled() else { return }
|
||||
let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: config.path)
|
||||
guard !socketListenerHealthCheckInFlight,
|
||||
let config = socketListenerConfigurationIfEnabled() else { return }
|
||||
let expectedSocketPath = config.path
|
||||
let terminalController = TerminalController.shared
|
||||
socketListenerHealthCheckInFlight = true
|
||||
Thread.detachNewThread { [weak self, expectedSocketPath, source, terminalController] in
|
||||
let health = terminalController.socketListenerHealth(expectedSocketPath: expectedSocketPath)
|
||||
Task { @MainActor [weak self, health] in
|
||||
guard let self else { return }
|
||||
self.socketListenerHealthCheckInFlight = false
|
||||
self.handleSocketListenerHealthCheckResult(
|
||||
health,
|
||||
source: source,
|
||||
expectedSocketPath: expectedSocketPath
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSocketListenerHealthCheckResult(
|
||||
_ health: TerminalController.SocketListenerHealth,
|
||||
source: String,
|
||||
expectedSocketPath: String
|
||||
) {
|
||||
guard let config = socketListenerConfigurationIfEnabled(),
|
||||
config.path == expectedSocketPath else { return }
|
||||
guard !health.isHealthy else {
|
||||
lastSocketListenerUnhealthyCaptureAt = .distantPast
|
||||
return
|
||||
}
|
||||
let failureSignals = health.failureSignals
|
||||
let data: [String: Any] = [
|
||||
var data: [String: Any] = [
|
||||
"source": source,
|
||||
"path": config.path,
|
||||
"isRunning": health.isRunning ? 1 : 0,
|
||||
"acceptLoopAlive": health.acceptLoopAlive ? 1 : 0,
|
||||
"socketPathMatches": health.socketPathMatches ? 1 : 0,
|
||||
"socketPathExists": health.socketPathExists ? 1 : 0,
|
||||
"socketProbePerformed": health.socketProbePerformed ? 1 : 0,
|
||||
"failureSignals": failureSignals
|
||||
]
|
||||
if let socketConnectable = health.socketConnectable {
|
||||
data["socketConnectable"] = socketConnectable ? 1 : 0
|
||||
}
|
||||
if let socketConnectErrno = health.socketConnectErrno {
|
||||
data["socketConnectErrno"] = Int(socketConnectErrno)
|
||||
}
|
||||
sentryBreadcrumb("socket.listener.unhealthy", category: "socket", data: data)
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastSocketListenerUnhealthyCaptureAt) >= Self.socketListenerUnhealthyCaptureCooldown {
|
||||
|
|
@ -5482,6 +5532,60 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
}
|
||||
|
||||
private func isGotoSplitUITestRecordingEnabled() -> Bool {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
return env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" || env["CMUX_UI_TEST_GOTO_SPLIT_RECORD_ONLY"] == "1"
|
||||
}
|
||||
|
||||
private func gotoSplitUITestDataPath() -> String? {
|
||||
guard isGotoSplitUITestRecordingEnabled() else { return nil }
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard let path = env["CMUX_UI_TEST_GOTO_SPLIT_PATH"], !path.isEmpty else { return nil }
|
||||
return path
|
||||
}
|
||||
|
||||
private func gotoSplitFindStateSnapshot(for workspace: Workspace) -> [String: String] {
|
||||
var updates: [String: String] = [
|
||||
"focusedPaneId": workspace.bonsplitController.focusedPaneId?.description ?? ""
|
||||
]
|
||||
|
||||
if let focusedPanelId = workspace.focusedPanelId {
|
||||
updates["focusedPanelId"] = focusedPanelId.uuidString
|
||||
if let terminal = workspace.terminalPanel(for: focusedPanelId) {
|
||||
updates["focusedPanelKind"] = "terminal"
|
||||
updates["focusedTerminalFindNeedle"] = terminal.searchState?.needle ?? ""
|
||||
updates["focusedBrowserFindNeedle"] = ""
|
||||
} else if let browser = workspace.browserPanel(for: focusedPanelId) {
|
||||
updates["focusedPanelKind"] = "browser"
|
||||
updates["focusedBrowserFindNeedle"] = browser.searchState?.needle ?? ""
|
||||
updates["focusedTerminalFindNeedle"] = ""
|
||||
} else {
|
||||
updates["focusedPanelKind"] = "other"
|
||||
updates["focusedTerminalFindNeedle"] = ""
|
||||
updates["focusedBrowserFindNeedle"] = ""
|
||||
}
|
||||
} else {
|
||||
updates["focusedPanelId"] = ""
|
||||
updates["focusedPanelKind"] = "none"
|
||||
updates["focusedTerminalFindNeedle"] = ""
|
||||
updates["focusedBrowserFindNeedle"] = ""
|
||||
}
|
||||
|
||||
let terminalWithFind = workspace.panels.values
|
||||
.compactMap { $0 as? TerminalPanel }
|
||||
.first(where: { $0.searchState != nil })
|
||||
updates["terminalFindPanelId"] = terminalWithFind?.id.uuidString ?? ""
|
||||
updates["terminalFindNeedle"] = terminalWithFind?.searchState?.needle ?? ""
|
||||
|
||||
let browserWithFind = workspace.panels.values
|
||||
.compactMap { $0 as? BrowserPanel }
|
||||
.first(where: { $0.searchState != nil })
|
||||
updates["browserFindPanelId"] = browserWithFind?.id.uuidString ?? ""
|
||||
updates["browserFindNeedle"] = browserWithFind?.searchState?.needle ?? ""
|
||||
|
||||
return updates
|
||||
}
|
||||
|
||||
private func focusWebViewForGotoSplitUITest(tab: Workspace, browserPanelId: UUID, attempt: Int = 0) {
|
||||
let maxAttempts = 120
|
||||
guard attempt < maxAttempts else {
|
||||
|
|
@ -5603,10 +5707,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
|
||||
private func recordGotoSplitMoveIfNeeded(direction: NavigationDirection) {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" else { return }
|
||||
guard let tabManager,
|
||||
let focusedPaneId = tabManager.selectedWorkspace?.bonsplitController.focusedPaneId else { return }
|
||||
guard isGotoSplitUITestRecordingEnabled() else { return }
|
||||
guard let tabManager, let workspace = tabManager.selectedWorkspace else { return }
|
||||
|
||||
let directionValue: String
|
||||
switch direction {
|
||||
|
|
@ -5620,15 +5722,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
directionValue = "down"
|
||||
}
|
||||
|
||||
writeGotoSplitTestData([
|
||||
"lastMoveDirection": directionValue,
|
||||
"focusedPaneId": focusedPaneId.description
|
||||
])
|
||||
var updates = gotoSplitFindStateSnapshot(for: workspace)
|
||||
updates["lastMoveDirection"] = directionValue
|
||||
writeGotoSplitTestData(updates)
|
||||
}
|
||||
|
||||
private func recordGotoSplitSplitIfNeeded(direction: SplitDirection) {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" else { return }
|
||||
guard isGotoSplitUITestRecordingEnabled() else { return }
|
||||
guard let workspace = tabManager?.selectedWorkspace else { return }
|
||||
|
||||
let directionValue: String
|
||||
|
|
@ -5643,16 +5743,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
directionValue = "down"
|
||||
}
|
||||
|
||||
writeGotoSplitTestData([
|
||||
"lastSplitDirection": directionValue,
|
||||
"paneCountAfterSplit": String(workspace.bonsplitController.allPaneIds.count),
|
||||
"focusedPaneId": workspace.bonsplitController.focusedPaneId?.description ?? ""
|
||||
])
|
||||
var updates = gotoSplitFindStateSnapshot(for: workspace)
|
||||
updates["lastSplitDirection"] = directionValue
|
||||
updates["paneCountAfterSplit"] = String(workspace.bonsplitController.allPaneIds.count)
|
||||
writeGotoSplitTestData(updates)
|
||||
}
|
||||
|
||||
private func writeGotoSplitTestData(_ updates: [String: String]) {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard let path = env["CMUX_UI_TEST_GOTO_SPLIT_PATH"], !path.isEmpty else { return }
|
||||
guard let path = gotoSplitUITestDataPath() else { return }
|
||||
var payload = loadGotoSplitTestData(at: path)
|
||||
for (key, value) in updates {
|
||||
payload[key] = value
|
||||
|
|
@ -5792,6 +5890,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func dismissNotificationsPopoverIfShown() -> Bool {
|
||||
titlebarAccessoryController.dismissNotificationsPopoverIfShown()
|
||||
}
|
||||
|
||||
func jumpToLatestUnread() {
|
||||
guard let notificationStore else { return }
|
||||
#if DEBUG
|
||||
|
|
@ -6103,7 +6206,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
private func handleCustomShortcut(event: NSEvent) -> Bool {
|
||||
// `charactersIgnoringModifiers` can be nil for some synthetic NSEvents and certain special keys.
|
||||
// Most shortcuts below use keyCode fallbacks, so treat nil as "" rather than bailing out.
|
||||
// Treat nil as "" and rely on keyCode/layout-aware fallback logic where needed.
|
||||
let chars = (event.charactersIgnoringModifiers ?? "").lowercased()
|
||||
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
let hasControl = flags.contains(.control)
|
||||
|
|
@ -6140,7 +6243,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
// Special-case: Cmd+D should confirm destructive close on alerts.
|
||||
// XCUITest key events often hit the app-level local monitor first, so forward the key
|
||||
// equivalent to the alert panel explicitly.
|
||||
if flags == [.command], chars == "d",
|
||||
if matchShortcut(
|
||||
event: event,
|
||||
shortcut: StoredShortcut(key: "d", command: true, shift: false, option: false, control: false)
|
||||
),
|
||||
let root = closeConfirmationPanel.contentView,
|
||||
let closeButton = findButton(in: root, titled: "Close") {
|
||||
closeButton.performClick(nil)
|
||||
|
|
@ -6315,15 +6421,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
// focused omnibar in another window does not suppress Cmd+P here.
|
||||
let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil
|
||||
let isCommandP = !hasFocusedAddressBarInShortcutContext
|
||||
&& normalizedFlags == [.command]
|
||||
&& (chars == "p" || event.keyCode == 35)
|
||||
&& matchShortcut(
|
||||
event: event,
|
||||
shortcut: StoredShortcut(key: "p", command: true, shift: false, option: false, control: false)
|
||||
)
|
||||
if isCommandP {
|
||||
let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
||||
requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.cmdP")
|
||||
return true
|
||||
}
|
||||
|
||||
let isCommandShiftP = normalizedFlags == [.command, .shift] && (chars == "p" || event.keyCode == 35)
|
||||
let isCommandShiftP = matchShortcut(
|
||||
event: event,
|
||||
shortcut: StoredShortcut(key: "p", command: true, shift: true, option: false, control: false)
|
||||
)
|
||||
if isCommandShiftP {
|
||||
let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
||||
requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.cmdShiftP")
|
||||
|
|
@ -6339,11 +6450,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return true
|
||||
}
|
||||
|
||||
if normalizedFlags == [.command], chars == "q" {
|
||||
if matchShortcut(
|
||||
event: event,
|
||||
shortcut: StoredShortcut(key: "q", command: true, shift: false, option: false, control: false)
|
||||
) {
|
||||
return handleQuitShortcutWarning()
|
||||
}
|
||||
if normalizedFlags == [.command, .shift],
|
||||
(chars == "," || chars == "<" || event.keyCode == 43) {
|
||||
if matchShortcut(
|
||||
event: event,
|
||||
shortcut: StoredShortcut(key: ",", command: true, shift: true, option: false, control: false)
|
||||
) {
|
||||
GhosttyApp.shared.reloadConfiguration(source: "shortcut.cmd_shift_comma")
|
||||
return true
|
||||
}
|
||||
|
|
@ -6563,7 +6679,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
)
|
||||
}
|
||||
|
||||
if normalizedFlags == [.command, .option], (chars == "t" || event.keyCode == 17) {
|
||||
if matchShortcut(
|
||||
event: event,
|
||||
shortcut: StoredShortcut(key: "t", command: true, shift: false, option: true, control: false)
|
||||
) {
|
||||
if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow,
|
||||
targetWindow.identifier?.rawValue == "cmux.settings" {
|
||||
targetWindow.performClose(nil)
|
||||
|
|
@ -6584,7 +6703,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
// Cmd+W must close the focused panel even if first-responder momentarily lags on a
|
||||
// browser NSTextView during split focus transitions.
|
||||
if normalizedFlags == [.command], (chars == "w" || event.keyCode == 13) {
|
||||
if matchShortcut(
|
||||
event: event,
|
||||
shortcut: StoredShortcut(key: "w", command: true, shift: false, option: false, control: false)
|
||||
) {
|
||||
if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow,
|
||||
targetWindow.identifier?.rawValue == "cmux.settings" {
|
||||
targetWindow.performClose(nil)
|
||||
|
|
@ -6813,7 +6935,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
|
||||
// Focus browser address bar: Cmd+L
|
||||
if flags == [.command] && chars == "l" {
|
||||
if matchShortcut(
|
||||
event: event,
|
||||
shortcut: StoredShortcut(key: "l", command: true, shift: false, option: false, control: false)
|
||||
) {
|
||||
if let focusedPanel = tabManager?.focusedBrowserPanel {
|
||||
focusBrowserAddressBar(in: focusedPanel)
|
||||
return true
|
||||
|
|
@ -7401,42 +7526,127 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return false
|
||||
}
|
||||
|
||||
/// Match a shortcut against an event, handling normal keys
|
||||
/// Match a shortcut against an event, handling normal keys.
|
||||
private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool {
|
||||
// Some keys can include extra flags (e.g. .function) depending on the responder chain.
|
||||
// Strip those for consistent matching across first responders (terminal, WebKit, etc).
|
||||
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
.subtracting([.numericPad, .function])
|
||||
.subtracting([.numericPad, .function, .capsLock])
|
||||
guard flags == shortcut.modifierFlags else { return false }
|
||||
|
||||
// NSEvent.charactersIgnoringModifiers preserves Shift for some symbol keys
|
||||
// (e.g. Shift+] can yield "}" instead of "]"), so match brackets by keyCode.
|
||||
let shortcutKey = shortcut.key.lowercased()
|
||||
if shortcutKey == "\r" {
|
||||
return event.keyCode == 36 || event.keyCode == 76
|
||||
}
|
||||
if shortcutKey == "[" || shortcutKey == "]" {
|
||||
switch event.keyCode {
|
||||
case 33: // kVK_ANSI_LeftBracket
|
||||
return shortcutKey == "["
|
||||
case 30: // kVK_ANSI_RightBracket
|
||||
return shortcutKey == "]"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Control-key combos can produce control characters (e.g. Ctrl+H => backspace),
|
||||
// so fall back to keyCode matching for common printable keys.
|
||||
if let chars = event.charactersIgnoringModifiers?.lowercased(), chars == shortcutKey {
|
||||
let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers
|
||||
if shortcutCharacterMatches(
|
||||
eventCharacter: eventCharsIgnoringModifiers,
|
||||
shortcutKey: shortcutKey,
|
||||
applyShiftSymbolNormalization: flags.contains(.shift),
|
||||
eventKeyCode: event.keyCode
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) {
|
||||
|
||||
// For command-based shortcuts, trust AppKit's layout-aware characters when present.
|
||||
// Keep this strict for letter shortcuts to avoid physical-key collisions across layouts,
|
||||
// while still allowing keyCode fallback for digit/punctuation shortcuts on non-US layouts.
|
||||
let hasEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true)
|
||||
if hasEventChars,
|
||||
flags.contains(.command),
|
||||
!flags.contains(.control),
|
||||
shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Match using the current keyboard layout so Command shortcuts stay character-based
|
||||
// across layouts (QWERTY, Dvorak, etc.) instead of being tied to ANSI physical keys.
|
||||
let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags)
|
||||
if shortcutCharacterMatches(
|
||||
eventCharacter: layoutCharacter,
|
||||
shortcutKey: shortcutKey,
|
||||
applyShiftSymbolNormalization: false,
|
||||
eventKeyCode: event.keyCode
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Control-key combos can surface as ASCII control characters (e.g. Ctrl+H => backspace),
|
||||
// so keep ANSI keyCode fallback for control-modified shortcuts. Also allow fallback for
|
||||
// command punctuation shortcuts, since some non-US layouts report different characters
|
||||
// for the same physical key even when menu-equivalent semantics should still apply.
|
||||
let allowANSIKeyCodeFallback = flags.contains(.control)
|
||||
|| (flags.contains(.command)
|
||||
&& !flags.contains(.control)
|
||||
&& (
|
||||
!shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey)
|
||||
|| (!hasEventChars && (layoutCharacter?.isEmpty ?? true))
|
||||
))
|
||||
if allowANSIKeyCodeFallback, let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) {
|
||||
return event.keyCode == expectedKeyCode
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func shouldRequireCharacterMatchForCommandShortcut(shortcutKey: String) -> Bool {
|
||||
guard shortcutKey.count == 1, let scalar = shortcutKey.unicodeScalars.first else {
|
||||
return false
|
||||
}
|
||||
return CharacterSet.letters.contains(scalar)
|
||||
}
|
||||
|
||||
private func shortcutCharacterMatches(
|
||||
eventCharacter: String?,
|
||||
shortcutKey: String,
|
||||
applyShiftSymbolNormalization: Bool,
|
||||
eventKeyCode: UInt16
|
||||
) -> Bool {
|
||||
guard let eventCharacter, !eventCharacter.isEmpty else { return false }
|
||||
if normalizedShortcutEventCharacter(
|
||||
eventCharacter,
|
||||
applyShiftSymbolNormalization: applyShiftSymbolNormalization,
|
||||
eventKeyCode: eventKeyCode
|
||||
) == shortcutKey {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func normalizedShortcutEventCharacter(
|
||||
_ eventCharacter: String,
|
||||
applyShiftSymbolNormalization: Bool,
|
||||
eventKeyCode: UInt16
|
||||
) -> String {
|
||||
let lowered = eventCharacter.lowercased()
|
||||
guard applyShiftSymbolNormalization else { return lowered }
|
||||
|
||||
switch lowered {
|
||||
case "{": return "["
|
||||
case "}": return "]"
|
||||
case "<": return eventKeyCode == 43 ? "," : lowered // kVK_ANSI_Comma
|
||||
case ">": return eventKeyCode == 47 ? "." : lowered // kVK_ANSI_Period
|
||||
case "?": return "/"
|
||||
case ":": return ";"
|
||||
case "\"": return "'"
|
||||
case "|": return "\\"
|
||||
case "~": return "`"
|
||||
case "+": return "="
|
||||
case "_": return "-"
|
||||
case "!": return eventKeyCode == 18 ? "1" : lowered // kVK_ANSI_1
|
||||
case "@": return eventKeyCode == 19 ? "2" : lowered // kVK_ANSI_2
|
||||
case "#": return eventKeyCode == 20 ? "3" : lowered // kVK_ANSI_3
|
||||
case "$": return eventKeyCode == 21 ? "4" : lowered // kVK_ANSI_4
|
||||
case "%": return eventKeyCode == 23 ? "5" : lowered // kVK_ANSI_5
|
||||
case "^": return eventKeyCode == 22 ? "6" : lowered // kVK_ANSI_6
|
||||
case "&": return eventKeyCode == 26 ? "7" : lowered // kVK_ANSI_7
|
||||
case "*": return eventKeyCode == 28 ? "8" : lowered // kVK_ANSI_8
|
||||
case "(": return eventKeyCode == 25 ? "9" : lowered // kVK_ANSI_9
|
||||
case ")": return eventKeyCode == 29 ? "0" : lowered // kVK_ANSI_0
|
||||
default: return lowered
|
||||
}
|
||||
}
|
||||
|
||||
private func keyCodeForShortcutKey(_ key: String) -> UInt16? {
|
||||
// Matches macOS ANSI key codes. This is intentionally limited to keys we
|
||||
// support in StoredShortcut/ghostty trigger translation.
|
||||
|
|
@ -7470,8 +7680,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
case "-": return 27 // kVK_ANSI_Minus
|
||||
case "8": return 28 // kVK_ANSI_8
|
||||
case "0": return 29 // kVK_ANSI_0
|
||||
case "]": return 30 // kVK_ANSI_RightBracket
|
||||
case "o": return 31 // kVK_ANSI_O
|
||||
case "u": return 32 // kVK_ANSI_U
|
||||
case "[": return 33 // kVK_ANSI_LeftBracket
|
||||
case "i": return 34 // kVK_ANSI_I
|
||||
case "p": return 35 // kVK_ANSI_P
|
||||
case "l": return 37 // kVK_ANSI_L
|
||||
|
|
@ -7943,16 +8155,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
)
|
||||
#endif
|
||||
|
||||
if let notificationId, let store = notificationStore {
|
||||
markReadIfFocused(
|
||||
notificationId: notificationId,
|
||||
tabId: tabId,
|
||||
surfaceId: surfaceId,
|
||||
tabManager: context.tabManager,
|
||||
notificationStore: store
|
||||
)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
recordMultiWindowNotificationFocusIfNeeded(
|
||||
windowId: context.windowId,
|
||||
|
|
@ -8006,15 +8208,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
)
|
||||
#endif
|
||||
|
||||
if let notificationId, let store = notificationStore {
|
||||
markReadIfFocused(
|
||||
notificationId: notificationId,
|
||||
tabId: tabId,
|
||||
surfaceId: surfaceId,
|
||||
tabManager: tabManager,
|
||||
notificationStore: store
|
||||
)
|
||||
}
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" {
|
||||
writeJumpUnreadTestData(["jumpUnreadOpenInFallback": "1", "jumpUnreadOpenResult": "1"])
|
||||
|
|
@ -8074,22 +8267,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
|
||||
}
|
||||
|
||||
private func markReadIfFocused(
|
||||
notificationId: UUID,
|
||||
tabId: UUID,
|
||||
surfaceId: UUID?,
|
||||
tabManager: TabManager,
|
||||
notificationStore: TerminalNotificationStore
|
||||
) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
guard tabManager.selectedTabId == tabId else { return }
|
||||
if let surfaceId {
|
||||
guard tabManager.focusedSurfaceId(for: tabId) == surfaceId else { return }
|
||||
}
|
||||
notificationStore.markRead(id: notificationId)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func recordMultiWindowNotificationOpenFailureIfNeeded(
|
||||
tabId: UUID,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -631,7 +631,12 @@ final class FileDropOverlayView: NSView {
|
|||
}
|
||||
|
||||
/// Hit-tests the window to find a WKWebView (browser panel) under the cursor.
|
||||
private func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? {
|
||||
func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? {
|
||||
if let window,
|
||||
let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint(windowPoint, in: window) {
|
||||
return portalWebView
|
||||
}
|
||||
|
||||
guard let window, let contentView = window.contentView else { return nil }
|
||||
isHidden = true
|
||||
defer { isHidden = false }
|
||||
|
|
@ -5707,7 +5712,7 @@ struct VerticalTabsSidebar: View {
|
|||
@Binding var selection: SidebarSelection
|
||||
@Binding var selectedTabIds: Set<UUID>
|
||||
@Binding var lastSidebarSelectionIndex: Int?
|
||||
@StateObject private var commandKeyMonitor = SidebarCommandKeyMonitor()
|
||||
@StateObject private var modifierKeyMonitor = SidebarShortcutHintModifierMonitor()
|
||||
@StateObject private var dragAutoScrollController = SidebarDragAutoScrollController()
|
||||
@StateObject private var dragFailsafeMonitor = SidebarDragFailsafeMonitor()
|
||||
@State private var draggedTabId: UUID?
|
||||
|
|
@ -5735,7 +5740,7 @@ struct VerticalTabsSidebar: View {
|
|||
selection: $selection,
|
||||
selectedTabIds: $selectedTabIds,
|
||||
lastSidebarSelectionIndex: $lastSidebarSelectionIndex,
|
||||
showsCommandShortcutHints: commandKeyMonitor.isCommandPressed,
|
||||
showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed,
|
||||
dragAutoScrollController: dragAutoScrollController,
|
||||
draggedTabId: $draggedTabId,
|
||||
dropIndicator: $dropIndicator
|
||||
|
|
@ -5791,12 +5796,12 @@ struct VerticalTabsSidebar: View {
|
|||
.background(SidebarBackdrop().ignoresSafeArea())
|
||||
.background(
|
||||
WindowAccessor { window in
|
||||
commandKeyMonitor.setHostWindow(window)
|
||||
modifierKeyMonitor.setHostWindow(window)
|
||||
}
|
||||
.frame(width: 0, height: 0)
|
||||
)
|
||||
.onAppear {
|
||||
commandKeyMonitor.start()
|
||||
modifierKeyMonitor.start()
|
||||
draggedTabId = nil
|
||||
dropIndicator = nil
|
||||
SidebarDragLifecycleNotification.postStateDidChange(
|
||||
|
|
@ -5805,7 +5810,7 @@ struct VerticalTabsSidebar: View {
|
|||
)
|
||||
}
|
||||
.onDisappear {
|
||||
commandKeyMonitor.stop()
|
||||
modifierKeyMonitor.stop()
|
||||
dragAutoScrollController.stop()
|
||||
dragFailsafeMonitor.stop()
|
||||
draggedTabId = nil
|
||||
|
|
@ -5849,15 +5854,18 @@ struct VerticalTabsSidebar: View {
|
|||
}
|
||||
}
|
||||
|
||||
enum SidebarCommandHintPolicy {
|
||||
enum ShortcutHintModifierPolicy {
|
||||
static let intentionalHoldDelay: TimeInterval = 0.30
|
||||
|
||||
static func shouldShowHints(
|
||||
for modifierFlags: NSEvent.ModifierFlags,
|
||||
defaults: UserDefaults = .standard
|
||||
) -> Bool {
|
||||
ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults) &&
|
||||
modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command]
|
||||
let normalized = modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
guard normalized == [.command] else {
|
||||
return false
|
||||
}
|
||||
return ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults)
|
||||
}
|
||||
|
||||
static func isCurrentWindow(
|
||||
|
|
@ -5922,6 +5930,11 @@ enum ShortcutHintDebugSettings {
|
|||
}
|
||||
return defaults.bool(forKey: showHintsOnCommandHoldKey)
|
||||
}
|
||||
|
||||
static func resetVisibilityDefaults(defaults: UserDefaults = .standard) {
|
||||
defaults.set(defaultAlwaysShowHints, forKey: alwaysShowHintsKey)
|
||||
defaults.set(defaultShowHintsOnCommandHold, forKey: showHintsOnCommandHoldKey)
|
||||
}
|
||||
}
|
||||
|
||||
enum SidebarDragLifecycleNotification {
|
||||
|
|
@ -6139,8 +6152,8 @@ private struct SidebarExternalDropDelegate: DropDelegate {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
private final class SidebarCommandKeyMonitor: ObservableObject {
|
||||
@Published private(set) var isCommandPressed = false
|
||||
private final class SidebarShortcutHintModifierMonitor: ObservableObject {
|
||||
@Published private(set) var isModifierPressed = false
|
||||
|
||||
private weak var hostWindow: NSWindow?
|
||||
private var hostWindowDidBecomeKeyObserver: NSObjectProtocol?
|
||||
|
|
@ -6234,7 +6247,7 @@ private final class SidebarCommandKeyMonitor: ObservableObject {
|
|||
}
|
||||
|
||||
private func isCurrentWindow(eventWindow: NSWindow?) -> Bool {
|
||||
SidebarCommandHintPolicy.isCurrentWindow(
|
||||
ShortcutHintModifierPolicy.isCurrentWindow(
|
||||
hostWindowNumber: hostWindow?.windowNumber,
|
||||
hostWindowIsKey: hostWindow?.isKeyWindow ?? false,
|
||||
eventWindowNumber: eventWindow?.windowNumber,
|
||||
|
|
@ -6243,7 +6256,7 @@ private final class SidebarCommandKeyMonitor: ObservableObject {
|
|||
}
|
||||
|
||||
private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) {
|
||||
guard SidebarCommandHintPolicy.shouldShowHints(
|
||||
guard ShortcutHintModifierPolicy.shouldShowHints(
|
||||
for: modifierFlags,
|
||||
hostWindowNumber: hostWindow?.windowNumber,
|
||||
hostWindowIsKey: hostWindow?.isKeyWindow ?? false,
|
||||
|
|
@ -6258,31 +6271,31 @@ private final class SidebarCommandKeyMonitor: ObservableObject {
|
|||
}
|
||||
|
||||
private func queueHintShow() {
|
||||
guard !isCommandPressed else { return }
|
||||
guard !isModifierPressed else { return }
|
||||
guard pendingShowWorkItem == nil else { return }
|
||||
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pendingShowWorkItem = nil
|
||||
guard SidebarCommandHintPolicy.shouldShowHints(
|
||||
guard ShortcutHintModifierPolicy.shouldShowHints(
|
||||
for: NSEvent.modifierFlags,
|
||||
hostWindowNumber: self.hostWindow?.windowNumber,
|
||||
hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false,
|
||||
eventWindowNumber: nil,
|
||||
keyWindowNumber: NSApp.keyWindow?.windowNumber
|
||||
) else { return }
|
||||
self.isCommandPressed = true
|
||||
self.isModifierPressed = true
|
||||
}
|
||||
|
||||
pendingShowWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + SidebarCommandHintPolicy.intentionalHoldDelay, execute: workItem)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + ShortcutHintModifierPolicy.intentionalHoldDelay, execute: workItem)
|
||||
}
|
||||
|
||||
private func cancelPendingHintShow(resetVisible: Bool) {
|
||||
pendingShowWorkItem?.cancel()
|
||||
pendingShowWorkItem = nil
|
||||
if resetVisible {
|
||||
isCommandPressed = false
|
||||
isModifierPressed = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -6520,7 +6533,7 @@ private struct TabItemView: View {
|
|||
@Binding var selection: SidebarSelection
|
||||
@Binding var selectedTabIds: Set<UUID>
|
||||
@Binding var lastSidebarSelectionIndex: Int?
|
||||
let showsCommandShortcutHints: Bool
|
||||
let showsModifierShortcutHints: Bool
|
||||
let dragAutoScrollController: SidebarDragAutoScrollController
|
||||
@Binding var draggedTabId: UUID?
|
||||
@Binding var dropIndicator: SidebarDropIndicator?
|
||||
|
|
@ -6623,7 +6636,7 @@ private struct TabItemView: View {
|
|||
}
|
||||
|
||||
private var showCloseButton: Bool {
|
||||
isHovering && tabManager.tabs.count > 1 && !(showsCommandShortcutHints || alwaysShowShortcutHints)
|
||||
isHovering && tabManager.tabs.count > 1 && !(showsModifierShortcutHints || alwaysShowShortcutHints)
|
||||
}
|
||||
|
||||
private var workspaceShortcutLabel: String? {
|
||||
|
|
@ -6632,7 +6645,7 @@ private struct TabItemView: View {
|
|||
}
|
||||
|
||||
private var showsWorkspaceShortcutHint: Bool {
|
||||
(showsCommandShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil
|
||||
(showsModifierShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil
|
||||
}
|
||||
|
||||
private var workspaceHintSlotWidth: CGFloat {
|
||||
|
|
@ -6748,7 +6761,7 @@ private struct TabItemView: View {
|
|||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.14), value: showsCommandShortcutHints || alwaysShowShortcutHints)
|
||||
.animation(.easeInOut(duration: 0.14), value: showsModifierShortcutHints || alwaysShowShortcutHints)
|
||||
.frame(width: workspaceHintSlotWidth, height: 16, alignment: .trailing)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,11 +14,21 @@ struct BrowserSearchOverlay: View {
|
|||
|
||||
private let padding: CGFloat = 8
|
||||
|
||||
private func requestSearchFieldFocus(maxAttempts: Int = 3) {
|
||||
guard maxAttempts > 0 else { return }
|
||||
isSearchFieldFocused = true
|
||||
guard maxAttempts > 1 else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
requestSearchFieldFocus(maxAttempts: maxAttempts - 1)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
HStack(spacing: 4) {
|
||||
TextField("Search", text: $searchState.needle)
|
||||
.textFieldStyle(.plain)
|
||||
.accessibilityIdentifier("BrowserFindSearchTextField")
|
||||
.frame(width: 180)
|
||||
.padding(.leading, 8)
|
||||
.padding(.trailing, 50)
|
||||
|
|
@ -95,13 +105,13 @@ struct BrowserSearchOverlay: View {
|
|||
#if DEBUG
|
||||
dlog("browser.findbar.appear panel=\(panelId.uuidString.prefix(5))")
|
||||
#endif
|
||||
isSearchFieldFocused = true
|
||||
requestSearchFieldFocus()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .browserSearchFocus)) { notification in
|
||||
guard let notifiedPanelId = notification.object as? UUID,
|
||||
notifiedPanelId == panelId else { return }
|
||||
DispatchQueue.main.async {
|
||||
isSearchFieldFocused = true
|
||||
requestSearchFieldFocus()
|
||||
}
|
||||
}
|
||||
.background(
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ struct SurfaceSearchOverlay: View {
|
|||
onNavigateSearch(action)
|
||||
}
|
||||
)
|
||||
.accessibilityIdentifier("TerminalFindSearchTextField")
|
||||
.frame(width: 180)
|
||||
.padding(.leading, 8)
|
||||
.padding(.trailing, 50)
|
||||
|
|
@ -303,6 +304,7 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable {
|
|||
let field = SearchNativeTextField(frame: .zero)
|
||||
field.font = .systemFont(ofSize: NSFont.systemFontSize)
|
||||
field.placeholderString = String(localized: "search.placeholder", defaultValue: "Search")
|
||||
field.setAccessibilityIdentifier("TerminalFindSearchTextField")
|
||||
field.delegate = context.coordinator
|
||||
field.stringValue = text
|
||||
context.coordinator.parentField = field
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import AppKit
|
||||
import Carbon
|
||||
|
||||
class KeyboardLayout {
|
||||
|
|
@ -12,8 +13,12 @@ class KeyboardLayout {
|
|||
return nil
|
||||
}
|
||||
|
||||
/// Translate a physical keyCode to the unmodified character under the current keyboard layout.
|
||||
static func character(forKeyCode keyCode: UInt16) -> String? {
|
||||
/// Translate a physical keyCode to the character AppKit would use for shortcut matching,
|
||||
/// preserving command-aware layouts such as "Dvorak - QWERTY Command".
|
||||
static func character(
|
||||
forKeyCode keyCode: UInt16,
|
||||
modifierFlags: NSEvent.ModifierFlags = []
|
||||
) -> String? {
|
||||
guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(),
|
||||
let layoutDataPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else {
|
||||
return nil
|
||||
|
|
@ -31,7 +36,7 @@ class KeyboardLayout {
|
|||
keyboardLayout,
|
||||
keyCode,
|
||||
UInt16(kUCKeyActionDisplay),
|
||||
0,
|
||||
translationModifierKeyState(for: modifierFlags),
|
||||
UInt32(LMGetKbdType()),
|
||||
UInt32(kUCKeyTranslateNoDeadKeysBit),
|
||||
&deadKeyState,
|
||||
|
|
@ -43,4 +48,20 @@ class KeyboardLayout {
|
|||
guard status == noErr, length > 0 else { return nil }
|
||||
return String(utf16CodeUnits: chars, count: length).lowercased()
|
||||
}
|
||||
|
||||
private static func translationModifierKeyState(for modifierFlags: NSEvent.ModifierFlags) -> UInt32 {
|
||||
let normalized = modifierFlags
|
||||
.intersection(.deviceIndependentFlagsMask)
|
||||
.intersection([.shift, .command])
|
||||
|
||||
var carbonModifiers: Int = 0
|
||||
if normalized.contains(.shift) {
|
||||
carbonModifiers |= shiftKey
|
||||
}
|
||||
if normalized.contains(.command) {
|
||||
carbonModifiers |= cmdKey
|
||||
}
|
||||
|
||||
return UInt32((carbonModifiers >> 8) & 0xFF)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1595,10 +1595,8 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
Task { @MainActor [weak self] in
|
||||
self?.refreshFavicon(from: webView)
|
||||
self?.applyBrowserThemeModeIfNeeded()
|
||||
// Clear find-in-page on navigation so stale highlights don't persist.
|
||||
if self?.searchState != nil {
|
||||
self?.searchState = nil
|
||||
}
|
||||
// Keep find-in-page open through load completion and refresh matches for the new DOM.
|
||||
self?.restoreFindStateAfterNavigation(replaySearch: true)
|
||||
}
|
||||
}
|
||||
navDelegate.didFailNavigation = { [weak self] _, failedURL in
|
||||
|
|
@ -1609,10 +1607,8 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
self.pageTitle = failedURL.isEmpty ? "" : failedURL
|
||||
self.faviconPNGData = nil
|
||||
self.lastFaviconURLString = nil
|
||||
// Clear find-in-page so stale highlights don't persist.
|
||||
if self.searchState != nil {
|
||||
self.searchState = nil
|
||||
}
|
||||
// Keep find-in-page open and clear stale counters on failed loads.
|
||||
self.restoreFindStateAfterNavigation(replaySearch: false)
|
||||
}
|
||||
}
|
||||
navDelegate.openInNewTab = { [weak self] url in
|
||||
|
|
@ -2645,6 +2641,18 @@ extension BrowserPanel {
|
|||
if searchState == nil {
|
||||
searchState = BrowserSearchState()
|
||||
}
|
||||
postBrowserSearchFocusNotification()
|
||||
// Focus notification can race with portal overlay mount. Re-post on the
|
||||
// next runloop and shortly after so the find field can claim first responder.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.postBrowserSearchFocusNotification()
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
||||
self?.postBrowserSearchFocusNotification()
|
||||
}
|
||||
}
|
||||
|
||||
private func postBrowserSearchFocusNotification() {
|
||||
NotificationCenter.default.post(name: .browserSearchFocus, object: id)
|
||||
}
|
||||
|
||||
|
|
@ -2668,6 +2676,16 @@ extension BrowserPanel {
|
|||
searchState = nil
|
||||
}
|
||||
|
||||
private func restoreFindStateAfterNavigation(replaySearch: Bool) {
|
||||
guard let state = searchState else { return }
|
||||
state.total = nil
|
||||
state.selected = nil
|
||||
if replaySearch, !state.needle.isEmpty {
|
||||
executeFindSearch(state.needle)
|
||||
}
|
||||
postBrowserSearchFocusNotification()
|
||||
}
|
||||
|
||||
private func executeFindSearch(_ needle: String) {
|
||||
guard !needle.isEmpty else {
|
||||
executeFindClear()
|
||||
|
|
@ -2743,6 +2761,9 @@ extension BrowserPanel {
|
|||
if suppressWebViewFocusForAddressBar {
|
||||
return true
|
||||
}
|
||||
if searchState != nil {
|
||||
return true
|
||||
}
|
||||
if let until = suppressWebViewFocusUntil {
|
||||
return Date() < until
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,6 +211,7 @@ struct BrowserPanelView: View {
|
|||
let portalPriority: Int
|
||||
let onRequestPanelFocus: () -> Void
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.paneDropZone) private var paneDropZone
|
||||
@State private var omnibarState = OmnibarState()
|
||||
@State private var addressBarFocused: Bool = false
|
||||
@AppStorage(BrowserSearchSettings.searchEngineKey) private var searchEngineRaw = BrowserSearchSettings.defaultSearchEngine.rawValue
|
||||
|
|
@ -317,7 +318,10 @@ struct BrowserPanelView: View {
|
|||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay {
|
||||
if let searchState = panel.searchState {
|
||||
// Keep Cmd+F usable when the browser is still in the empty new-tab
|
||||
// state (no WKWebView mounted yet). WebView-backed cases are hosted
|
||||
// in AppKit by WebViewRepresentable to avoid layering/clipping issues.
|
||||
if !panel.shouldRenderWebView, let searchState = panel.searchState {
|
||||
BrowserSearchOverlay(
|
||||
panelId: panel.id,
|
||||
searchState: searchState,
|
||||
|
|
@ -734,10 +738,12 @@ struct BrowserPanelView: View {
|
|||
if panel.shouldRenderWebView {
|
||||
WebViewRepresentable(
|
||||
panel: panel,
|
||||
browserSearchState: panel.searchState,
|
||||
shouldAttachWebView: isVisibleInUI,
|
||||
shouldFocusWebView: isFocused && !addressBarFocused,
|
||||
isPanelFocused: isFocused,
|
||||
portalZPriority: portalPriority
|
||||
portalZPriority: portalPriority,
|
||||
paneDropZone: paneDropZone
|
||||
)
|
||||
// Keep the host stable for normal pane churn, but force a remount when
|
||||
// BrowserPanel replaces its underlying WKWebView after process termination.
|
||||
|
|
@ -3032,10 +3038,12 @@ private struct OmnibarSuggestionsView: View {
|
|||
/// NSViewRepresentable wrapper for WKWebView
|
||||
struct WebViewRepresentable: NSViewRepresentable {
|
||||
let panel: BrowserPanel
|
||||
let browserSearchState: BrowserSearchState?
|
||||
let shouldAttachWebView: Bool
|
||||
let shouldFocusWebView: Bool
|
||||
let isPanelFocused: Bool
|
||||
let portalZPriority: Int
|
||||
let paneDropZone: DropZone?
|
||||
|
||||
final class Coordinator {
|
||||
weak var panel: BrowserPanel?
|
||||
|
|
@ -3044,6 +3052,7 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
var desiredPortalVisibleInUI: Bool = true
|
||||
var desiredPortalZPriority: Int = 0
|
||||
var lastPortalHostId: ObjectIdentifier?
|
||||
var searchOverlayHostingView: NSHostingView<BrowserSearchOverlay>?
|
||||
}
|
||||
|
||||
private final class HostContainerView: NSView {
|
||||
|
|
@ -3196,6 +3205,67 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
host.onGeometryChanged = nil
|
||||
}
|
||||
|
||||
private static func removeSearchOverlay(from coordinator: Coordinator) {
|
||||
coordinator.searchOverlayHostingView?.removeFromSuperview()
|
||||
coordinator.searchOverlayHostingView = nil
|
||||
}
|
||||
|
||||
private static func updateSearchOverlay(
|
||||
panel: BrowserPanel,
|
||||
coordinator: Coordinator,
|
||||
containerView: NSView?
|
||||
) {
|
||||
// Layering contract: keep browser Cmd+F UI in the portal-hosted AppKit layer.
|
||||
// SwiftUI panel overlays can be covered by portal-hosted WKWebView content.
|
||||
guard let searchState = panel.searchState,
|
||||
let containerView else {
|
||||
removeSearchOverlay(from: coordinator)
|
||||
return
|
||||
}
|
||||
|
||||
let rootView = BrowserSearchOverlay(
|
||||
panelId: panel.id,
|
||||
searchState: searchState,
|
||||
onNext: { [weak panel] in
|
||||
panel?.findNext()
|
||||
},
|
||||
onPrevious: { [weak panel] in
|
||||
panel?.findPrevious()
|
||||
},
|
||||
onClose: { [weak panel] in
|
||||
panel?.hideFind()
|
||||
}
|
||||
)
|
||||
|
||||
if let overlay = coordinator.searchOverlayHostingView {
|
||||
overlay.rootView = rootView
|
||||
if overlay.superview !== containerView {
|
||||
overlay.removeFromSuperview()
|
||||
containerView.addSubview(overlay, positioned: .above, relativeTo: nil)
|
||||
NSLayoutConstraint.activate([
|
||||
overlay.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
overlay.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
||||
overlay.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
overlay.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
])
|
||||
} else if containerView.subviews.last !== overlay {
|
||||
containerView.addSubview(overlay, positioned: .above, relativeTo: nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let overlay = NSHostingView(rootView: rootView)
|
||||
overlay.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(overlay, positioned: .above, relativeTo: nil)
|
||||
NSLayoutConstraint.activate([
|
||||
overlay.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
overlay.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
||||
overlay.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
overlay.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
])
|
||||
coordinator.searchOverlayHostingView = overlay
|
||||
}
|
||||
|
||||
private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) {
|
||||
guard let host = nsView as? HostContainerView else { return }
|
||||
|
||||
|
|
@ -3206,6 +3276,7 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
coordinator.desiredPortalZPriority = portalZPriority
|
||||
coordinator.attachGeneration += 1
|
||||
let generation = coordinator.attachGeneration
|
||||
let paneDropContext = shouldAttachWebView ? currentPaneDropContext() : nil
|
||||
|
||||
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator] in
|
||||
guard let host, let webView, let coordinator else { return }
|
||||
|
|
@ -3217,7 +3288,15 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
visibleInUI: coordinator.desiredPortalVisibleInUI,
|
||||
zPriority: coordinator.desiredPortalZPriority
|
||||
)
|
||||
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: paneDropContext)
|
||||
coordinator.lastPortalHostId = ObjectIdentifier(host)
|
||||
if let panel = coordinator.panel {
|
||||
Self.updateSearchOverlay(
|
||||
panel: panel,
|
||||
coordinator: coordinator,
|
||||
containerView: webView.superview
|
||||
)
|
||||
}
|
||||
}
|
||||
host.onGeometryChanged = { [weak host, weak coordinator] in
|
||||
guard let host, let coordinator else { return }
|
||||
|
|
@ -3249,6 +3328,11 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
coordinator.lastPortalHostId = hostId
|
||||
}
|
||||
BrowserWindowPortalRegistry.synchronizeForAnchor(host)
|
||||
Self.updateSearchOverlay(
|
||||
panel: panel,
|
||||
coordinator: coordinator,
|
||||
containerView: webView.superview
|
||||
)
|
||||
} else {
|
||||
// Bind is deferred until host moves into a window. Keep the current
|
||||
// portal entry's desired state in sync so stale callbacks cannot keep
|
||||
|
|
@ -3258,8 +3342,18 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
visibleInUI: coordinator.desiredPortalVisibleInUI,
|
||||
zPriority: coordinator.desiredPortalZPriority
|
||||
)
|
||||
Self.removeSearchOverlay(from: coordinator)
|
||||
}
|
||||
|
||||
BrowserWindowPortalRegistry.updateDropZoneOverlay(
|
||||
for: webView,
|
||||
zone: shouldAttachWebView ? paneDropZone : nil
|
||||
)
|
||||
BrowserWindowPortalRegistry.updatePaneDropContext(
|
||||
for: webView,
|
||||
context: paneDropContext
|
||||
)
|
||||
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
|
||||
#if DEBUG
|
||||
|
|
@ -3277,6 +3371,7 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
let webView = panel.webView
|
||||
let coordinator = context.coordinator
|
||||
if let previousWebView = coordinator.webView, previousWebView !== webView {
|
||||
Self.removeSearchOverlay(from: coordinator)
|
||||
BrowserWindowPortalRegistry.detach(webView: previousWebView)
|
||||
coordinator.lastPortalHostId = nil
|
||||
}
|
||||
|
|
@ -3348,6 +3443,7 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
|
||||
coordinator.attachGeneration += 1
|
||||
clearPortalCallbacks(for: nsView)
|
||||
removeSearchOverlay(from: coordinator)
|
||||
|
||||
guard let webView = coordinator.webView else { return }
|
||||
let panel = coordinator.panel
|
||||
|
|
@ -3372,7 +3468,24 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
window.makeFirstResponder(nil)
|
||||
}
|
||||
}
|
||||
BrowserWindowPortalRegistry.detach(webView: webView)
|
||||
|
||||
// SwiftUI can transiently dismantle/rebuild the browser host view during split
|
||||
// rearrangement. Do not detach the portal-hosted WKWebView here; explicit detach
|
||||
// still happens on real web view replacement and panel teardown.
|
||||
BrowserWindowPortalRegistry.updateDropZoneOverlay(for: webView, zone: nil)
|
||||
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: nil)
|
||||
coordinator.lastPortalHostId = nil
|
||||
}
|
||||
|
||||
private func currentPaneDropContext() -> BrowserPaneDropContext? {
|
||||
guard let workspace = AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == panel.workspaceId }),
|
||||
let paneId = workspace.paneId(forPanelId: panel.id) else {
|
||||
return nil
|
||||
}
|
||||
return BrowserPaneDropContext(
|
||||
workspaceId: panel.workspaceId,
|
||||
panelId: panel.id,
|
||||
paneId: paneId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1113,6 +1113,11 @@ final class CmuxWebView: WKWebView {
|
|||
NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder"),
|
||||
]
|
||||
|
||||
static func shouldRejectInternalPaneDrag(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool {
|
||||
DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes)
|
||||
|| DragOverlayRoutingPolicy.hasSidebarTabReorder(pasteboardTypes)
|
||||
}
|
||||
|
||||
override func registerForDraggedTypes(_ newTypes: [NSPasteboard.PasteboardType]) {
|
||||
let filtered = newTypes.filter { !Self.blockedDragTypes.contains($0) }
|
||||
if !filtered.isEmpty {
|
||||
|
|
@ -1120,6 +1125,21 @@ final class CmuxWebView: WKWebView {
|
|||
}
|
||||
}
|
||||
|
||||
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
||||
guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return [] }
|
||||
return super.draggingEntered(sender)
|
||||
}
|
||||
|
||||
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
||||
guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return [] }
|
||||
return super.draggingUpdated(sender)
|
||||
}
|
||||
|
||||
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
|
||||
guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return false }
|
||||
return super.performDragOperation(sender)
|
||||
}
|
||||
|
||||
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
|
||||
super.willOpenMenu(menu, with: event)
|
||||
lastContextMenuPoint = convert(event.locationInWindow, from: nil)
|
||||
|
|
|
|||
|
|
@ -600,7 +600,7 @@ class TabManager: ObservableObject {
|
|||
self.focusSelectedTabPanel(previousTabId: previousTabId)
|
||||
self.updateWindowTitleForSelectedTab()
|
||||
if let selectedTabId = self.selectedTabId {
|
||||
self.markFocusedPanelReadIfActive(tabId: selectedTabId)
|
||||
self.flashFocusedPanelIfUnreadAndActive(tabId: selectedTabId)
|
||||
}
|
||||
#if DEBUG
|
||||
let dtMs = self.debugWorkspaceSwitchStartTime > 0
|
||||
|
|
@ -672,7 +672,7 @@ class TabManager: ObservableObject {
|
|||
guard let self else { return }
|
||||
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return }
|
||||
guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { return }
|
||||
markPanelReadOnFocusIfActive(tabId: tabId, panelId: surfaceId)
|
||||
flashPanelIfUnreadAndActive(tabId: tabId, panelId: surfaceId)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -1618,16 +1618,16 @@ class TabManager: ObservableObject {
|
|||
selectedTabId != pendingTabId
|
||||
}
|
||||
|
||||
private func markFocusedPanelReadIfActive(tabId: UUID) {
|
||||
private func flashFocusedPanelIfUnreadAndActive(tabId: UUID) {
|
||||
let shouldSuppressFlash = suppressFocusFlash
|
||||
suppressFocusFlash = false
|
||||
guard !shouldSuppressFlash else { return }
|
||||
guard AppFocusState.isAppActive() else { return }
|
||||
guard let panelId = focusedPanelId(for: tabId) else { return }
|
||||
markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId)
|
||||
flashPanelIfUnreadAndActive(tabId: tabId, panelId: panelId)
|
||||
}
|
||||
|
||||
private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) {
|
||||
private func flashPanelIfUnreadAndActive(tabId: UUID, panelId: UUID) {
|
||||
guard selectedTabId == tabId else { return }
|
||||
guard !suppressFocusFlash else { return }
|
||||
guard AppFocusState.isAppActive() else { return }
|
||||
|
|
@ -1636,7 +1636,6 @@ class TabManager: ObservableObject {
|
|||
if let tab = tabs.first(where: { $0.id == tabId }) {
|
||||
tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false)
|
||||
}
|
||||
notificationStore.markRead(forTabId: tabId, surfaceId: panelId)
|
||||
}
|
||||
|
||||
private func enqueuePanelTitleUpdate(tabId: UUID, panelId: UUID, title: String) {
|
||||
|
|
@ -1762,7 +1761,6 @@ class TabManager: ObservableObject {
|
|||
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
|
||||
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: targetPanelId) else { return }
|
||||
tab.triggerNotificationFocusFlash(panelId: targetPanelId, requiresSplit: false, shouldFocus: true)
|
||||
notificationStore.markRead(forTabId: tabId, surfaceId: targetPanelId)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ class TerminalController {
|
|||
let acceptLoopAlive: Bool
|
||||
let socketPathMatches: Bool
|
||||
let socketPathExists: Bool
|
||||
let socketProbePerformed: Bool
|
||||
let socketConnectable: Bool?
|
||||
let socketConnectErrno: Int32?
|
||||
|
||||
var failureSignals: [String] {
|
||||
var signals: [String] = []
|
||||
|
|
@ -20,6 +23,9 @@ class TerminalController {
|
|||
if !acceptLoopAlive { signals.append("accept_loop_dead") }
|
||||
if !socketPathMatches { signals.append("socket_path_mismatch") }
|
||||
if !socketPathExists { signals.append("socket_missing") }
|
||||
if socketProbePerformed && isRunning && acceptLoopAlive && socketPathMatches && socketPathExists && socketConnectable == false {
|
||||
signals.append("socket_unreachable")
|
||||
}
|
||||
return signals
|
||||
}
|
||||
|
||||
|
|
@ -51,6 +57,14 @@ class TerminalController {
|
|||
private nonisolated static let acceptFailureMaxBackoffMs = 5_000
|
||||
private nonisolated static let acceptFailureMinimumRearmDelayMs = 100
|
||||
private nonisolated static let acceptFailureRearmThreshold = 50
|
||||
private nonisolated static let socketProbePollTimeoutMs: Int32 = 100
|
||||
private nonisolated static let socketProbePollAttempts = 3
|
||||
private nonisolated static let socketProbePollRetryBackoffUs: useconds_t = 50_000
|
||||
private nonisolated static let unixSocketPathMaxLength: Int = {
|
||||
var addr = sockaddr_un()
|
||||
// Reserve one byte for the null terminator.
|
||||
return MemoryLayout.size(ofValue: addr.sun_path) - 1
|
||||
}()
|
||||
|
||||
private struct ListenerStateSnapshot {
|
||||
let socketPath: String
|
||||
|
|
@ -508,6 +522,99 @@ class TerminalController {
|
|||
return !isRunning && activeGeneration == 0
|
||||
}
|
||||
|
||||
private nonisolated static func unixSocketAddress(path: String) -> sockaddr_un? {
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
|
||||
let maxLength = unixSocketPathMaxLength + 1
|
||||
var didFit = false
|
||||
path.withCString { source in
|
||||
let sourceLength = strlen(source)
|
||||
guard sourceLength < maxLength else { return }
|
||||
|
||||
_ = withUnsafeMutableBytes(of: &addr.sun_path) { buffer in
|
||||
buffer.initializeMemory(as: UInt8.self, repeating: 0)
|
||||
}
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
|
||||
let destination = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
|
||||
strncpy(destination, source, maxLength - 1)
|
||||
}
|
||||
didFit = true
|
||||
}
|
||||
return didFit ? addr : nil
|
||||
}
|
||||
|
||||
private nonisolated static func bindUnixSocket(_ socket: Int32, path: String) -> Int32? {
|
||||
guard var addr = unixSocketAddress(path: path) else { return nil }
|
||||
return withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
|
||||
bind(socket, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func probeSocketConnectability(path: String) -> (isConnectable: Bool?, errnoCode: Int32?) {
|
||||
let probeSocket = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard probeSocket >= 0 else {
|
||||
return (false, errno)
|
||||
}
|
||||
defer { close(probeSocket) }
|
||||
|
||||
let existingFlags = fcntl(probeSocket, F_GETFL, 0)
|
||||
if existingFlags >= 0 {
|
||||
_ = fcntl(probeSocket, F_SETFL, existingFlags | O_NONBLOCK)
|
||||
}
|
||||
|
||||
guard var addr = unixSocketAddress(path: path) else {
|
||||
return (false, ENAMETOOLONG)
|
||||
}
|
||||
let connectResult = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
|
||||
connect(probeSocket, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
|
||||
}
|
||||
}
|
||||
if connectResult == 0 {
|
||||
return (true, nil)
|
||||
}
|
||||
let connectErrno = errno
|
||||
if connectErrno == EINPROGRESS {
|
||||
var pollDescriptor = pollfd(fd: probeSocket, events: Int16(POLLOUT), revents: 0)
|
||||
for attempt in 0..<Self.socketProbePollAttempts {
|
||||
pollDescriptor.revents = 0
|
||||
let pollResult = poll(&pollDescriptor, 1, Self.socketProbePollTimeoutMs)
|
||||
if pollResult > 0 {
|
||||
var socketError: Int32 = 0
|
||||
var socketErrorLength = socklen_t(MemoryLayout<Int32>.size)
|
||||
let status = getsockopt(
|
||||
probeSocket,
|
||||
SOL_SOCKET,
|
||||
SO_ERROR,
|
||||
&socketError,
|
||||
&socketErrorLength
|
||||
)
|
||||
if status == 0 && socketError == 0 {
|
||||
return (true, nil)
|
||||
}
|
||||
if status == 0 {
|
||||
return (false, socketError)
|
||||
}
|
||||
return (false, errno)
|
||||
}
|
||||
|
||||
let pollErrno = errno
|
||||
if pollResult == 0 || pollErrno == EINTR {
|
||||
if attempt + 1 < Self.socketProbePollAttempts {
|
||||
usleep(Self.socketProbePollRetryBackoffUs)
|
||||
continue
|
||||
}
|
||||
return (false, pollResult == 0 ? ETIMEDOUT : pollErrno)
|
||||
}
|
||||
return (false, pollErrno)
|
||||
}
|
||||
}
|
||||
return (false, connectErrno)
|
||||
}
|
||||
|
||||
func start(tabManager: TabManager, socketPath: String, accessMode: SocketControlMode) {
|
||||
self.tabManager = tabManager
|
||||
self.accessMode = accessMode
|
||||
|
|
@ -556,19 +663,18 @@ class TerminalController {
|
|||
}
|
||||
|
||||
// Bind to path
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
socketPath.withCString { ptr in
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
|
||||
let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
|
||||
strcpy(pathBuf, ptr)
|
||||
}
|
||||
}
|
||||
|
||||
let bindResult = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
|
||||
bind(newServerSocket, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
|
||||
}
|
||||
guard let bindResult = Self.bindUnixSocket(newServerSocket, path: socketPath) else {
|
||||
close(newServerSocket)
|
||||
reportSocketListenerFailure(
|
||||
message: "socket.listener.start.failed",
|
||||
stage: "bind_path_too_long",
|
||||
errnoCode: ENAMETOOLONG,
|
||||
extra: [
|
||||
"pathLength": socketPath.utf8.count,
|
||||
"maxPathLength": Self.unixSocketPathMaxLength
|
||||
]
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard bindResult >= 0 else {
|
||||
|
|
@ -653,12 +759,19 @@ class TerminalController {
|
|||
|
||||
var st = stat()
|
||||
let exists = lstat(expectedSocketPath, &st) == 0 && (st.st_mode & S_IFMT) == S_IFSOCK
|
||||
let shouldProbeConnection = snapshot.isRunning && snapshot.acceptLoopAlive && pathMatches && exists
|
||||
let connectability = shouldProbeConnection
|
||||
? Self.probeSocketConnectability(path: expectedSocketPath)
|
||||
: (isConnectable: nil, errnoCode: nil)
|
||||
|
||||
return SocketListenerHealth(
|
||||
isRunning: snapshot.isRunning,
|
||||
acceptLoopAlive: snapshot.acceptLoopAlive,
|
||||
socketPathMatches: pathMatches,
|
||||
socketPathExists: exists
|
||||
socketPathExists: exists,
|
||||
socketProbePerformed: shouldProbeConnection,
|
||||
socketConnectable: connectability.isConnectable,
|
||||
socketConnectErrno: connectability.errnoCode
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -5285,41 +5398,70 @@ class TerminalController {
|
|||
_ webView: WKWebView,
|
||||
script: String,
|
||||
timeout: TimeInterval = 5.0,
|
||||
preferAsync: Bool = false
|
||||
preferAsync: Bool = false,
|
||||
contentWorld: WKContentWorld
|
||||
) -> V2JavaScriptResult {
|
||||
let timeoutSeconds = max(0.01, timeout)
|
||||
let resultLock = NSLock()
|
||||
let completionSignal = DispatchSemaphore(value: 0)
|
||||
var done = false
|
||||
var resultValue: Any?
|
||||
var resultError: String?
|
||||
|
||||
if preferAsync, #available(macOS 11.0, *) {
|
||||
webView.callAsyncJavaScript(script, arguments: [:], in: nil, in: .page) { result in
|
||||
switch result {
|
||||
case .success(let value):
|
||||
resultValue = value
|
||||
case .failure(let error):
|
||||
resultError = error.localizedDescription
|
||||
}
|
||||
let finish: (_ value: Any?, _ error: String?) -> Void = { value, error in
|
||||
resultLock.lock()
|
||||
if !done {
|
||||
done = true
|
||||
resultValue = value
|
||||
resultError = error
|
||||
completionSignal.signal()
|
||||
}
|
||||
resultLock.unlock()
|
||||
}
|
||||
|
||||
let evaluator = {
|
||||
if preferAsync, #available(macOS 11.0, *) {
|
||||
webView.callAsyncJavaScript(script, arguments: [:], in: nil, in: contentWorld) { result in
|
||||
switch result {
|
||||
case .success(let value):
|
||||
finish(value, nil)
|
||||
case .failure(let error):
|
||||
finish(nil, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
webView.evaluateJavaScript(script) { value, error in
|
||||
if let error {
|
||||
finish(nil, error.localizedDescription)
|
||||
} else {
|
||||
finish(value, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if Thread.isMainThread {
|
||||
evaluator()
|
||||
let deadline = Date().addingTimeInterval(timeoutSeconds)
|
||||
while true {
|
||||
resultLock.lock()
|
||||
let isDone = done
|
||||
resultLock.unlock()
|
||||
if isDone {
|
||||
break
|
||||
}
|
||||
if Date() >= deadline {
|
||||
return .failure("Timed out waiting for JavaScript result")
|
||||
}
|
||||
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
|
||||
}
|
||||
} else {
|
||||
webView.evaluateJavaScript(script) { value, error in
|
||||
if let error {
|
||||
resultError = error.localizedDescription
|
||||
} else {
|
||||
resultValue = value
|
||||
}
|
||||
done = true
|
||||
DispatchQueue.main.async(execute: evaluator)
|
||||
if completionSignal.wait(timeout: .now() + timeoutSeconds) == .timedOut {
|
||||
return .failure("Timed out waiting for JavaScript result")
|
||||
}
|
||||
}
|
||||
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while !done && Date() < deadline {
|
||||
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
|
||||
}
|
||||
|
||||
if !done {
|
||||
return .failure("Timed out waiting for JavaScript result")
|
||||
}
|
||||
if let resultError {
|
||||
return .failure(resultError)
|
||||
}
|
||||
|
|
@ -5369,7 +5511,8 @@ class TerminalController {
|
|||
_ webView: WKWebView,
|
||||
surfaceId: UUID,
|
||||
script: String,
|
||||
timeout: TimeInterval = 5.0
|
||||
timeout: TimeInterval = 5.0,
|
||||
useEval: Bool = true
|
||||
) -> V2JavaScriptResult {
|
||||
let scriptLiteral = v2JSONLiteral(script)
|
||||
let framePrelude: String
|
||||
|
|
@ -5388,6 +5531,13 @@ class TerminalController {
|
|||
framePrelude = "const __cmuxDoc = document;"
|
||||
}
|
||||
|
||||
let executionBlock: String
|
||||
if useEval {
|
||||
executionBlock = "const __r = eval(\(scriptLiteral));"
|
||||
} else {
|
||||
executionBlock = "const __r = \(script);"
|
||||
}
|
||||
|
||||
let asyncFunctionBody = """
|
||||
\(framePrelude)
|
||||
|
||||
|
|
@ -5400,7 +5550,7 @@ class TerminalController {
|
|||
|
||||
const __cmuxEvalInFrame = async function() {
|
||||
const document = __cmuxDoc;
|
||||
const __r = eval(\(scriptLiteral));
|
||||
\(executionBlock)
|
||||
const __value = await __cmuxMaybeAwait(__r);
|
||||
return {
|
||||
__cmux_t: (typeof __value === 'undefined') ? 'undefined' : 'value',
|
||||
|
|
@ -5411,16 +5561,40 @@ class TerminalController {
|
|||
return await __cmuxEvalInFrame();
|
||||
"""
|
||||
|
||||
let rawResult: V2JavaScriptResult
|
||||
var rawResult: V2JavaScriptResult
|
||||
if #available(macOS 11.0, *) {
|
||||
rawResult = v2RunJavaScript(webView, script: asyncFunctionBody, timeout: timeout, preferAsync: true)
|
||||
rawResult = v2RunJavaScript(
|
||||
webView,
|
||||
script: asyncFunctionBody,
|
||||
timeout: timeout,
|
||||
preferAsync: true,
|
||||
contentWorld: .page
|
||||
)
|
||||
} else {
|
||||
let evaluateFallback = """
|
||||
(async () => {
|
||||
\(asyncFunctionBody)
|
||||
})()
|
||||
"""
|
||||
rawResult = v2RunJavaScript(webView, script: evaluateFallback, timeout: timeout)
|
||||
rawResult = v2RunJavaScript(webView, script: evaluateFallback, timeout: timeout, contentWorld: .page)
|
||||
}
|
||||
|
||||
if !useEval, case .failure(let pageMessage) = rawResult, #available(macOS 11.0, *) {
|
||||
let isolatedResult = v2RunJavaScript(
|
||||
webView,
|
||||
script: asyncFunctionBody,
|
||||
timeout: timeout,
|
||||
preferAsync: true,
|
||||
contentWorld: .defaultClient
|
||||
)
|
||||
switch isolatedResult {
|
||||
case .success:
|
||||
rawResult = isolatedResult
|
||||
case .failure(let isolatedMessage):
|
||||
if isolatedMessage != pageMessage {
|
||||
rawResult = .failure("\(pageMessage) (isolated-world retry: \(isolatedMessage))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch rawResult {
|
||||
|
|
@ -5521,38 +5695,41 @@ class TerminalController {
|
|||
}
|
||||
}
|
||||
|
||||
private func v2BrowserWaitForCondition(
|
||||
_ conditionScript: String,
|
||||
webView: WKWebView,
|
||||
surfaceId: UUID? = nil,
|
||||
timeout: TimeInterval = 5.0,
|
||||
pollInterval: TimeInterval = 0.05
|
||||
) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let wrapped = "(() => { try { return !!(\(conditionScript)); } catch (_) { return false; } })()"
|
||||
let jsResult: V2JavaScriptResult
|
||||
if let surfaceId {
|
||||
jsResult = v2RunBrowserJavaScript(webView, surfaceId: surfaceId, script: wrapped, timeout: max(0.5, pollInterval + 0.25))
|
||||
} else {
|
||||
jsResult = v2RunJavaScript(webView, script: wrapped, timeout: max(0.5, pollInterval + 0.25))
|
||||
}
|
||||
if case let .success(value) = jsResult,
|
||||
let ok = value as? Bool,
|
||||
ok {
|
||||
return true
|
||||
}
|
||||
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(pollInterval))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func v2PNGData(from image: NSImage) -> Data? {
|
||||
guard let tiff = image.tiffRepresentation,
|
||||
let rep = NSBitmapImageRep(data: tiff) else { return nil }
|
||||
return rep.representation(using: .png, properties: [:])
|
||||
}
|
||||
|
||||
private func bestEffortPruneTemporaryFiles(
|
||||
in directoryURL: URL,
|
||||
keepingMostRecent maxCount: Int = 50,
|
||||
maxAge: TimeInterval = 24 * 60 * 60
|
||||
) {
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(
|
||||
at: directoryURL,
|
||||
includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
let datedEntries = entries.compactMap { url -> (url: URL, date: Date)? in
|
||||
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey]),
|
||||
values.isRegularFile == true else {
|
||||
return nil
|
||||
}
|
||||
return (url, values.contentModificationDate ?? values.creationDate ?? .distantPast)
|
||||
}.sorted { $0.date > $1.date }
|
||||
|
||||
for (index, entry) in datedEntries.enumerated() {
|
||||
if index >= maxCount || now.timeIntervalSince(entry.date) > maxAge {
|
||||
try? FileManager.default.removeItem(at: entry.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Markdown
|
||||
|
||||
private func v2MarkdownOpen(params: [String: Any]) -> V2CallResult {
|
||||
|
|
@ -5973,7 +6150,7 @@ class TerminalController {
|
|||
let retryAttempts = max(1, v2Int(params, "retry_attempts") ?? 3)
|
||||
|
||||
for attempt in 1...retryAttempts {
|
||||
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
|
||||
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, useEval: false) {
|
||||
case .failure(let message):
|
||||
return .err(code: "js_error", message: message, data: ["action": actionName, "selector": selector])
|
||||
case .success(let value):
|
||||
|
|
@ -6231,7 +6408,7 @@ class TerminalController {
|
|||
})()
|
||||
"""
|
||||
|
||||
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0) {
|
||||
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0, useEval: false) {
|
||||
case .failure(let message):
|
||||
return .err(code: "js_error", message: message, data: nil)
|
||||
case .success(let value):
|
||||
|
|
@ -6328,42 +6505,120 @@ class TerminalController {
|
|||
private func v2BrowserWait(params: [String: Any]) -> V2CallResult {
|
||||
let timeoutMs = max(1, v2Int(params, "timeout_ms") ?? 5_000)
|
||||
let timeout = Double(timeoutMs) / 1000.0
|
||||
let selectorRaw = v2BrowserSelector(params)
|
||||
|
||||
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
|
||||
let conditionScript: String = {
|
||||
if let selector = v2BrowserSelector(params) {
|
||||
let literal = v2JSONLiteral(selector)
|
||||
return "document.querySelector(\(literal)) !== null"
|
||||
let conditionScriptBase: String = {
|
||||
if let urlContains = v2String(params, "url_contains") {
|
||||
let literal = v2JSONLiteral(urlContains)
|
||||
return "String(location.href || '').includes(\(literal))"
|
||||
}
|
||||
if let textContains = v2String(params, "text_contains") {
|
||||
let literal = v2JSONLiteral(textContains)
|
||||
return "(document.body && String(document.body.innerText || '').includes(\(literal)))"
|
||||
}
|
||||
if let loadState = v2String(params, "load_state") {
|
||||
let normalizedLoadState = loadState.lowercased()
|
||||
if normalizedLoadState == "interactive" {
|
||||
return """
|
||||
(() => {
|
||||
const __state = String(document.readyState || '').toLowerCase();
|
||||
return __state === 'interactive' || __state === 'complete';
|
||||
})()
|
||||
"""
|
||||
}
|
||||
if let urlContains = v2String(params, "url_contains") {
|
||||
let literal = v2JSONLiteral(urlContains)
|
||||
return "String(location.href || '').includes(\(literal))"
|
||||
}
|
||||
if let textContains = v2String(params, "text_contains") {
|
||||
let literal = v2JSONLiteral(textContains)
|
||||
return "(document.body && String(document.body.innerText || '').includes(\(literal)))"
|
||||
}
|
||||
if let loadState = v2String(params, "load_state") {
|
||||
let literal = v2JSONLiteral(loadState.lowercased())
|
||||
return "String(document.readyState || '').toLowerCase() === \(literal)"
|
||||
}
|
||||
if let fn = v2String(params, "function") {
|
||||
return "(() => { return !!(\(fn)); })()"
|
||||
}
|
||||
return "document.readyState === 'complete'"
|
||||
}()
|
||||
let literal = v2JSONLiteral(normalizedLoadState)
|
||||
return "String(document.readyState || '').toLowerCase() === \(literal)"
|
||||
}
|
||||
if let fn = v2String(params, "function") {
|
||||
return "(() => { return !!(\(fn)); })()"
|
||||
}
|
||||
return "document.readyState === 'complete'"
|
||||
}()
|
||||
|
||||
let ok = v2BrowserWaitForCondition(conditionScript, webView: browserPanel.webView, surfaceId: surfaceId, timeout: timeout)
|
||||
if !ok {
|
||||
var setupResult: V2CallResult?
|
||||
var workspaceId: UUID?
|
||||
var surfaceIdOut: UUID?
|
||||
var webView: WKWebView?
|
||||
|
||||
v2MainSync {
|
||||
guard let tabManager = self.v2ResolveTabManager(params: params) else {
|
||||
setupResult = .err(code: "unavailable", message: "TabManager not available", data: nil)
|
||||
return
|
||||
}
|
||||
guard let ws = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else {
|
||||
setupResult = .err(code: "not_found", message: "Workspace not found", data: nil)
|
||||
return
|
||||
}
|
||||
let surfaceId = self.v2UUID(params, "surface_id") ?? ws.focusedPanelId
|
||||
guard let surfaceId else {
|
||||
setupResult = .err(code: "not_found", message: "No focused browser surface", data: nil)
|
||||
return
|
||||
}
|
||||
guard let browserPanel = ws.browserPanel(for: surfaceId) else {
|
||||
setupResult = .err(code: "invalid_params", message: "Surface is not a browser", data: ["surface_id": surfaceId.uuidString])
|
||||
return
|
||||
}
|
||||
workspaceId = ws.id
|
||||
surfaceIdOut = surfaceId
|
||||
webView = browserPanel.webView
|
||||
}
|
||||
|
||||
if let setupResult {
|
||||
return setupResult
|
||||
}
|
||||
guard let workspaceId, let surfaceIdOut, let webView else {
|
||||
return .err(code: "internal_error", message: "Failed to resolve browser surface", data: nil)
|
||||
}
|
||||
|
||||
let conditionScript: String
|
||||
if let selectorRaw {
|
||||
guard let selector = v2BrowserResolveSelector(selectorRaw, surfaceId: surfaceIdOut) else {
|
||||
return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw])
|
||||
}
|
||||
let literal = v2JSONLiteral(selector)
|
||||
conditionScript = "document.querySelector(\(literal)) !== null"
|
||||
} else {
|
||||
conditionScript = conditionScriptBase
|
||||
}
|
||||
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
let pollInterval = 0.05
|
||||
let wrappedScript = "(() => { try { return !!(\(conditionScript)); } catch (_) { return false; } })()"
|
||||
|
||||
while true {
|
||||
switch v2RunBrowserJavaScript(
|
||||
webView,
|
||||
surfaceId: surfaceIdOut,
|
||||
script: wrappedScript,
|
||||
timeout: max(0.5, pollInterval + 0.25),
|
||||
useEval: false
|
||||
) {
|
||||
case .success(let value):
|
||||
if let b = value as? Bool, b {
|
||||
return .ok([
|
||||
"workspace_id": workspaceId.uuidString,
|
||||
"workspace_ref": self.v2Ref(kind: .workspace, uuid: workspaceId),
|
||||
"surface_id": surfaceIdOut.uuidString,
|
||||
"surface_ref": self.v2Ref(kind: .surface, uuid: surfaceIdOut),
|
||||
"waited": true
|
||||
])
|
||||
}
|
||||
case .failure(let message):
|
||||
return .err(
|
||||
code: "js_error",
|
||||
message: message,
|
||||
data: [
|
||||
"condition": conditionScript,
|
||||
"timeout_ms": timeoutMs
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
if Date() >= deadline {
|
||||
return .err(code: "timeout", message: "Condition not met before timeout", data: ["timeout_ms": timeoutMs])
|
||||
}
|
||||
return .ok([
|
||||
"workspace_id": ws.id.uuidString,
|
||||
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
||||
"surface_id": surfaceId.uuidString,
|
||||
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
|
||||
"waited": true
|
||||
])
|
||||
|
||||
Thread.sleep(forTimeInterval: pollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -6708,13 +6963,31 @@ class TerminalController {
|
|||
return .err(code: "internal_error", message: "Failed to capture snapshot", data: nil)
|
||||
}
|
||||
|
||||
return .ok([
|
||||
var result: [String: Any] = [
|
||||
"workspace_id": ws.id.uuidString,
|
||||
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
|
||||
"surface_id": surfaceId.uuidString,
|
||||
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
|
||||
"png_base64": imageData.base64EncodedString()
|
||||
])
|
||||
]
|
||||
|
||||
// Best effort: keep screenshot data available even when temp-file writes fail.
|
||||
let screenshotsDirectory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-browser-screenshots", isDirectory: true)
|
||||
if (try? FileManager.default.createDirectory(at: screenshotsDirectory, withIntermediateDirectories: true)) != nil {
|
||||
bestEffortPruneTemporaryFiles(in: screenshotsDirectory)
|
||||
let timestampMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
let shortSurfaceId = String(surfaceId.uuidString.prefix(8))
|
||||
let shortRandomId = String(UUID().uuidString.prefix(8))
|
||||
let filename = "surface-\(shortSurfaceId)-\(timestampMs)-\(shortRandomId).png"
|
||||
let imageURL = screenshotsDirectory.appendingPathComponent(filename, isDirectory: false)
|
||||
if (try? imageData.write(to: imageURL, options: .atomic)) != nil {
|
||||
result["path"] = imageURL.path
|
||||
result["url"] = imageURL.absoluteString
|
||||
}
|
||||
}
|
||||
|
||||
return .ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -7544,7 +7817,8 @@ class TerminalController {
|
|||
_ = v2RunJavaScript(
|
||||
browserPanel.webView,
|
||||
script: BrowserPanel.telemetryHookBootstrapScriptSource,
|
||||
timeout: 5.0
|
||||
timeout: 5.0,
|
||||
contentWorld: .page
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -7552,7 +7826,8 @@ class TerminalController {
|
|||
_ = v2RunJavaScript(
|
||||
browserPanel.webView,
|
||||
script: BrowserPanel.dialogTelemetryHookBootstrapScriptSource,
|
||||
timeout: 5.0
|
||||
timeout: 5.0,
|
||||
contentWorld: .page
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -7584,7 +7859,7 @@ class TerminalController {
|
|||
})()
|
||||
"""
|
||||
|
||||
switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0) {
|
||||
switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0, contentWorld: .page) {
|
||||
case .failure(let message):
|
||||
return .err(code: "js_error", message: message, data: nil)
|
||||
case .success(let value):
|
||||
|
|
@ -8179,7 +8454,7 @@ class TerminalController {
|
|||
return { ok: true, items };
|
||||
})()
|
||||
"""
|
||||
switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0) {
|
||||
switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0, contentWorld: .page) {
|
||||
case .failure(let message):
|
||||
return .err(code: "js_error", message: message, data: nil)
|
||||
case .success(let value):
|
||||
|
|
@ -8217,7 +8492,7 @@ class TerminalController {
|
|||
return { ok: true, items };
|
||||
})()
|
||||
"""
|
||||
switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0) {
|
||||
switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0, contentWorld: .page) {
|
||||
case .failure(let message):
|
||||
return .err(code: "js_error", message: message, data: nil)
|
||||
case .success(let value):
|
||||
|
|
|
|||
|
|
@ -833,16 +833,9 @@ 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 suppressNativeDelivery = isAppFocused && isFocusedPanel
|
||||
|
||||
if WorkspaceAutoReorderSettings.isEnabled() {
|
||||
if WorkspaceAutoReorderSettings.isEnabled() && !suppressNativeDelivery {
|
||||
AppDelegate.shared?.tabManager?.moveTabToTop(tabId)
|
||||
}
|
||||
|
||||
|
|
@ -862,7 +855,11 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
||||
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
||||
}
|
||||
scheduleUserNotification(notification)
|
||||
if suppressNativeDelivery {
|
||||
Self.runNotificationCustomCommand(notification)
|
||||
} else {
|
||||
scheduleUserNotification(notification)
|
||||
}
|
||||
}
|
||||
|
||||
func markRead(id: UUID) {
|
||||
|
|
@ -993,10 +990,7 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
guard let self, authorized else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
||||
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
|
||||
?? "cmux"
|
||||
content.title = notification.title.isEmpty ? appName : notification.title
|
||||
content.title = Self.notificationDisplayTitle(notification)
|
||||
content.subtitle = notification.subtitle
|
||||
content.body = notification.body
|
||||
content.sound = NotificationSoundSettings.sound()
|
||||
|
|
@ -1019,16 +1013,27 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
if let error {
|
||||
NSLog("Failed to schedule notification: \(error)")
|
||||
} else {
|
||||
NotificationSoundSettings.runCustomCommand(
|
||||
title: content.title,
|
||||
subtitle: content.subtitle,
|
||||
body: content.body
|
||||
)
|
||||
Self.runNotificationCustomCommand(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func notificationDisplayTitle(_ notification: TerminalNotification) -> String {
|
||||
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
||||
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
|
||||
?? "cmux"
|
||||
return notification.title.isEmpty ? appName : notification.title
|
||||
}
|
||||
|
||||
nonisolated private static func runNotificationCustomCommand(_ notification: TerminalNotification) {
|
||||
NotificationSoundSettings.runCustomCommand(
|
||||
title: notificationDisplayTitle(notification),
|
||||
subtitle: notification.subtitle,
|
||||
body: notification.body
|
||||
)
|
||||
}
|
||||
|
||||
private func ensureAuthorization(
|
||||
origin: AuthorizationRequestOrigin,
|
||||
_ completion: @escaping (Bool) -> Void
|
||||
|
|
|
|||
|
|
@ -726,6 +726,7 @@ final class WindowTerminalPortal: NSObject {
|
|||
guard let window else { return false }
|
||||
guard let (container, reference) = installedTargetIfStillValid(for: window) ?? installationTarget(for: window)
|
||||
else { return false }
|
||||
let browserHost = preferredBrowserHost(in: container)
|
||||
|
||||
if hostView.superview !== container ||
|
||||
installedContainerView !== container ||
|
||||
|
|
@ -734,7 +735,11 @@ final class WindowTerminalPortal: NSObject {
|
|||
installConstraints.removeAll()
|
||||
|
||||
hostView.removeFromSuperview()
|
||||
container.addSubview(hostView, positioned: .above, relativeTo: reference)
|
||||
if let browserHost {
|
||||
container.addSubview(hostView, positioned: .below, relativeTo: browserHost)
|
||||
} else {
|
||||
container.addSubview(hostView, positioned: .above, relativeTo: reference)
|
||||
}
|
||||
|
||||
installConstraints = [
|
||||
hostView.leadingAnchor.constraint(equalTo: reference.leadingAnchor),
|
||||
|
|
@ -745,6 +750,10 @@ final class WindowTerminalPortal: NSObject {
|
|||
NSLayoutConstraint.activate(installConstraints)
|
||||
installedContainerView = container
|
||||
installedReferenceView = reference
|
||||
} else if let browserHost {
|
||||
if !Self.isView(browserHost, above: hostView, in: container) {
|
||||
container.addSubview(hostView, positioned: .below, relativeTo: browserHost)
|
||||
}
|
||||
} else if !Self.isView(hostView, above: reference, in: container) {
|
||||
container.addSubview(hostView, positioned: .above, relativeTo: reference)
|
||||
}
|
||||
|
|
@ -837,6 +846,10 @@ final class WindowTerminalPortal: NSObject {
|
|||
return viewIndex > referenceIndex
|
||||
}
|
||||
|
||||
private func preferredBrowserHost(in container: NSView) -> WindowBrowserHostView? {
|
||||
container.subviews.last(where: { $0 is WindowBrowserHostView }) as? WindowBrowserHostView
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func nearestBonsplitContainer(from anchorView: NSView) -> NSView? {
|
||||
var current: NSView? = anchorView
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ struct TitlebarControlsView: View {
|
|||
@AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY
|
||||
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
||||
@State private var shortcutRefreshTick = 0
|
||||
@StateObject private var commandKeyMonitor = TitlebarCommandKeyMonitor()
|
||||
@StateObject private var modifierKeyMonitor = TitlebarShortcutHintModifierMonitor()
|
||||
private let titlebarHintRightSafetyShift: CGFloat = 10
|
||||
private let titlebarHintBaseXShift: CGFloat = -10
|
||||
private let titlebarHintBaseYShift: CGFloat = 1
|
||||
|
|
@ -269,7 +269,7 @@ struct TitlebarControlsView: View {
|
|||
}
|
||||
|
||||
private var shouldShowTitlebarShortcutHints: Bool {
|
||||
alwaysShowShortcutHints || commandKeyMonitor.isCommandPressed
|
||||
alwaysShowShortcutHints || modifierKeyMonitor.isModifierPressed
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -283,7 +283,7 @@ struct TitlebarControlsView: View {
|
|||
.padding(.trailing, titlebarHintTrailingInset)
|
||||
.background(
|
||||
WindowAccessor { window in
|
||||
commandKeyMonitor.setHostWindow(window)
|
||||
modifierKeyMonitor.setHostWindow(window)
|
||||
}
|
||||
.frame(width: 0, height: 0)
|
||||
)
|
||||
|
|
@ -291,10 +291,10 @@ struct TitlebarControlsView: View {
|
|||
shortcutRefreshTick &+= 1
|
||||
}
|
||||
.onAppear {
|
||||
commandKeyMonitor.start()
|
||||
modifierKeyMonitor.start()
|
||||
}
|
||||
.onDisappear {
|
||||
commandKeyMonitor.stop()
|
||||
modifierKeyMonitor.stop()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -503,8 +503,8 @@ struct TitlebarControlsView: View {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
private final class TitlebarCommandKeyMonitor: ObservableObject {
|
||||
@Published private(set) var isCommandPressed = false
|
||||
private final class TitlebarShortcutHintModifierMonitor: ObservableObject {
|
||||
@Published private(set) var isModifierPressed = false
|
||||
|
||||
private weak var hostWindow: NSWindow?
|
||||
private var hostWindowDidBecomeKeyObserver: NSObjectProtocol?
|
||||
|
|
@ -598,7 +598,7 @@ private final class TitlebarCommandKeyMonitor: ObservableObject {
|
|||
}
|
||||
|
||||
private func isCurrentWindow(eventWindow: NSWindow?) -> Bool {
|
||||
SidebarCommandHintPolicy.isCurrentWindow(
|
||||
ShortcutHintModifierPolicy.isCurrentWindow(
|
||||
hostWindowNumber: hostWindow?.windowNumber,
|
||||
hostWindowIsKey: hostWindow?.isKeyWindow ?? false,
|
||||
eventWindowNumber: eventWindow?.windowNumber,
|
||||
|
|
@ -607,7 +607,7 @@ private final class TitlebarCommandKeyMonitor: ObservableObject {
|
|||
}
|
||||
|
||||
private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) {
|
||||
guard SidebarCommandHintPolicy.shouldShowHints(
|
||||
guard ShortcutHintModifierPolicy.shouldShowHints(
|
||||
for: modifierFlags,
|
||||
hostWindowNumber: hostWindow?.windowNumber,
|
||||
hostWindowIsKey: hostWindow?.isKeyWindow ?? false,
|
||||
|
|
@ -622,31 +622,31 @@ private final class TitlebarCommandKeyMonitor: ObservableObject {
|
|||
}
|
||||
|
||||
private func queueHintShow() {
|
||||
guard !isCommandPressed else { return }
|
||||
guard !isModifierPressed else { return }
|
||||
guard pendingShowWorkItem == nil else { return }
|
||||
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pendingShowWorkItem = nil
|
||||
guard SidebarCommandHintPolicy.shouldShowHints(
|
||||
guard ShortcutHintModifierPolicy.shouldShowHints(
|
||||
for: NSEvent.modifierFlags,
|
||||
hostWindowNumber: self.hostWindow?.windowNumber,
|
||||
hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false,
|
||||
eventWindowNumber: nil,
|
||||
keyWindowNumber: NSApp.keyWindow?.windowNumber
|
||||
) else { return }
|
||||
self.isCommandPressed = true
|
||||
self.isModifierPressed = true
|
||||
}
|
||||
|
||||
pendingShowWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + SidebarCommandHintPolicy.intentionalHoldDelay, execute: workItem)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + ShortcutHintModifierPolicy.intentionalHoldDelay, execute: workItem)
|
||||
}
|
||||
|
||||
private func cancelPendingHintShow(resetVisible: Bool) {
|
||||
pendingShowWorkItem?.cancel()
|
||||
pendingShowWorkItem = nil
|
||||
if resetVisible {
|
||||
isCommandPressed = false
|
||||
isModifierPressed = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1968,11 +1968,21 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
guard let paneId = sourcePaneId else { return nil }
|
||||
let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId)
|
||||
|
||||
// Inherit working directory: prefer the source panel's reported cwd,
|
||||
// fall back to the workspace's current directory.
|
||||
let splitWorkingDirectory: String? = panelDirectories[panelId]
|
||||
?? (currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? nil : currentDirectory)
|
||||
#if DEBUG
|
||||
dlog("split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")")
|
||||
#endif
|
||||
|
||||
// Create the new terminal panel.
|
||||
let newPanel = TerminalPanel(
|
||||
workspaceId: id,
|
||||
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
||||
configTemplate: inheritedConfig,
|
||||
workingDirectory: splitWorkingDirectory,
|
||||
portOrdinal: portOrdinal
|
||||
)
|
||||
panels[newPanel.id] = newPanel
|
||||
|
|
@ -3086,7 +3096,14 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
if let browserPanel = panels[panelId] as? BrowserPanel {
|
||||
maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger)
|
||||
// Keep browser find focus behavior aligned with terminal find behavior.
|
||||
// When switching back to a pane with an already-open find bar, reassert
|
||||
// focus to that field instead of leaving first responder stale.
|
||||
if browserPanel.searchState != nil {
|
||||
browserPanel.startFind()
|
||||
} else {
|
||||
maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2825,6 +2825,8 @@ struct SettingsView: View {
|
|||
@AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
|
||||
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
|
||||
private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
||||
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey)
|
||||
private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
||||
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
||||
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
||||
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
||||
|
|
@ -4107,6 +4109,8 @@ struct SettingsView: View {
|
|||
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
||||
warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
|
||||
commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
||||
ShortcutHintDebugSettings.resetVisibilityDefaults()
|
||||
alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
||||
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
||||
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
||||
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
||||
|
|
|
|||
|
|
@ -8,6 +8,39 @@ import XCTest
|
|||
|
||||
@MainActor
|
||||
final class AppDelegateShortcutRoutingTests: XCTestCase {
|
||||
private var savedShortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:]
|
||||
private var actionsWithPersistedShortcut: Set<KeyboardShortcutSettings.Action> = []
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
actionsWithPersistedShortcut = Set(
|
||||
KeyboardShortcutSettings.Action.allCases.filter {
|
||||
UserDefaults.standard.object(forKey: $0.defaultsKey) != nil
|
||||
}
|
||||
)
|
||||
savedShortcutsByAction = Dictionary(
|
||||
uniqueKeysWithValues: actionsWithPersistedShortcut.map { action in
|
||||
(action, KeyboardShortcutSettings.shortcut(for: action))
|
||||
}
|
||||
)
|
||||
KeyboardShortcutSettings.resetAll()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
AppDelegate.shared?.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:)
|
||||
AppDelegate.shared?.dismissNotificationsPopoverIfShown()
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
for action in KeyboardShortcutSettings.Action.allCases {
|
||||
if actionsWithPersistedShortcut.contains(action),
|
||||
let savedShortcut = savedShortcutsByAction[action] {
|
||||
KeyboardShortcutSettings.setShortcut(savedShortcut, for: action)
|
||||
} else {
|
||||
KeyboardShortcutSettings.resetShortcut(for: action)
|
||||
}
|
||||
}
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testCmdNUsesEventWindowContextWhenActiveManagerIsStale() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
|
|
@ -311,6 +344,910 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
|||
XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window")
|
||||
}
|
||||
|
||||
func testCmdPhysicalIWithDvorakCharactersDoesNotTriggerShowNotifications() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
withTemporaryShortcut(action: .showNotifications) {
|
||||
// Dvorak: physical ANSI "I" key can produce the character "c".
|
||||
// This should behave like Cmd+C (copy), not match the Cmd+I app shortcut.
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: "c",
|
||||
charactersIgnoringModifiers: "c",
|
||||
isARepeat: false,
|
||||
keyCode: 34 // kVK_ANSI_I
|
||||
) else {
|
||||
XCTFail("Failed to construct Dvorak Cmd+C event on physical ANSI I key")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func testCmdPhysicalPWithDvorakCharactersDoesNotTriggerCommandPaletteSwitcher() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
let switcherExpectation = expectation(description: "Cmd+L should not request command palette switcher")
|
||||
switcherExpectation.isInverted = true
|
||||
let token = NotificationCenter.default.addObserver(
|
||||
forName: .commandPaletteSwitcherRequested,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { _ in
|
||||
switcherExpectation.fulfill()
|
||||
}
|
||||
defer { NotificationCenter.default.removeObserver(token) }
|
||||
|
||||
// Dvorak: physical ANSI "P" key can produce "l".
|
||||
// This should behave as Cmd+L, not as physical Cmd+P.
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: "l",
|
||||
charactersIgnoringModifiers: "l",
|
||||
isARepeat: false,
|
||||
keyCode: 35 // kVK_ANSI_P
|
||||
) else {
|
||||
XCTFail("Failed to construct Dvorak Cmd+L event on physical ANSI P key")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
_ = appDelegate.debugHandleCustomShortcut(event: event)
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
wait(for: [switcherExpectation], timeout: 0.15)
|
||||
}
|
||||
|
||||
func testCmdPWithCapsLockStillTriggersCommandPaletteSwitcher() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
let switcherExpectation = expectation(description: "Cmd+P with Caps Lock should request command palette switcher")
|
||||
let token = NotificationCenter.default.addObserver(
|
||||
forName: .commandPaletteSwitcherRequested,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { _ in
|
||||
switcherExpectation.fulfill()
|
||||
}
|
||||
defer { NotificationCenter.default.removeObserver(token) }
|
||||
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command, .capsLock],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: "p",
|
||||
charactersIgnoringModifiers: "p",
|
||||
isARepeat: false,
|
||||
keyCode: 35 // kVK_ANSI_P
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+P + Caps Lock event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
wait(for: [switcherExpectation], timeout: 0.15)
|
||||
}
|
||||
|
||||
func testCmdPFallsBackToANSIKeyCodeWhenCharactersAndLayoutTranslationAreUnavailable() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
appDelegate.shortcutLayoutCharacterProvider = { _, _ in nil }
|
||||
defer {
|
||||
appDelegate.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:)
|
||||
}
|
||||
|
||||
let switcherExpectation = expectation(description: "Cmd+P with unavailable characters should request command palette switcher")
|
||||
let token = NotificationCenter.default.addObserver(
|
||||
forName: .commandPaletteSwitcherRequested,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { _ in
|
||||
switcherExpectation.fulfill()
|
||||
}
|
||||
defer { NotificationCenter.default.removeObserver(token) }
|
||||
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: "",
|
||||
charactersIgnoringModifiers: "",
|
||||
isARepeat: false,
|
||||
keyCode: 35 // kVK_ANSI_P
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+P event with unavailable characters")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertTrue(appDelegate.handleBrowserSurfaceKeyEquivalent(event))
|
||||
wait(for: [switcherExpectation], timeout: 0.15)
|
||||
}
|
||||
|
||||
func testCmdPDoesNotFallbackToANSIKeyCodeWhenLayoutTranslationProvidesDifferentLetter() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
appDelegate.shortcutLayoutCharacterProvider = { _, _ in "b" }
|
||||
defer {
|
||||
appDelegate.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:)
|
||||
}
|
||||
|
||||
let switcherExpectation = expectation(description: "Non-P layout translation should not request command palette switcher")
|
||||
switcherExpectation.isInverted = true
|
||||
let token = NotificationCenter.default.addObserver(
|
||||
forName: .commandPaletteSwitcherRequested,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { _ in
|
||||
switcherExpectation.fulfill()
|
||||
}
|
||||
defer { NotificationCenter.default.removeObserver(token) }
|
||||
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: "",
|
||||
charactersIgnoringModifiers: "",
|
||||
isARepeat: false,
|
||||
keyCode: 35 // kVK_ANSI_P
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+P event with unavailable characters")
|
||||
return
|
||||
}
|
||||
|
||||
_ = appDelegate.handleBrowserSurfaceKeyEquivalent(event)
|
||||
wait(for: [switcherExpectation], timeout: 0.15)
|
||||
}
|
||||
|
||||
func testCmdPFallsBackToCommandAwareLayoutTranslationWhenCharactersAreUnavailable() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
appDelegate.shortcutLayoutCharacterProvider = { keyCode, modifierFlags in
|
||||
guard keyCode == 35 else { return nil } // kVK_ANSI_P
|
||||
return modifierFlags.contains(.command) ? "p" : "r"
|
||||
}
|
||||
defer {
|
||||
appDelegate.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:)
|
||||
}
|
||||
|
||||
let switcherExpectation = expectation(description: "Command-aware layout translation should request command palette switcher")
|
||||
let token = NotificationCenter.default.addObserver(
|
||||
forName: .commandPaletteSwitcherRequested,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { _ in
|
||||
switcherExpectation.fulfill()
|
||||
}
|
||||
defer { NotificationCenter.default.removeObserver(token) }
|
||||
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: "",
|
||||
charactersIgnoringModifiers: "",
|
||||
isARepeat: false,
|
||||
keyCode: 35 // kVK_ANSI_P
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+P event with unavailable characters")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertTrue(appDelegate.handleBrowserSurfaceKeyEquivalent(event))
|
||||
wait(for: [switcherExpectation], timeout: 0.15)
|
||||
}
|
||||
|
||||
func testCmdShiftPhysicalPWithDvorakCharactersDoesNotTriggerCommandPalette() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
let paletteExpectation = expectation(description: "Cmd+Shift+L should not request command palette")
|
||||
paletteExpectation.isInverted = true
|
||||
let token = NotificationCenter.default.addObserver(
|
||||
forName: .commandPaletteRequested,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { _ in
|
||||
paletteExpectation.fulfill()
|
||||
}
|
||||
defer { NotificationCenter.default.removeObserver(token) }
|
||||
|
||||
// Dvorak: physical ANSI "P" key can produce "l".
|
||||
// This should behave as Cmd+Shift+L, not as physical Cmd+Shift+P.
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command, .shift],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: "l",
|
||||
charactersIgnoringModifiers: "l",
|
||||
isARepeat: false,
|
||||
keyCode: 35 // kVK_ANSI_P
|
||||
) else {
|
||||
XCTFail("Failed to construct Dvorak Cmd+Shift+L event on physical ANSI P key")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
_ = appDelegate.debugHandleCustomShortcut(event: event)
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
wait(for: [paletteExpectation], timeout: 0.15)
|
||||
}
|
||||
|
||||
func testCmdOptionPhysicalTWithDvorakCharactersDoesNotTriggerCloseOtherTabsShortcut() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
// Dvorak: physical ANSI "T" key can produce "y".
|
||||
// This should not match the Cmd+Option+T app shortcut.
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command, .option],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: "y",
|
||||
charactersIgnoringModifiers: "y",
|
||||
isARepeat: false,
|
||||
keyCode: 17 // kVK_ANSI_T
|
||||
) else {
|
||||
XCTFail("Failed to construct Dvorak Cmd+Option+Y event on physical ANSI T key")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
}
|
||||
|
||||
func testCmdPhysicalWWithDvorakCharactersDoesNotTriggerClosePanelShortcut() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId),
|
||||
let manager = appDelegate.tabManagerFor(windowId: windowId),
|
||||
let workspace = manager.selectedWorkspace else {
|
||||
XCTFail("Expected test window and workspace")
|
||||
return
|
||||
}
|
||||
|
||||
let panelCountBefore = workspace.panels.count
|
||||
|
||||
// Dvorak: physical ANSI "W" key can produce ",".
|
||||
// This should not match the Cmd+W close-panel shortcut.
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: ",",
|
||||
charactersIgnoringModifiers: ",",
|
||||
isARepeat: false,
|
||||
keyCode: 13 // kVK_ANSI_W
|
||||
) else {
|
||||
XCTFail("Failed to construct Dvorak Cmd+, event on physical ANSI W key")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
XCTAssertEqual(workspace.panels.count, panelCountBefore)
|
||||
}
|
||||
|
||||
func testCmdIStillTriggersShowNotificationsShortcut() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
withTemporaryShortcut(action: .showNotifications) {
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: "i",
|
||||
charactersIgnoringModifiers: "i",
|
||||
isARepeat: false,
|
||||
keyCode: 34 // kVK_ANSI_I
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+I event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func testCmdUnshiftedSymbolDoesNotMatchDigitShortcut() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
withTemporaryShortcut(
|
||||
action: .showNotifications,
|
||||
shortcut: StoredShortcut(key: "8", command: true, shift: false, option: false, control: false)
|
||||
) {
|
||||
// Some non-US layouts can produce "*" without Shift.
|
||||
// This must not be coerced into "8" for a Cmd+8 shortcut match.
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: "*",
|
||||
charactersIgnoringModifiers: "*",
|
||||
isARepeat: false,
|
||||
keyCode: 30 // kVK_ANSI_RightBracket
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+* event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func testCmdDigitShortcutFallsBackByKeyCodeOnSymbolFirstLayouts() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
withTemporaryShortcut(
|
||||
action: .showNotifications,
|
||||
shortcut: StoredShortcut(key: "1", command: true, shift: false, option: false, control: false)
|
||||
) {
|
||||
// Symbol-first layouts (for example AZERTY) can report "&" for the ANSI 1 key.
|
||||
// Cmd+1 shortcuts should still match via keyCode fallback in this case.
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: "&",
|
||||
charactersIgnoringModifiers: "&",
|
||||
isARepeat: false,
|
||||
keyCode: 18 // kVK_ANSI_1
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+& event on ANSI 1 key")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func testCmdShiftNonDigitKeySymbolDoesNotMatchShiftedDigitShortcut() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
withTemporaryShortcut(
|
||||
action: .showNotifications,
|
||||
shortcut: StoredShortcut(key: "8", command: true, shift: true, option: false, control: false)
|
||||
) {
|
||||
// Avoid unrelated default Cmd+Shift+] handling for this assertion.
|
||||
withTemporaryShortcut(
|
||||
action: .nextSurface,
|
||||
shortcut: StoredShortcut(key: "x", command: true, shift: true, option: false, control: false)
|
||||
) {
|
||||
// On some non-US layouts, Shift+RightBracket can produce "*".
|
||||
// This must not be interpreted as Shift+8.
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command, .shift],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: "*",
|
||||
charactersIgnoringModifiers: "*",
|
||||
isARepeat: false,
|
||||
keyCode: 30 // kVK_ANSI_RightBracket
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+Shift+* event from non-digit key")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testCmdShiftDigitShortcutMatchesShiftedDigitKey() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
withTemporaryShortcut(
|
||||
action: .showNotifications,
|
||||
shortcut: StoredShortcut(key: "8", command: true, shift: true, option: false, control: false)
|
||||
) {
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command, .shift],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: "*",
|
||||
charactersIgnoringModifiers: "*",
|
||||
isARepeat: false,
|
||||
keyCode: 28 // kVK_ANSI_8
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+Shift+8 event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func testCmdShiftQuestionMarkMatchesSlashShortcut() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
withTemporaryShortcut(
|
||||
action: .triggerFlash,
|
||||
shortcut: StoredShortcut(key: "/", command: true, shift: true, option: false, control: false)
|
||||
) {
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command, .shift],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: "?",
|
||||
charactersIgnoringModifiers: "?",
|
||||
isARepeat: false,
|
||||
keyCode: 44 // kVK_ANSI_Slash
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+Shift+/ event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func testCmdShiftISOAngleBracketDoesNotMatchCommaShortcut() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
withTemporaryShortcut(
|
||||
action: .showNotifications,
|
||||
shortcut: StoredShortcut(key: ",", command: true, shift: true, option: false, control: false)
|
||||
) {
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command, .shift],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: "<",
|
||||
charactersIgnoringModifiers: "<",
|
||||
isARepeat: false,
|
||||
keyCode: 10 // kVK_ISO_Section
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+Shift+< event from ISO key")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func testCmdShiftRightBracketCanFallbackByKeyCodeOnNonUSLayouts() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
withTemporaryShortcut(action: .nextSurface) {
|
||||
// Non-US layouts can report "*" (or other symbols) for kVK_ANSI_RightBracket with Shift.
|
||||
// Shortcut matching should still allow Cmd+Shift+] via keyCode fallback.
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command, .shift],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: "*",
|
||||
charactersIgnoringModifiers: "*",
|
||||
isARepeat: false,
|
||||
keyCode: 30 // kVK_ANSI_RightBracket
|
||||
) else {
|
||||
XCTFail("Failed to construct non-US Cmd+Shift+] event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func testCmdPhysicalOWithDvorakCharactersTriggersRenameTabShortcut() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
let renameTabExpectation = expectation(description: "Expected rename tab request for semantic Cmd+R")
|
||||
var observedRenameTabWindow: NSWindow?
|
||||
let renameTabToken = NotificationCenter.default.addObserver(
|
||||
forName: .commandPaletteRenameTabRequested,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { notification in
|
||||
observedRenameTabWindow = notification.object as? NSWindow
|
||||
renameTabExpectation.fulfill()
|
||||
}
|
||||
defer { NotificationCenter.default.removeObserver(renameTabToken) }
|
||||
|
||||
let switcherExpectation = expectation(description: "Cmd+R should not trigger command palette switcher")
|
||||
switcherExpectation.isInverted = true
|
||||
let switcherToken = NotificationCenter.default.addObserver(
|
||||
forName: .commandPaletteSwitcherRequested,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { _ in
|
||||
switcherExpectation.fulfill()
|
||||
}
|
||||
defer { NotificationCenter.default.removeObserver(switcherToken) }
|
||||
|
||||
withTemporaryShortcut(action: .renameTab) {
|
||||
// Dvorak: physical ANSI "O" key can produce "r".
|
||||
// This should behave as semantic Cmd+R (rename tab), not Cmd+P.
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: "r",
|
||||
charactersIgnoringModifiers: "r",
|
||||
isARepeat: false,
|
||||
keyCode: 31 // kVK_ANSI_O
|
||||
) else {
|
||||
XCTFail("Failed to construct Dvorak Cmd+R event on physical ANSI O key")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
}
|
||||
|
||||
wait(for: [renameTabExpectation, switcherExpectation], timeout: 1.0)
|
||||
XCTAssertEqual(observedRenameTabWindow?.windowNumber, window.windowNumber)
|
||||
}
|
||||
|
||||
func testCmdPhysicalRWithDvorakCharactersTriggersCommandPaletteSwitcher() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
let switcherExpectation = expectation(description: "Expected command palette switcher request for semantic Cmd+P")
|
||||
var observedSwitcherWindow: NSWindow?
|
||||
let switcherToken = NotificationCenter.default.addObserver(
|
||||
forName: .commandPaletteSwitcherRequested,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { notification in
|
||||
observedSwitcherWindow = notification.object as? NSWindow
|
||||
switcherExpectation.fulfill()
|
||||
}
|
||||
defer { NotificationCenter.default.removeObserver(switcherToken) }
|
||||
|
||||
let renameTabExpectation = expectation(description: "Physical R on Dvorak should not trigger rename tab")
|
||||
renameTabExpectation.isInverted = true
|
||||
let renameTabToken = NotificationCenter.default.addObserver(
|
||||
forName: .commandPaletteRenameTabRequested,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { _ in
|
||||
renameTabExpectation.fulfill()
|
||||
}
|
||||
defer { NotificationCenter.default.removeObserver(renameTabToken) }
|
||||
|
||||
// Dvorak: physical ANSI "R" key can produce "p".
|
||||
// This should behave as semantic Cmd+P (palette switcher), not Cmd+R.
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [.command],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: "p",
|
||||
charactersIgnoringModifiers: "p",
|
||||
isARepeat: false,
|
||||
keyCode: 15 // kVK_ANSI_R
|
||||
) else {
|
||||
XCTFail("Failed to construct Dvorak Cmd+P event on physical ANSI R key")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
wait(for: [switcherExpectation, renameTabExpectation], timeout: 1.0)
|
||||
XCTAssertEqual(observedSwitcherWindow?.windowNumber, window.windowNumber)
|
||||
}
|
||||
|
||||
func testCmdShiftRRequestsRenameWorkspaceInCommandPalette() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
|
|
@ -684,6 +1621,9 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
|||
return
|
||||
}
|
||||
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(
|
||||
appDelegate.debugSetCommandPalettePendingOpenAge(window: window, age: 20.0),
|
||||
|
|
@ -1210,6 +2150,24 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
private func withTemporaryShortcut(
|
||||
action: KeyboardShortcutSettings.Action,
|
||||
shortcut: StoredShortcut? = nil,
|
||||
_ body: () -> Void
|
||||
) {
|
||||
let hadPersistedShortcut = UserDefaults.standard.object(forKey: action.defaultsKey) != nil
|
||||
let originalShortcut = KeyboardShortcutSettings.shortcut(for: action)
|
||||
defer {
|
||||
if hadPersistedShortcut {
|
||||
KeyboardShortcutSettings.setShortcut(originalShortcut, for: action)
|
||||
} else {
|
||||
KeyboardShortcutSettings.resetShortcut(for: action)
|
||||
}
|
||||
}
|
||||
KeyboardShortcutSettings.setShortcut(shortcut ?? action.defaultShortcut, for: action)
|
||||
body()
|
||||
}
|
||||
|
||||
private func assertEscapeKeyUpIsConsumedAfterCommandPaletteOpenRequest(
|
||||
_ openRequest: (_ appDelegate: AppDelegate, _ window: NSWindow) -> Void,
|
||||
file: StaticString = #filePath,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -423,6 +423,180 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testCmdOptionPaneSwitchPreservesFindFieldFocus() {
|
||||
runFindFocusPersistenceScenario(route: .cmdOptionArrows, useAutofocusRacePage: false)
|
||||
}
|
||||
|
||||
func testCmdCtrlPaneSwitchPreservesFindFieldFocus() {
|
||||
runFindFocusPersistenceScenario(route: .cmdCtrlLetters, useAutofocusRacePage: false)
|
||||
}
|
||||
|
||||
func testCmdOptionPaneSwitchPreservesFindFieldFocusDuringPageAutofocusRace() {
|
||||
runFindFocusPersistenceScenario(route: .cmdOptionArrows, useAutofocusRacePage: true)
|
||||
}
|
||||
|
||||
private enum FindFocusRoute {
|
||||
case cmdOptionArrows
|
||||
case cmdCtrlLetters
|
||||
}
|
||||
|
||||
private func runFindFocusPersistenceScenario(route: FindFocusRoute, useAutofocusRacePage: Bool) {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_RECORD_ONLY"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
if route == .cmdCtrlLetters {
|
||||
app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1"
|
||||
}
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
let window = app.windows.firstMatch
|
||||
XCTAssertTrue(window.waitForExistence(timeout: 10.0), "Expected main window to exist")
|
||||
|
||||
// Repro setup: split, open browser split, navigate to example.com.
|
||||
app.typeKey("d", modifierFlags: [.command])
|
||||
focusRightPaneForFindScenario(app, route: route)
|
||||
|
||||
app.typeKey("l", modifierFlags: [.command, .shift])
|
||||
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
|
||||
XCTAssertTrue(omnibar.waitForExistence(timeout: 8.0), "Expected browser omnibar after Cmd+Shift+L")
|
||||
|
||||
app.typeKey("a", modifierFlags: [.command])
|
||||
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
|
||||
if useAutofocusRacePage {
|
||||
app.typeText(autofocusRacePageURL)
|
||||
} else {
|
||||
app.typeText("example.com")
|
||||
}
|
||||
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
|
||||
|
||||
if useAutofocusRacePage {
|
||||
XCTAssertTrue(
|
||||
waitForOmnibarToContain(omnibar, value: "data:text/html", timeout: 8.0),
|
||||
"Expected browser navigation to data URL before running find flow. value=\(String(describing: omnibar.value))"
|
||||
)
|
||||
} else {
|
||||
XCTAssertTrue(
|
||||
waitForOmnibarToContainExampleDomain(omnibar, timeout: 8.0),
|
||||
"Expected browser navigation to example domain before running find flow. value=\(String(describing: omnibar.value))"
|
||||
)
|
||||
}
|
||||
|
||||
// Left terminal: Cmd+F then type "la".
|
||||
focusLeftPaneForFindScenario(app, route: route)
|
||||
XCTAssertTrue(
|
||||
waitForDataMatch(timeout: 6.0) { data in
|
||||
data["focusedPanelKind"] == "terminal"
|
||||
},
|
||||
"Expected left terminal pane to be focused before terminal find. data=\(String(describing: loadData()))"
|
||||
)
|
||||
app.typeKey("f", modifierFlags: [.command])
|
||||
app.typeText("la")
|
||||
|
||||
// Right browser: Cmd+F then type "am".
|
||||
focusRightPaneForFindScenario(app, route: route)
|
||||
XCTAssertTrue(
|
||||
waitForDataMatch(timeout: 6.0) { data in
|
||||
data["lastMoveDirection"] == "right"
|
||||
&& data["focusedPanelKind"] == "browser"
|
||||
&& data["terminalFindNeedle"] == "la"
|
||||
},
|
||||
"Expected terminal find query to persist as 'la' after focusing browser pane. data=\(String(describing: loadData()))"
|
||||
)
|
||||
app.typeKey("f", modifierFlags: [.command])
|
||||
app.typeText("am")
|
||||
|
||||
if useAutofocusRacePage {
|
||||
XCTAssertTrue(
|
||||
waitForOmnibarToContain(omnibar, value: "#focused", timeout: 5.0),
|
||||
"Expected autofocus race page to signal focus handoff via URL hash. value=\(String(describing: omnibar.value))"
|
||||
)
|
||||
}
|
||||
|
||||
// Left terminal: typing should keep going into terminal find field.
|
||||
focusLeftPaneForFindScenario(app, route: route)
|
||||
XCTAssertTrue(
|
||||
waitForDataMatch(timeout: 6.0) { data in
|
||||
data["lastMoveDirection"] == "left"
|
||||
&& data["focusedPanelKind"] == "terminal"
|
||||
&& data["browserFindNeedle"] == "am"
|
||||
},
|
||||
"Expected browser find query to persist as 'am' after returning left. data=\(String(describing: loadData()))"
|
||||
)
|
||||
app.typeText("foo")
|
||||
|
||||
// Right browser: typing should keep going into browser find field.
|
||||
focusRightPaneForFindScenario(app, route: route)
|
||||
XCTAssertTrue(
|
||||
waitForDataMatch(timeout: 6.0) { data in
|
||||
data["lastMoveDirection"] == "right"
|
||||
&& data["focusedPanelKind"] == "browser"
|
||||
&& data["terminalFindNeedle"] == "lafoo"
|
||||
},
|
||||
"Expected terminal find query to stay focused and become 'lafoo'. data=\(String(describing: loadData()))"
|
||||
)
|
||||
app.typeText("do")
|
||||
|
||||
// Move left once more so the recorder captures browser find state after typing.
|
||||
focusLeftPaneForFindScenario(app, route: route)
|
||||
XCTAssertTrue(
|
||||
waitForDataMatch(timeout: 6.0) { data in
|
||||
data["lastMoveDirection"] == "left"
|
||||
&& data["focusedPanelKind"] == "terminal"
|
||||
&& data["browserFindNeedle"] == "amdo"
|
||||
},
|
||||
"Expected browser find query to stay focused and become 'amdo'. data=\(String(describing: loadData()))"
|
||||
)
|
||||
}
|
||||
|
||||
private func focusLeftPaneForFindScenario(_ app: XCUIApplication, route: FindFocusRoute) {
|
||||
switch route {
|
||||
case .cmdOptionArrows:
|
||||
app.typeKey(XCUIKeyboardKey.leftArrow.rawValue, modifierFlags: [.command, .option])
|
||||
case .cmdCtrlLetters:
|
||||
app.typeKey("h", modifierFlags: [.command, .control])
|
||||
}
|
||||
}
|
||||
|
||||
private func focusRightPaneForFindScenario(_ app: XCUIApplication, route: FindFocusRoute) {
|
||||
switch route {
|
||||
case .cmdOptionArrows:
|
||||
app.typeKey(XCUIKeyboardKey.rightArrow.rawValue, modifierFlags: [.command, .option])
|
||||
case .cmdCtrlLetters:
|
||||
app.typeKey("l", modifierFlags: [.command, .control])
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForOmnibarToContainExampleDomain(_ omnibar: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let value = (omnibar.value as? String) ?? ""
|
||||
if value.contains("example.com") || value.contains("example.org") {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
let value = (omnibar.value as? String) ?? ""
|
||||
return value.contains("example.com") || value.contains("example.org")
|
||||
}
|
||||
|
||||
private func waitForOmnibarToContain(_ omnibar: XCUIElement, value expectedSubstring: String, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let value = (omnibar.value as? String) ?? ""
|
||||
if value.contains(expectedSubstring) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
let value = (omnibar.value as? String) ?? ""
|
||||
return value.contains(expectedSubstring)
|
||||
}
|
||||
|
||||
private var autofocusRacePageURL: String {
|
||||
"data:text/html,%3Cinput%20id%3D%22q%22%3E%3Cscript%3EsetTimeout%28function%28%29%7Bdocument.getElementById%28%22q%22%29.focus%28%29%3Blocation.hash%3D%22focused%22%3B%7D%2C700%29%3B%3C%2Fscript%3E"
|
||||
}
|
||||
|
||||
private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) {
|
||||
app.launch()
|
||||
XCTAssertTrue(
|
||||
|
|
|
|||
BIN
docs/assets/split-cwd-inheritance-demo.gif
Normal file
BIN
docs/assets/split-cwd-inheritance-demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 829 KiB |
|
|
@ -10,19 +10,21 @@ Use this skill for browser tasks inside cmux webviews.
|
|||
## Core Workflow
|
||||
|
||||
1. Open or target a browser surface.
|
||||
2. Snapshot (`--interactive`) to get fresh element refs.
|
||||
3. Act with refs (`click`, `fill`, `type`, `select`, `press`).
|
||||
4. Wait for state changes.
|
||||
5. Re-snapshot after DOM/navigation changes.
|
||||
2. Verify navigation with `get url` before waiting or snapshotting.
|
||||
3. Snapshot (`--interactive`) to get fresh element refs.
|
||||
4. Act with refs (`click`, `fill`, `type`, `select`, `press`).
|
||||
5. Wait for state changes.
|
||||
6. Re-snapshot after DOM/navigation changes.
|
||||
|
||||
```bash
|
||||
cmux browser open https://example.com --json
|
||||
cmux --json browser open https://example.com
|
||||
# use returned surface ref, for example: surface:7
|
||||
|
||||
cmux browser surface:7 get url
|
||||
cmux browser surface:7 wait --load-state complete --timeout-ms 15000
|
||||
cmux browser surface:7 snapshot --interactive
|
||||
cmux browser surface:7 fill e1 "hello"
|
||||
cmux browser surface:7 click e2 --snapshot-after --json
|
||||
cmux browser surface:7 wait --load-state complete --timeout-ms 15000
|
||||
cmux --json browser surface:7 click e2 --snapshot-after
|
||||
cmux browser surface:7 snapshot --interactive
|
||||
```
|
||||
|
||||
|
|
@ -58,11 +60,13 @@ cmux browser <surface> wait --function "document.readyState === 'complete'" --ti
|
|||
### Form Submit
|
||||
|
||||
```bash
|
||||
cmux browser open https://example.com/signup --json
|
||||
cmux --json browser open https://example.com/signup
|
||||
cmux browser surface:7 get url
|
||||
cmux browser surface:7 wait --load-state complete --timeout-ms 15000
|
||||
cmux browser surface:7 snapshot --interactive
|
||||
cmux browser surface:7 fill e1 "Jane Doe"
|
||||
cmux browser surface:7 fill e2 "jane@example.com"
|
||||
cmux browser surface:7 click e3 --snapshot-after --json
|
||||
cmux --json browser surface:7 click e3 --snapshot-after
|
||||
cmux browser surface:7 wait --url-contains "/welcome" --timeout-ms 15000
|
||||
cmux browser surface:7 snapshot --interactive
|
||||
```
|
||||
|
|
@ -77,13 +81,16 @@ cmux browser surface:7 get value e11 --json
|
|||
### Stable Agent Loop (Recommended)
|
||||
|
||||
```bash
|
||||
# snapshot -> action -> wait -> snapshot
|
||||
cmux browser surface:7 snapshot --interactive
|
||||
cmux browser surface:7 click e5 --snapshot-after --json
|
||||
# navigate -> verify -> wait -> snapshot -> action -> snapshot
|
||||
cmux browser surface:7 get url
|
||||
cmux browser surface:7 wait --load-state complete --timeout-ms 15000
|
||||
cmux browser surface:7 snapshot --interactive
|
||||
cmux --json browser surface:7 click e5 --snapshot-after
|
||||
cmux browser surface:7 snapshot --interactive
|
||||
```
|
||||
|
||||
If `get url` is empty or `about:blank`, navigate first instead of waiting on load state.
|
||||
|
||||
## Deep-Dive References
|
||||
|
||||
| Reference | When to Use |
|
||||
|
|
@ -114,3 +121,21 @@ These commands currently return `not_supported` because they rely on Chrome/CDP-
|
|||
- low-level raw input injection
|
||||
|
||||
Use supported high-level commands (`click`, `fill`, `press`, `scroll`, `wait`, `snapshot`) instead.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `js_error` on `snapshot --interactive` or `eval`
|
||||
|
||||
Some complex pages can reject or break the JavaScript used for rich snapshots and ad-hoc evaluation.
|
||||
|
||||
Recovery steps:
|
||||
|
||||
```bash
|
||||
cmux browser surface:7 get url
|
||||
cmux browser surface:7 get text body
|
||||
cmux browser surface:7 get html body
|
||||
```
|
||||
|
||||
- Use `get url` first so you know whether the page actually navigated.
|
||||
- Fall back to `get text body` or `get html body` when `snapshot --interactive` or `eval` returns `js_error`.
|
||||
- If the page is still failing, navigate to a simpler intermediate page, then retry the task from there.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ This maps common `agent-browser` usage to `cmux browser` usage.
|
|||
- `agent-browser fill <ref> <text>` -> `cmux browser <surface> fill <ref> <text>`
|
||||
- `agent-browser type <ref> <text>` -> `cmux browser <surface> type <ref> <text>`
|
||||
- `agent-browser select <ref> <value>` -> `cmux browser <surface> select <ref> <value>`
|
||||
- `agent-browser get text <ref>` -> `cmux browser <surface> get text <ref>`
|
||||
- `agent-browser get text <ref>` -> `cmux browser <surface> get text <ref-or-selector>`
|
||||
- `agent-browser get url` -> `cmux browser <surface> get url`
|
||||
- `agent-browser get title` -> `cmux browser <surface> get title`
|
||||
|
||||
|
|
@ -34,7 +34,13 @@ cmux browser <surface> get url|title
|
|||
```bash
|
||||
cmux browser <surface> snapshot --interactive
|
||||
cmux browser <surface> snapshot --interactive --compact --max-depth 3
|
||||
cmux browser <surface> get text|html|value|attr|count|box|styles ...
|
||||
cmux browser <surface> get text body
|
||||
cmux browser <surface> get html body
|
||||
cmux browser <surface> get value "#email"
|
||||
cmux browser <surface> get attr "#email" --attr placeholder
|
||||
cmux browser <surface> get count ".row"
|
||||
cmux browser <surface> get box "#submit"
|
||||
cmux browser <surface> get styles "#submit" --property color
|
||||
cmux browser <surface> eval '<js>'
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ if [ -f "$STATE_FILE" ]; then
|
|||
fi
|
||||
|
||||
cmux browser "$SURFACE" goto "$DASHBOARD_URL"
|
||||
cmux browser "$SURFACE" get url
|
||||
cmux browser "$SURFACE" wait --load-state complete --timeout-ms 15000
|
||||
cmux browser "$SURFACE" snapshot --interactive
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ URL="${1:-https://example.com/form}"
|
|||
SURFACE="${2:-surface:1}"
|
||||
|
||||
cmux browser "$SURFACE" goto "$URL"
|
||||
cmux browser "$SURFACE" get url
|
||||
cmux browser "$SURFACE" wait --load-state complete --timeout-ms 15000
|
||||
cmux browser "$SURFACE" snapshot --interactive
|
||||
|
||||
|
|
|
|||
51
tests/regression_helpers.py
Normal file
51
tests/regression_helpers.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Shared helpers for static regression tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
git = shutil.which("git")
|
||||
if git is None:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[git, "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=2,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return Path(__file__).resolve().parents[1]
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def extract_block(source: str, signature: str) -> str:
|
||||
# Targeted helper for this regression suite: assumes braces in the matched
|
||||
# block are structural (not inside strings/comments/character literals).
|
||||
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}")
|
||||
203
tests/test_browser_find_overlay_portal_regression.py
Normal file
203
tests/test_browser_find_overlay_portal_regression.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
#!/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,6 +1,6 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
E2E: focusing a panel clears its notification and triggers a flash.
|
||||
E2E: focusing a panel preserves its notification and triggers a flash.
|
||||
|
||||
Note: This uses the socket focus command (no assistive access needed).
|
||||
"""
|
||||
|
|
@ -74,8 +74,12 @@ def main() -> int:
|
|||
client.send("x")
|
||||
time.sleep(0.2)
|
||||
|
||||
if not wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
|
||||
print("FAIL: Notification did not become read after focus")
|
||||
if wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
|
||||
print("FAIL: Notification became read after focus")
|
||||
return 1
|
||||
items = client.list_notifications()
|
||||
if not any(item["surface_id"] == surface_id and not item["is_read"] for item in items):
|
||||
print("FAIL: Notification did not remain present and unread after focus")
|
||||
return 1
|
||||
|
||||
final_flash = client.flash_count(term_b)
|
||||
|
|
@ -93,7 +97,7 @@ def main() -> int:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
print("PASS: Focus clears notification and flashes panel")
|
||||
print("PASS: Focus preserves notification and flashes panel")
|
||||
return 0
|
||||
except (cmuxError, RuntimeError) as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
|
|
|
|||
105
tests/test_issue_666_sidebar_branch_checkout_refresh.py
Normal file
105
tests/test_issue_666_sidebar_branch_checkout_refresh.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
#!/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())
|
||||
162
tests/test_issue_952_socket_listener_recovery.py
Normal file
162
tests/test_issue_952_socket_listener_recovery.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
#!/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())
|
||||
|
|
@ -58,6 +58,15 @@ def wait_for_flash_count(client: cmux, surface: str, minimum: int = 1, timeout:
|
|||
return last
|
||||
|
||||
|
||||
def wait_for_current_workspace(client: cmux, expected: str, timeout: float = 2.0) -> bool:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
if client.current_workspace() == expected:
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return client.current_workspace() == expected
|
||||
|
||||
|
||||
def ensure_two_surfaces(client: cmux) -> list[tuple[int, str, bool]]:
|
||||
surfaces = client.list_surfaces()
|
||||
if len(surfaces) < 2:
|
||||
|
|
@ -215,8 +224,8 @@ def test_rxvt_notification_osc777(client: cmux) -> TestResult:
|
|||
return result
|
||||
|
||||
|
||||
def test_mark_read_on_focus_change(client: cmux) -> TestResult:
|
||||
result = TestResult("Mark Read On Panel Focus")
|
||||
def test_preserve_unread_on_focus_change(client: cmux) -> TestResult:
|
||||
result = TestResult("Preserve Unread On Panel Focus")
|
||||
try:
|
||||
client.clear_notifications()
|
||||
client.reset_flash_counts()
|
||||
|
|
@ -229,81 +238,88 @@ def test_mark_read_on_focus_change(client: cmux) -> TestResult:
|
|||
|
||||
client.set_app_focus(False)
|
||||
client.notify_surface(other[0], "focusread")
|
||||
time.sleep(0.1)
|
||||
items = wait_for_notifications(client, 1)
|
||||
target = next((n for n in items if n["surface_id"] == other[1]), None)
|
||||
if target is None or target["is_read"]:
|
||||
result.failure("Expected unread notification for target surface before focus")
|
||||
return result
|
||||
|
||||
client.set_app_focus(True)
|
||||
client.focus_surface(other[0])
|
||||
time.sleep(0.1)
|
||||
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
|
||||
if count < 1:
|
||||
result.failure("Expected flash on panel focus")
|
||||
return result
|
||||
|
||||
items = client.list_notifications()
|
||||
target = next((n for n in items if n["surface_id"] == other[1]), None)
|
||||
if target is None:
|
||||
result.failure("Expected notification for target surface")
|
||||
elif not target["is_read"]:
|
||||
result.failure("Expected notification to be marked read on focus")
|
||||
elif target["is_read"]:
|
||||
result.failure("Expected notification to remain unread on focus")
|
||||
else:
|
||||
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
|
||||
if count < 1:
|
||||
result.failure("Expected flash on panel focus dismissal")
|
||||
else:
|
||||
result.success("Notification marked read on focus")
|
||||
result.success("Notification persisted across panel focus")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
||||
|
||||
def test_mark_read_on_app_active(client: cmux) -> TestResult:
|
||||
result = TestResult("Mark Read On App Active")
|
||||
def test_preserve_unread_on_app_active(client: cmux) -> TestResult:
|
||||
result = TestResult("Preserve Unread On App Active")
|
||||
try:
|
||||
client.clear_notifications()
|
||||
client.set_app_focus(False)
|
||||
client.notify("activate")
|
||||
time.sleep(0.1)
|
||||
|
||||
items = client.list_notifications()
|
||||
items = wait_for_notifications(client, 1)
|
||||
if not items or items[0]["is_read"]:
|
||||
result.failure("Expected unread notification before activation")
|
||||
return result
|
||||
|
||||
client.simulate_app_active()
|
||||
time.sleep(0.1)
|
||||
|
||||
items = client.list_notifications()
|
||||
items = wait_for_notifications(client, 1)
|
||||
if not items:
|
||||
result.failure("Expected notification to remain after activation")
|
||||
elif not items[0]["is_read"]:
|
||||
result.failure("Expected notification to be marked read on app active")
|
||||
elif items[0]["is_read"]:
|
||||
result.failure("Expected notification to remain unread on app active")
|
||||
else:
|
||||
result.success("Notification marked read on app active")
|
||||
result.success("Notification persisted across app activation")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
||||
|
||||
def test_mark_read_on_tab_switch(client: cmux) -> TestResult:
|
||||
result = TestResult("Mark Read On Tab Switch")
|
||||
def test_preserve_unread_on_tab_switch(client: cmux) -> TestResult:
|
||||
result = TestResult("Preserve Unread On Tab Switch")
|
||||
try:
|
||||
client.clear_notifications()
|
||||
client.set_app_focus(False)
|
||||
tab1 = client.current_workspace()
|
||||
client.notify("tabswitch")
|
||||
time.sleep(0.1)
|
||||
items = wait_for_notifications(client, 1)
|
||||
target = next((n for n in items if n["workspace_id"] == tab1), None)
|
||||
if target is None or target["is_read"]:
|
||||
result.failure("Expected unread notification for original tab before switching")
|
||||
return result
|
||||
|
||||
tab2 = client.new_workspace()
|
||||
time.sleep(0.1)
|
||||
if not wait_for_current_workspace(client, tab2):
|
||||
result.failure("Expected new workspace to become selected")
|
||||
return result
|
||||
|
||||
client.set_app_focus(True)
|
||||
client.select_workspace(tab1)
|
||||
time.sleep(0.1)
|
||||
if not wait_for_current_workspace(client, tab1):
|
||||
result.failure("Expected original workspace to become selected again")
|
||||
return result
|
||||
|
||||
items = client.list_notifications()
|
||||
items = wait_for_notifications(client, 1)
|
||||
target = next((n for n in items if n["workspace_id"] == tab1), None)
|
||||
if target is None:
|
||||
result.failure("Expected notification for original tab")
|
||||
elif not target["is_read"]:
|
||||
result.failure("Expected notification to be marked read on tab switch")
|
||||
elif target["is_read"]:
|
||||
result.failure("Expected notification to remain unread on tab switch")
|
||||
else:
|
||||
result.success("Notification marked read on tab switch")
|
||||
result.success("Notification persisted across tab switch")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
|
@ -371,11 +387,20 @@ def test_focus_on_notification_click(client: cmux) -> TestResult:
|
|||
result.failure("Expected notification surface to be focused")
|
||||
return result
|
||||
|
||||
items = client.list_notifications()
|
||||
notification = next((n for n in items if n["surface_id"] == other[1]), None)
|
||||
if notification is None:
|
||||
result.failure("Expected notification to remain listed after notification click")
|
||||
return result
|
||||
if notification["is_read"]:
|
||||
result.failure("Expected notification click to preserve unread state")
|
||||
return result
|
||||
|
||||
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
|
||||
if count < 1:
|
||||
result.failure(f"Expected flash count >= 1, got {count}")
|
||||
else:
|
||||
result.success("Notification click focuses and flashes panel")
|
||||
result.success("Notification click focuses, flashes, and preserves unread state")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
|
@ -455,9 +480,9 @@ def run_tests() -> int:
|
|||
results.append(test_kitty_notification_simple(client))
|
||||
results.append(test_kitty_notification_chunked(client))
|
||||
results.append(test_rxvt_notification_osc777(client))
|
||||
results.append(test_mark_read_on_focus_change(client))
|
||||
results.append(test_mark_read_on_app_active(client))
|
||||
results.append(test_mark_read_on_tab_switch(client))
|
||||
results.append(test_preserve_unread_on_focus_change(client))
|
||||
results.append(test_preserve_unread_on_app_active(client))
|
||||
results.append(test_preserve_unread_on_tab_switch(client))
|
||||
results.append(test_flash_on_tab_switch(client))
|
||||
results.append(test_focus_on_notification_click(client))
|
||||
results.append(test_restore_focus_on_tab_switch(client))
|
||||
|
|
|
|||
213
tests/test_split_cwd_inheritance.py
Normal file
213
tests/test_split_cwd_inheritance.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
End-to-end test for split CWD inheritance.
|
||||
|
||||
Verifies that new split panes and new workspace tabs inherit the current
|
||||
working directory from the source terminal.
|
||||
|
||||
Requires:
|
||||
- cmux running with allowAll socket mode
|
||||
- bash shell integration sourced (cmux-bash-integration.bash)
|
||||
|
||||
Run with a tagged instance:
|
||||
CMUX_TAG=<tag> python3 tests/test_split_cwd_inheritance.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from cmux import cmux # noqa: E402
|
||||
|
||||
|
||||
def _parse_sidebar_state(text: str) -> dict[str, str]:
|
||||
data: dict[str, str] = {}
|
||||
for raw in (text or "").splitlines():
|
||||
line = raw.rstrip("\n")
|
||||
if not line or line.startswith(" "):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
data[k.strip()] = v.strip()
|
||||
return data
|
||||
|
||||
|
||||
def _wait_for(predicate, timeout: float, interval: float, label: str):
|
||||
start = time.time()
|
||||
last_error: Exception | None = None
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
value = predicate()
|
||||
if value:
|
||||
return value
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
time.sleep(interval)
|
||||
extra = ""
|
||||
if last_error is not None:
|
||||
extra = f" Last error: {last_error}"
|
||||
raise AssertionError(f"Timed out waiting for {label}.{extra}")
|
||||
|
||||
|
||||
def _wait_for_focused_cwd(
|
||||
client: cmux,
|
||||
expected: str,
|
||||
timeout: float = 12.0,
|
||||
exclude_panel: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Wait for focused_cwd to match expected.
|
||||
|
||||
If exclude_panel is given, also require that focused_panel differs from
|
||||
that value — ensuring we're checking the *new* pane, not the original.
|
||||
"""
|
||||
def pred():
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
cwd = state.get("focused_cwd", "")
|
||||
if cwd != expected:
|
||||
return None
|
||||
if exclude_panel and state.get("focused_panel", "") == exclude_panel:
|
||||
return None
|
||||
return state
|
||||
label = f"focused_cwd={expected!r}"
|
||||
if exclude_panel:
|
||||
label += f" (panel != {exclude_panel})"
|
||||
return _wait_for(pred, timeout=timeout, interval=0.3, label=label)
|
||||
|
||||
|
||||
def _send_cd_and_wait(
|
||||
client: cmux,
|
||||
target: str,
|
||||
timeout: float = 12.0,
|
||||
) -> dict[str, str]:
|
||||
"""cd to target and wait for sidebar focused_cwd to reflect it."""
|
||||
client.send(f"cd {target}\n")
|
||||
return _wait_for_focused_cwd(client, target, timeout=timeout)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tag = os.environ.get("CMUX_TAG", "")
|
||||
|
||||
socket_path = None
|
||||
if tag:
|
||||
socket_path = f"/tmp/cmux-debug-{tag}.sock"
|
||||
client = cmux(socket_path=socket_path)
|
||||
client.connect()
|
||||
|
||||
# Use resolved paths to avoid /tmp -> /private/tmp symlink mismatch on macOS
|
||||
test_dir_a = str(Path("/tmp/cmux_split_cwd_test_a").resolve())
|
||||
test_dir_b = str(Path("/tmp/cmux_split_cwd_test_b").resolve())
|
||||
os.makedirs(test_dir_a, exist_ok=True)
|
||||
os.makedirs(test_dir_b, exist_ok=True)
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
def check(name: str, condition: bool, detail: str = ""):
|
||||
nonlocal passed, failed
|
||||
if condition:
|
||||
print(f" PASS {name}")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL {name}{': ' + detail if detail else ''}")
|
||||
failed += 1
|
||||
|
||||
print("=== Split CWD Inheritance Tests ===")
|
||||
|
||||
# --- Setup: cd to test_dir_a in workspace 1 ---
|
||||
print(" [setup] cd to test_dir_a and wait for shell integration...")
|
||||
_send_cd_and_wait(client, test_dir_a)
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
check("setup: focused_cwd is test_dir_a", state.get("focused_cwd") == test_dir_a,
|
||||
f"got {state.get('focused_cwd')!r}")
|
||||
|
||||
# --- Test 1: New split inherits test_dir_a ---
|
||||
print(" [test1] creating right split from test_dir_a...")
|
||||
# Record the original panel so we can verify focus moves to the NEW pane.
|
||||
original_panel = state.get("focused_panel", "")
|
||||
split_result = client.new_split("right")
|
||||
if not split_result:
|
||||
check("split created", False)
|
||||
print(f"\n{passed} passed, {failed} failed")
|
||||
client.close()
|
||||
return 1
|
||||
check("split created", True)
|
||||
|
||||
# Wait for the NEW pane (different panel ID) to report test_dir_a.
|
||||
time.sleep(4) # wait for new bash to start + run PROMPT_COMMAND
|
||||
try:
|
||||
state = _wait_for_focused_cwd(
|
||||
client, test_dir_a, timeout=15.0, exclude_panel=original_panel,
|
||||
)
|
||||
new_panel = state.get("focused_panel", "")
|
||||
check("test1: focus moved to new pane", new_panel != original_panel,
|
||||
f"original={original_panel!r}, current={new_panel!r}")
|
||||
check("test1: split inherited test_dir_a",
|
||||
state.get("focused_cwd") == test_dir_a,
|
||||
f"focused_cwd={state.get('focused_cwd')!r}")
|
||||
except AssertionError:
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
check("test1: split inherited test_dir_a", False,
|
||||
f"focused_cwd={state.get('focused_cwd')!r}, focused_panel={state.get('focused_panel')!r}")
|
||||
|
||||
# --- Test 2: New workspace tab inherits CWD ---
|
||||
# First cd to test_dir_b so we have a different dir to inherit
|
||||
print(" [test2] cd to test_dir_b, then creating new workspace tab...")
|
||||
_send_cd_and_wait(client, test_dir_b)
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
original_tab = state.get("tab", "")
|
||||
|
||||
tab_result = client.new_tab()
|
||||
if not tab_result:
|
||||
check("new tab created", False)
|
||||
print(f"\n{passed} passed, {failed} failed")
|
||||
client.close()
|
||||
return 1
|
||||
check("new tab created", True)
|
||||
|
||||
# New workspace should be a different tab AND inherit test_dir_b
|
||||
time.sleep(4)
|
||||
try:
|
||||
def _new_tab_with_cwd():
|
||||
s = _parse_sidebar_state(client.sidebar_state())
|
||||
tab_id = s.get("tab", "")
|
||||
cwd = s.get("focused_cwd", "")
|
||||
if tab_id != original_tab and cwd == test_dir_b:
|
||||
return s
|
||||
return None
|
||||
|
||||
state = _wait_for(
|
||||
_new_tab_with_cwd, timeout=15.0, interval=0.3,
|
||||
label=f"new tab with focused_cwd={test_dir_b!r}",
|
||||
)
|
||||
check("test2: focus moved to new tab", state.get("tab") != original_tab,
|
||||
f"original={original_tab!r}, current={state.get('tab')!r}")
|
||||
check("test2: new workspace inherited test_dir_b",
|
||||
state.get("focused_cwd") == test_dir_b,
|
||||
f"focused_cwd={state.get('focused_cwd')!r}")
|
||||
except AssertionError:
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
check("test2: new workspace inherited test_dir_b", False,
|
||||
f"focused_cwd={state.get('focused_cwd')!r}, tab={state.get('tab')!r}")
|
||||
|
||||
print(f"\n{passed} passed, {failed} failed")
|
||||
|
||||
client.close()
|
||||
|
||||
# Cleanup
|
||||
for d in [test_dir_a, test_dir_b]:
|
||||
try:
|
||||
os.rmdir(d)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -164,6 +164,12 @@ def main() -> int:
|
|||
)
|
||||
_must(bool(workspace), f"Expected workspace handle from identify: {identify}")
|
||||
|
||||
blank_opened = _run_cli_json(cli, ["browser", "open", "about:blank", "--workspace", workspace])
|
||||
blank_surface = str(blank_opened.get("surface_ref") or blank_opened.get("surface_id") or "")
|
||||
_must(bool(blank_surface), f"Expected about:blank browser open to return a surface: {blank_opened}")
|
||||
blank_snapshot = _run_cli_text(cli, ["browser", blank_surface, "snapshot", "--interactive"])
|
||||
_must("about:blank" in blank_snapshot and "get url" in blank_snapshot, f"Expected empty snapshot diagnostics for about:blank: {blank_snapshot!r}")
|
||||
|
||||
opened_routed = _run_cli_json(cli, ["browser", "open", page_url, "--workspace", workspace])
|
||||
routed_surface = str(opened_routed.get("surface_ref") or opened_routed.get("surface_id") or "")
|
||||
_must(bool(routed_surface), f"browser open --workspace returned no surface handle: {opened_routed}")
|
||||
|
|
|
|||
146
tests_v2/test_browser_cli_wait_and_screenshot.py
Normal file
146
tests_v2/test_browser_cli_wait_and_screenshot.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: browser wait/snapshot and screenshot CLI return usable file locations."""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser(
|
||||
"~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux"
|
||||
)
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(
|
||||
os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"),
|
||||
recursive=True,
|
||||
)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli(cli: str, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
cmd = [cli, "--socket", SOCKET_PATH, *args]
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
if proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
|
||||
return proc
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cli = _find_cli_binary()
|
||||
|
||||
with cmux(SOCKET_PATH) as c:
|
||||
opened = c._call("browser.open_split", {"url": "about:blank"}) or {}
|
||||
target = str(opened.get("surface_id") or opened.get("surface_ref") or "")
|
||||
_must(target != "", f"browser.open_split returned no surface handle: {opened}")
|
||||
|
||||
html = """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><title>cmux-browser-cli-regression</title></head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>browser cli regression</h1>
|
||||
<p id="status">ready</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
""".strip()
|
||||
data_url = "data:text/html;charset=utf-8," + urllib.parse.quote(html)
|
||||
c._call("browser.navigate", {"surface_id": target, "url": data_url})
|
||||
|
||||
wait_proc = _run_cli(
|
||||
cli,
|
||||
"browser",
|
||||
target,
|
||||
"wait",
|
||||
"--load-state",
|
||||
"interactive",
|
||||
"--timeout-ms",
|
||||
"5000",
|
||||
)
|
||||
_must(wait_proc.stdout.strip() == "OK", f"Expected browser wait OK output: {wait_proc.stdout!r}")
|
||||
|
||||
snapshot_payload = c._call("browser.snapshot", {"surface_id": target}) or {}
|
||||
refs = snapshot_payload.get("refs") or {}
|
||||
_must(isinstance(refs, dict) and len(refs) > 0, f"Expected snapshot refs for ref-based wait coverage: {snapshot_payload}")
|
||||
ref_selector = str(next(iter(refs.keys())))
|
||||
ref_wait_proc = _run_cli(
|
||||
cli,
|
||||
"browser",
|
||||
target,
|
||||
"wait",
|
||||
"--selector",
|
||||
ref_selector,
|
||||
"--timeout-ms",
|
||||
"2000",
|
||||
)
|
||||
_must(ref_wait_proc.stdout.strip() == "OK", f"Expected browser wait to resolve snapshot refs: {ref_wait_proc.stdout!r}")
|
||||
|
||||
snapshot_proc = _run_cli(cli, "browser", target, "snapshot", "--compact")
|
||||
_must(
|
||||
snapshot_proc.stdout.strip().startswith("- document"),
|
||||
f"Expected snapshot command to succeed with structured output: {snapshot_proc.stdout!r}",
|
||||
)
|
||||
|
||||
screenshot_json_proc = _run_cli(cli, "browser", target, "screenshot", "--json")
|
||||
screenshot_json_text = screenshot_json_proc.stdout.strip()
|
||||
payload = json.loads(screenshot_json_text or "{}")
|
||||
|
||||
_must("\\/" not in screenshot_json_text, f"Expected screenshot JSON without escaped slashes: {screenshot_json_text!r}")
|
||||
_must("png_base64" not in payload, f"Expected screenshot JSON to omit png_base64 when file location is available: {payload}")
|
||||
|
||||
screenshot_path = str(payload.get("path") or "")
|
||||
screenshot_url = str(payload.get("url") or "")
|
||||
_must(screenshot_path.startswith("/"), f"Expected screenshot path in JSON payload: {payload}")
|
||||
_must(screenshot_url.startswith("file://"), f"Expected screenshot file URL in JSON payload: {payload}")
|
||||
_must(Path(screenshot_path).is_file(), f"Expected screenshot file to exist: {payload}")
|
||||
|
||||
out_dir = Path(tempfile.mkdtemp(prefix="cmux-browser-screenshot-cli-")) / "nested" / "dir"
|
||||
out_path = out_dir / "capture.png"
|
||||
screenshot_out_proc = _run_cli(
|
||||
cli,
|
||||
"browser",
|
||||
target,
|
||||
"screenshot",
|
||||
"--out",
|
||||
str(out_path),
|
||||
)
|
||||
_must(screenshot_out_proc.stdout.strip() == f"OK {out_path}", f"Expected --out to print the requested path: {screenshot_out_proc.stdout!r}")
|
||||
_must("file://" not in screenshot_out_proc.stdout, f"Expected --out to print a path, not a file URL: {screenshot_out_proc.stdout!r}")
|
||||
_must(out_path.is_file(), f"Expected --out screenshot file to exist: {out_path}")
|
||||
|
||||
print("PASS: browser CLI wait/snapshot and screenshot output work end-to-end")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
34
tests_v2/test_browser_skill_get_selector_docs.py
Normal file
34
tests_v2/test_browser_skill_get_selector_docs.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression checks for cmux-browser get selector examples."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmuxError
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
COMMANDS = ROOT / "skills/cmux-browser/references/commands.md"
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
commands = COMMANDS.read_text(encoding="utf-8")
|
||||
|
||||
_must("`agent-browser get text <ref>` -> `cmux browser <surface> get text <ref-or-selector>`" in commands, "Expected get text mapping to mention selector support")
|
||||
_must("cmux browser <surface> get text body" in commands, "Expected get text body example")
|
||||
_must("cmux browser <surface> get html body" in commands, "Expected get html body example")
|
||||
_must('cmux browser <surface> get value "#email"' in commands, "Expected get value selector example")
|
||||
_must('cmux browser <surface> get attr "#email" --attr placeholder' in commands, "Expected get attr selector example")
|
||||
_must("cmux browser <surface> get text|html|value|attr|count|box|styles ..." not in commands, "Unexpected bare get example block")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
35
tests_v2/test_browser_skill_js_error_docs.py
Normal file
35
tests_v2/test_browser_skill_js_error_docs.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression checks for cmux-browser js_error troubleshooting docs."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmuxError
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SKILL = ROOT / "skills/cmux-browser/SKILL.md"
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
skill = SKILL.read_text(encoding="utf-8")
|
||||
|
||||
_must("## Troubleshooting" in skill, "Expected Troubleshooting section in cmux-browser skill")
|
||||
_must("### `js_error` on `snapshot --interactive` or `eval`" in skill, "Expected js_error troubleshooting heading")
|
||||
_must("cmux browser surface:7 get url" in skill, "Expected get url recovery step")
|
||||
_must("cmux browser surface:7 get text body" in skill, "Expected get text body recovery step")
|
||||
_must("cmux browser surface:7 get html body" in skill, "Expected get html body recovery step")
|
||||
_must("when `snapshot --interactive` or `eval` returns `js_error`" in skill, "Expected js_error fallback guidance")
|
||||
_must("navigate to a simpler intermediate page" in skill, "Expected simpler-page fallback guidance")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
E2E: focusing a panel clears its notification and triggers a flash.
|
||||
E2E: focusing a panel preserves its notification and triggers a flash.
|
||||
|
||||
Note: This uses the socket focus command (no assistive access needed).
|
||||
"""
|
||||
|
|
@ -74,8 +74,12 @@ def main() -> int:
|
|||
client.send("x")
|
||||
time.sleep(0.2)
|
||||
|
||||
if not wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
|
||||
print("FAIL: Notification did not become read after focus")
|
||||
if wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
|
||||
print("FAIL: Notification became read after focus")
|
||||
return 1
|
||||
items = client.list_notifications()
|
||||
if not any(item["surface_id"] == surface_id and not item["is_read"] for item in items):
|
||||
print("FAIL: Notification did not remain present and unread after focus")
|
||||
return 1
|
||||
|
||||
final_flash = client.flash_count(term_b)
|
||||
|
|
@ -93,7 +97,7 @@ def main() -> int:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
print("PASS: Focus clears notification and flashes panel")
|
||||
print("PASS: Focus preserves notification and flashes panel")
|
||||
return 0
|
||||
except (cmuxError, RuntimeError) as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
|
|
|
|||
|
|
@ -58,6 +58,15 @@ def wait_for_flash_count(client: cmux, surface: str, minimum: int = 1, timeout:
|
|||
return last
|
||||
|
||||
|
||||
def wait_for_current_workspace(client: cmux, expected: str, timeout: float = 2.0) -> bool:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
if client.current_workspace() == expected:
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return client.current_workspace() == expected
|
||||
|
||||
|
||||
def ensure_two_surfaces(client: cmux) -> list[tuple[int, str, bool]]:
|
||||
surfaces = client.list_surfaces()
|
||||
if len(surfaces) < 2:
|
||||
|
|
@ -215,8 +224,8 @@ def test_rxvt_notification_osc777(client: cmux) -> TestResult:
|
|||
return result
|
||||
|
||||
|
||||
def test_mark_read_on_focus_change(client: cmux) -> TestResult:
|
||||
result = TestResult("Mark Read On Panel Focus")
|
||||
def test_preserve_unread_on_focus_change(client: cmux) -> TestResult:
|
||||
result = TestResult("Preserve Unread On Panel Focus")
|
||||
try:
|
||||
client.clear_notifications()
|
||||
client.reset_flash_counts()
|
||||
|
|
@ -229,81 +238,88 @@ def test_mark_read_on_focus_change(client: cmux) -> TestResult:
|
|||
|
||||
client.set_app_focus(False)
|
||||
client.notify_surface(other[0], "focusread")
|
||||
time.sleep(0.1)
|
||||
items = wait_for_notifications(client, 1)
|
||||
target = next((n for n in items if n["surface_id"] == other[1]), None)
|
||||
if target is None or target["is_read"]:
|
||||
result.failure("Expected unread notification for target surface before focus")
|
||||
return result
|
||||
|
||||
client.set_app_focus(True)
|
||||
client.focus_surface(other[0])
|
||||
time.sleep(0.1)
|
||||
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
|
||||
if count < 1:
|
||||
result.failure("Expected flash on panel focus")
|
||||
return result
|
||||
|
||||
items = client.list_notifications()
|
||||
target = next((n for n in items if n["surface_id"] == other[1]), None)
|
||||
if target is None:
|
||||
result.failure("Expected notification for target surface")
|
||||
elif not target["is_read"]:
|
||||
result.failure("Expected notification to be marked read on focus")
|
||||
elif target["is_read"]:
|
||||
result.failure("Expected notification to remain unread on focus")
|
||||
else:
|
||||
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
|
||||
if count < 1:
|
||||
result.failure("Expected flash on panel focus dismissal")
|
||||
else:
|
||||
result.success("Notification marked read on focus")
|
||||
result.success("Notification persisted across panel focus")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
||||
|
||||
def test_mark_read_on_app_active(client: cmux) -> TestResult:
|
||||
result = TestResult("Mark Read On App Active")
|
||||
def test_preserve_unread_on_app_active(client: cmux) -> TestResult:
|
||||
result = TestResult("Preserve Unread On App Active")
|
||||
try:
|
||||
client.clear_notifications()
|
||||
client.set_app_focus(False)
|
||||
client.notify("activate")
|
||||
time.sleep(0.1)
|
||||
|
||||
items = client.list_notifications()
|
||||
items = wait_for_notifications(client, 1)
|
||||
if not items or items[0]["is_read"]:
|
||||
result.failure("Expected unread notification before activation")
|
||||
return result
|
||||
|
||||
client.simulate_app_active()
|
||||
time.sleep(0.1)
|
||||
|
||||
items = client.list_notifications()
|
||||
items = wait_for_notifications(client, 1)
|
||||
if not items:
|
||||
result.failure("Expected notification to remain after activation")
|
||||
elif not items[0]["is_read"]:
|
||||
result.failure("Expected notification to be marked read on app active")
|
||||
elif items[0]["is_read"]:
|
||||
result.failure("Expected notification to remain unread on app active")
|
||||
else:
|
||||
result.success("Notification marked read on app active")
|
||||
result.success("Notification persisted across app activation")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
||||
|
||||
def test_mark_read_on_tab_switch(client: cmux) -> TestResult:
|
||||
result = TestResult("Mark Read On Tab Switch")
|
||||
def test_preserve_unread_on_tab_switch(client: cmux) -> TestResult:
|
||||
result = TestResult("Preserve Unread On Tab Switch")
|
||||
try:
|
||||
client.clear_notifications()
|
||||
client.set_app_focus(False)
|
||||
tab1 = client.current_workspace()
|
||||
client.notify("tabswitch")
|
||||
time.sleep(0.1)
|
||||
items = wait_for_notifications(client, 1)
|
||||
target = next((n for n in items if n["workspace_id"] == tab1), None)
|
||||
if target is None or target["is_read"]:
|
||||
result.failure("Expected unread notification for original tab before switching")
|
||||
return result
|
||||
|
||||
tab2 = client.new_workspace()
|
||||
time.sleep(0.1)
|
||||
if not wait_for_current_workspace(client, tab2):
|
||||
result.failure("Expected new workspace to become selected")
|
||||
return result
|
||||
|
||||
client.set_app_focus(True)
|
||||
client.select_workspace(tab1)
|
||||
time.sleep(0.1)
|
||||
if not wait_for_current_workspace(client, tab1):
|
||||
result.failure("Expected original workspace to become selected again")
|
||||
return result
|
||||
|
||||
items = client.list_notifications()
|
||||
items = wait_for_notifications(client, 1)
|
||||
target = next((n for n in items if n["workspace_id"] == tab1), None)
|
||||
if target is None:
|
||||
result.failure("Expected notification for original tab")
|
||||
elif not target["is_read"]:
|
||||
result.failure("Expected notification to be marked read on tab switch")
|
||||
elif target["is_read"]:
|
||||
result.failure("Expected notification to remain unread on tab switch")
|
||||
else:
|
||||
result.success("Notification marked read on tab switch")
|
||||
result.success("Notification persisted across tab switch")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
|
@ -371,11 +387,20 @@ def test_focus_on_notification_click(client: cmux) -> TestResult:
|
|||
result.failure("Expected notification surface to be focused")
|
||||
return result
|
||||
|
||||
items = client.list_notifications()
|
||||
notification = next((n for n in items if n["surface_id"] == other[1]), None)
|
||||
if notification is None:
|
||||
result.failure("Expected notification to remain listed after notification click")
|
||||
return result
|
||||
if notification["is_read"]:
|
||||
result.failure("Expected notification click to preserve unread state")
|
||||
return result
|
||||
|
||||
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
|
||||
if count < 1:
|
||||
result.failure(f"Expected flash count >= 1, got {count}")
|
||||
else:
|
||||
result.success("Notification click focuses and flashes panel")
|
||||
result.success("Notification click focuses, flashes, and preserves unread state")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
|
@ -455,9 +480,9 @@ def run_tests() -> int:
|
|||
results.append(test_kitty_notification_simple(client))
|
||||
results.append(test_kitty_notification_chunked(client))
|
||||
results.append(test_rxvt_notification_osc777(client))
|
||||
results.append(test_mark_read_on_focus_change(client))
|
||||
results.append(test_mark_read_on_app_active(client))
|
||||
results.append(test_mark_read_on_tab_switch(client))
|
||||
results.append(test_preserve_unread_on_focus_change(client))
|
||||
results.append(test_preserve_unread_on_app_active(client))
|
||||
results.append(test_preserve_unread_on_tab_switch(client))
|
||||
results.append(test_flash_on_tab_switch(client))
|
||||
results.append(test_focus_on_notification_click(client))
|
||||
results.append(test_restore_focus_on_tab_switch(client))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue