Merge origin/main into issue-668-command-palette-search-speed
This commit is contained in:
commit
2c2da79138
45 changed files with 7910 additions and 910 deletions
8
.github/workflows/ci-macos-compat.yml
vendored
8
.github/workflows/ci-macos-compat.yml
vendored
|
|
@ -73,11 +73,17 @@ jobs:
|
|||
- name: Clean DerivedData
|
||||
run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
|
||||
|
||||
- name: Cache Swift packages
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: .ci-source-packages
|
||||
key: spm-${{ matrix.os }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
|
||||
restore-keys: spm-${{ matrix.os }}-
|
||||
|
||||
- name: Resolve Swift packages
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
|
||||
rm -rf "$SOURCE_PACKAGES_DIR"
|
||||
mkdir -p "$SOURCE_PACKAGES_DIR"
|
||||
|
||||
for attempt in 1 2 3; do
|
||||
|
|
|
|||
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
|
|
@ -101,11 +101,17 @@ jobs:
|
|||
# Remove stale build cache to avoid incremental build errors
|
||||
rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
|
||||
|
||||
- name: Cache Swift packages
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: .ci-source-packages
|
||||
key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
|
||||
restore-keys: spm-
|
||||
|
||||
- name: Resolve Swift packages
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
|
||||
rm -rf "$SOURCE_PACKAGES_DIR"
|
||||
mkdir -p "$SOURCE_PACKAGES_DIR"
|
||||
|
||||
for attempt in 1 2 3; do
|
||||
|
|
@ -226,11 +232,17 @@ jobs:
|
|||
- name: Clean DerivedData
|
||||
run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
|
||||
|
||||
- name: Cache Swift packages
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: .ci-source-packages
|
||||
key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
|
||||
restore-keys: spm-
|
||||
|
||||
- name: Resolve Swift packages
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
|
||||
rm -rf "$SOURCE_PACKAGES_DIR"
|
||||
mkdir -p "$SOURCE_PACKAGES_DIR"
|
||||
|
||||
for attempt in 1 2 3; do
|
||||
|
|
|
|||
17
.github/workflows/nightly.yml
vendored
17
.github/workflows/nightly.yml
vendored
|
|
@ -142,13 +142,12 @@ jobs:
|
|||
rm GhosttyKit.xcframework.tar.gz
|
||||
test -d GhosttyKit.xcframework
|
||||
|
||||
- name: Configure SwiftPM cache
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CACHE_DIR="${RUNNER_TEMP}/swiftpm-cache/${GITHUB_RUN_ID}"
|
||||
rm -rf "$CACHE_DIR"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
echo "SWIFTPM_CACHE_PATH=$CACHE_DIR" >> "$GITHUB_ENV"
|
||||
- name: Cache Swift packages
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: .spm-cache
|
||||
key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
|
||||
restore-keys: spm-
|
||||
|
||||
- name: Derive Sparkle public key from private key
|
||||
env:
|
||||
|
|
@ -164,7 +163,9 @@ jobs:
|
|||
|
||||
- name: Build app (Release)
|
||||
run: |
|
||||
xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build
|
||||
xcodebuild -scheme cmux -configuration Release -derivedDataPath build \
|
||||
-clonedSourcePackagesDirPath .spm-cache \
|
||||
CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build
|
||||
|
||||
- name: Inject nightly identity and metadata
|
||||
run: |
|
||||
|
|
|
|||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
|
|
@ -129,6 +129,14 @@ jobs:
|
|||
rm GhosttyKit.xcframework.tar.gz
|
||||
test -d GhosttyKit.xcframework
|
||||
|
||||
- name: Cache Swift packages
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: .spm-cache
|
||||
key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
|
||||
restore-keys: spm-
|
||||
|
||||
- name: Derive Sparkle public key from private key
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
env:
|
||||
|
|
@ -145,7 +153,9 @@ jobs:
|
|||
- name: Build app (Release)
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
run: |
|
||||
xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
|
||||
xcodebuild -scheme cmux -configuration Release -derivedDataPath build \
|
||||
-clonedSourcePackagesDirPath .spm-cache \
|
||||
CODE_SIGNING_ALLOWED=NO build
|
||||
|
||||
- name: Inject Sparkle keys into Info.plist
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
|
|
|
|||
8
.github/workflows/test-e2e.yml
vendored
8
.github/workflows/test-e2e.yml
vendored
|
|
@ -151,11 +151,17 @@ jobs:
|
|||
- name: Clean DerivedData
|
||||
run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
|
||||
|
||||
- name: Cache Swift packages
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: .ci-source-packages
|
||||
key: spm-${{ inputs.runner || 'macos-15' }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
|
||||
restore-keys: spm-${{ inputs.runner || 'macos-15' }}-
|
||||
|
||||
- name: Resolve Swift packages
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
|
||||
rm -rf "$SOURCE_PACKAGES_DIR"
|
||||
mkdir -p "$SOURCE_PACKAGES_DIR"
|
||||
for attempt in 1 2 3; do
|
||||
if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \
|
||||
|
|
|
|||
|
|
@ -2622,7 +2622,34 @@ struct CMUXCLI {
|
|||
throw CLIError(message: "browser requires a subcommand")
|
||||
}
|
||||
|
||||
let (surfaceOpt, argsWithoutSurfaceFlag) = parseOption(commandArgs, name: "--surface")
|
||||
var effectiveJSONOutput = jsonOutput
|
||||
var effectiveIDFormat = idFormat
|
||||
var browserArgs = commandArgs
|
||||
|
||||
// Browser-skill examples often place output flags at the end of the command.
|
||||
// Strip trailing display flags so they don't become part of a URL or selector.
|
||||
while !browserArgs.isEmpty {
|
||||
if browserArgs.last == "--json" {
|
||||
effectiveJSONOutput = true
|
||||
browserArgs.removeLast()
|
||||
continue
|
||||
}
|
||||
|
||||
if browserArgs.count >= 2,
|
||||
browserArgs[browserArgs.count - 2] == "--id-format" {
|
||||
let raw = browserArgs.last!
|
||||
guard let parsed = try CLIIDFormat.parse(raw) else {
|
||||
throw CLIError(message: "--id-format must be one of: refs, uuids, both")
|
||||
}
|
||||
effectiveIDFormat = parsed
|
||||
browserArgs.removeLast(2)
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
let (surfaceOpt, argsWithoutSurfaceFlag) = parseOption(browserArgs, name: "--surface")
|
||||
var surfaceRaw = surfaceOpt
|
||||
var args = argsWithoutSurfaceFlag
|
||||
|
||||
|
|
@ -2651,8 +2678,8 @@ struct CMUXCLI {
|
|||
}
|
||||
|
||||
func output(_ payload: [String: Any], fallback: String) {
|
||||
if jsonOutput {
|
||||
print(jsonString(formatIDs(payload, mode: idFormat)))
|
||||
if effectiveJSONOutput {
|
||||
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
|
||||
return
|
||||
}
|
||||
print(fallback)
|
||||
|
|
@ -2808,8 +2835,8 @@ struct CMUXCLI {
|
|||
}
|
||||
}
|
||||
let payload = try client.sendV2(method: "browser.open_split", params: params)
|
||||
let surfaceText = formatHandle(payload, kind: "surface", idFormat: idFormat) ?? "unknown"
|
||||
let paneText = formatHandle(payload, kind: "pane", idFormat: idFormat) ?? "unknown"
|
||||
let surfaceText = formatHandle(payload, kind: "surface", idFormat: effectiveIDFormat) ?? "unknown"
|
||||
let paneText = formatHandle(payload, kind: "pane", idFormat: effectiveIDFormat) ?? "unknown"
|
||||
let placement = ((payload["created_split"] as? Bool) == true) ? "split" : "reuse"
|
||||
output(payload, fallback: "OK surface=\(surfaceText) pane=\(paneText) placement=\(placement)")
|
||||
return
|
||||
|
|
@ -2817,12 +2844,17 @@ struct CMUXCLI {
|
|||
|
||||
if subcommand == "goto" || subcommand == "navigate" {
|
||||
let sid = try requireSurface()
|
||||
let url = subArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var urlArgs = subArgs
|
||||
let snapshotAfter = urlArgs.last == "--snapshot-after"
|
||||
if snapshotAfter {
|
||||
urlArgs.removeLast()
|
||||
}
|
||||
let url = urlArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !url.isEmpty else {
|
||||
throw CLIError(message: "browser \(subcommand) requires a URL")
|
||||
}
|
||||
var params: [String: Any] = ["surface_id": sid, "url": url]
|
||||
if hasFlag(subArgs, name: "--snapshot-after") {
|
||||
if snapshotAfter {
|
||||
params["snapshot_after"] = true
|
||||
}
|
||||
let payload = try client.sendV2(method: "browser.navigate", params: params)
|
||||
|
|
@ -2849,8 +2881,8 @@ struct CMUXCLI {
|
|||
if subcommand == "url" || subcommand == "get-url" {
|
||||
let sid = try requireSurface()
|
||||
let payload = try client.sendV2(method: "browser.url.get", params: ["surface_id": sid])
|
||||
if jsonOutput {
|
||||
print(jsonString(formatIDs(payload, mode: idFormat)))
|
||||
if effectiveJSONOutput {
|
||||
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
|
||||
} else {
|
||||
print((payload["url"] as? String) ?? "")
|
||||
}
|
||||
|
|
@ -2867,8 +2899,8 @@ struct CMUXCLI {
|
|||
if ["is-webview-focused", "is_webview_focused"].contains(subcommand) {
|
||||
let sid = try requireSurface()
|
||||
let payload = try client.sendV2(method: "browser.is_webview_focused", params: ["surface_id": sid])
|
||||
if jsonOutput {
|
||||
print(jsonString(formatIDs(payload, mode: idFormat)))
|
||||
if effectiveJSONOutput {
|
||||
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
|
||||
} else {
|
||||
print((payload["focused"] as? Bool) == true ? "true" : "false")
|
||||
}
|
||||
|
|
@ -2901,8 +2933,8 @@ struct CMUXCLI {
|
|||
}
|
||||
|
||||
let payload = try client.sendV2(method: "browser.snapshot", params: params)
|
||||
if jsonOutput {
|
||||
print(jsonString(formatIDs(payload, mode: idFormat)))
|
||||
if effectiveJSONOutput {
|
||||
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
|
||||
} else {
|
||||
print(displaySnapshotText(payload))
|
||||
}
|
||||
|
|
@ -3113,7 +3145,7 @@ struct CMUXCLI {
|
|||
let sid = try requireSurface()
|
||||
let (outPathOpt, _) = parseOption(subArgs, name: "--out")
|
||||
let localJSONOutput = hasFlag(subArgs, name: "--json")
|
||||
let outputAsJSON = jsonOutput || localJSONOutput
|
||||
let outputAsJSON = effectiveJSONOutput || localJSONOutput
|
||||
var payload = try client.sendV2(method: "browser.screenshot", params: ["surface_id": sid])
|
||||
|
||||
func fileURL(fromPath rawPath: String) -> URL {
|
||||
|
|
@ -3228,7 +3260,7 @@ struct CMUXCLI {
|
|||
}
|
||||
|
||||
if outputAsJSON {
|
||||
let formattedPayload = formatIDs(payload, mode: idFormat)
|
||||
let formattedPayload = formatIDs(payload, mode: effectiveIDFormat)
|
||||
if var outputPayload = formattedPayload as? [String: Any] {
|
||||
if hasText(screenshotPath) || hasText(screenshotURL) {
|
||||
outputPayload.removeValue(forKey: "png_base64")
|
||||
|
|
@ -3302,8 +3334,8 @@ struct CMUXCLI {
|
|||
"styles": "browser.get.styles",
|
||||
]
|
||||
let payload = try client.sendV2(method: methodMap[getVerb]!, params: params)
|
||||
if jsonOutput {
|
||||
print(jsonString(formatIDs(payload, mode: idFormat)))
|
||||
if effectiveJSONOutput {
|
||||
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
|
||||
} else if let value = payload["value"] {
|
||||
if let str = value as? String {
|
||||
print(str)
|
||||
|
|
@ -3342,8 +3374,8 @@ struct CMUXCLI {
|
|||
throw CLIError(message: "Unsupported browser is subcommand: \(isVerb)")
|
||||
}
|
||||
let payload = try client.sendV2(method: method, params: ["surface_id": sid, "selector": selector])
|
||||
if jsonOutput {
|
||||
print(jsonString(formatIDs(payload, mode: idFormat)))
|
||||
if effectiveJSONOutput {
|
||||
print(jsonString(formatIDs(payload, mode: effectiveIDFormat)))
|
||||
} else if let value = payload["value"] {
|
||||
print("\(value)")
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@
|
|||
A5002000 /* THIRD_PARTY_LICENSES.md in Resources */ = {isa = PBXBuildFile; fileRef = A5002001 /* THIRD_PARTY_LICENSES.md */; };
|
||||
B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; };
|
||||
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; };
|
||||
B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */; };
|
||||
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; };
|
||||
B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */; };
|
||||
B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */; };
|
||||
|
|
@ -204,6 +205,7 @@
|
|||
A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = "<group>"; };
|
||||
A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = "<group>"; };
|
||||
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; };
|
||||
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = "<group>"; };
|
||||
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; };
|
||||
A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = "<group>"; };
|
||||
|
|
@ -435,6 +437,7 @@
|
|||
B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */,
|
||||
B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */,
|
||||
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */,
|
||||
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */,
|
||||
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */,
|
||||
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */,
|
||||
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */,
|
||||
|
|
@ -670,6 +673,7 @@
|
|||
B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */,
|
||||
B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */,
|
||||
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */,
|
||||
B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */,
|
||||
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */,
|
||||
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */,
|
||||
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */,
|
||||
|
|
|
|||
|
|
@ -567,6 +567,584 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"debug.devBuildBanner.show": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Show Dev Build Banner"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "開発ビルドバナーを表示"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"debug.devBuildBanner.title": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "THIS IS A DEV BUILD"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "これは開発ビルドです"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.button": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Help"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ヘルプ"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.changelog": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Changelog"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "更新履歴"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.githubIssues": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "GitHub Issues"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "GitHub Issues"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.sendFeedback": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Send Feedback"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "フィードバックを送信"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.attachImages": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Attach Images"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "画像を添付"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.attachImages.prompt": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Attach"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "添付"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.attachImages.title": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Attach Images"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "画像を添付"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.attachmentsHint": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Up to 10 images."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "画像は最大10枚まで添付できます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.cancel": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Cancel"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "キャンセル"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.connectionError": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Couldn't send feedback. Check your connection and try again."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "フィードバックを送信できませんでした。接続を確認して、もう一度お試しください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.done": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Done"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "完了"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.email": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Your Email"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "メールアドレス"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.emailPlaceholder": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "you@example.com"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "you@example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.emptyMessage": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Enter a message before sending."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "送信する前にメッセージを入力してください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.endpointError": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Feedback is unavailable right now. Email founders@manaflow.com instead."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "現在フィードバックを送信できません。代わりに founders@manaflow.com までメールしてください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.genericError": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Couldn't send feedback. Please try again."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "フィードバックを送信できませんでした。もう一度お試しください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.imageTooLarge": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Each image must be 4 MB or smaller."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "各画像は 4 MB 以下にしてください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.invalidEmail": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Enter a valid email address."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "有効なメールアドレスを入力してください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.invalidImageSelection": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "One of the selected files could not be attached."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "選択したファイルのうち1つを添付できませんでした。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.message": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Message"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "メッセージ"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.messagePlaceholder": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Share feedback, feature requests, or issues."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "フィードバック、機能要望、不具合をお知らせください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.messageTooLong": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Your message is too long."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "メッセージが長すぎます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.note": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "You can also reach us at founders@manaflow.com."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "founders@manaflow.com 宛てに直接ご連絡いただくこともできます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.rateLimited": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Too many feedback attempts. Please try again later."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "フィードバックの送信回数が多すぎます。しばらくしてからもう一度お試しください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.removeAttachment": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Remove"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "削除"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.send": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Send"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "送信"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.successBody": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "You can also reach us at founders@manaflow.com."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "founders@manaflow.com 宛てに直接ご連絡いただくこともできます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.successTitle": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Thanks for the feedback."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "フィードバックありがとうございます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.title": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Send Feedback"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "フィードバックを送信"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.tooManyImages": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "You can attach up to 10 images."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "画像は最大10枚まで添付できます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.totalImagesTooLarge": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "These images are too large to send together. Remove a few and try again."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "これらの画像はまとめて送信するには大きすぎます。いくつか削除してもう一度お試しください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.feedback.validationError": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Check your message and attachments, then try again."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "メッセージと添付ファイルを確認して、もう一度お試しください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"about.github": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
@ -35145,119 +35723,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"menu.updateLogs.title": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Update Logs"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "アップデートログ"
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "更新日志"
|
||||
}
|
||||
},
|
||||
"zh-Hant": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "更新記錄"
|
||||
}
|
||||
},
|
||||
"ko": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "업데이트 로그"
|
||||
}
|
||||
},
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Update-Protokolle"
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Registros de actualización"
|
||||
}
|
||||
},
|
||||
"fr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Journaux de mise à jour"
|
||||
}
|
||||
},
|
||||
"it": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Log aggiornamento"
|
||||
}
|
||||
},
|
||||
"da": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Opdateringslogfiler"
|
||||
}
|
||||
},
|
||||
"pl": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dzienniki aktualizacji"
|
||||
}
|
||||
},
|
||||
"ru": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Журналы обновлений"
|
||||
}
|
||||
},
|
||||
"bs": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Logovi ažuriranja"
|
||||
}
|
||||
},
|
||||
"ar": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "سجلات التحديث"
|
||||
}
|
||||
},
|
||||
"nb": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Oppdateringslogger"
|
||||
}
|
||||
},
|
||||
"pt-BR": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Logs de Atualização"
|
||||
}
|
||||
},
|
||||
"th": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "บันทึกการอัปเดต"
|
||||
}
|
||||
},
|
||||
"tr": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Güncelleme Günlükleri"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"menu.view.actualSize": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
|
|||
|
|
@ -1242,33 +1242,6 @@ func shouldRouteTerminalFontZoomShortcutToGhostty(
|
|||
) != nil
|
||||
}
|
||||
|
||||
func shouldRouteTerminalCommandShortcutToGhostty(
|
||||
flags: NSEvent.ModifierFlags,
|
||||
chars: String,
|
||||
keyCode: UInt16,
|
||||
terminalHasSelection: Bool
|
||||
) -> Bool {
|
||||
let normalizedFlags = flags
|
||||
.intersection(.deviceIndependentFlagsMask)
|
||||
.subtracting([.numericPad, .function, .capsLock])
|
||||
guard normalizedFlags.contains(.command) else { return false }
|
||||
|
||||
let normalizedChars = chars.lowercased()
|
||||
if normalizedFlags == [.command] {
|
||||
// Keep Preferences (Cmd+,) menu-routed even when a terminal is focused.
|
||||
if normalizedChars == "," || keyCode == 43 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Preserve standard copy behavior when text is selected in the terminal.
|
||||
if (normalizedChars == "c" || keyCode == 8), terminalHasSelection {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? {
|
||||
guard let responder else { return nil }
|
||||
if let ghosttyView = responder as? GhosttyNSView {
|
||||
|
|
@ -1721,6 +1694,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
PostHogAnalytics.shared.startIfNeeded()
|
||||
}
|
||||
|
||||
let forceDuplicateLaunchObserver = env["CMUX_UI_TEST_ENABLE_DUPLICATE_LAUNCH_OBSERVER"] == "1"
|
||||
|
||||
// UI tests frequently time out waiting for the main window if we do heavyweight
|
||||
// LaunchServices registration / single-instance enforcement synchronously at startup.
|
||||
// Skip these during XCTest (the app-under-test) so the window can appear quickly.
|
||||
|
|
@ -1731,6 +1706,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
self.enforceSingleInstance()
|
||||
self.observeDuplicateLaunches()
|
||||
}
|
||||
} else if forceDuplicateLaunchObserver {
|
||||
// Some UI regressions specifically exercise launch-observer behavior while still
|
||||
// running under XCTest. Allow an explicit opt-in for those cases only.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.observeDuplicateLaunches()
|
||||
}
|
||||
}
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
disableNativeTabbingShortcut()
|
||||
|
|
@ -1838,6 +1819,7 @@ 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 {
|
||||
|
|
@ -4858,8 +4840,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
@MainActor
|
||||
static func presentPreferencesWindow(
|
||||
showFallbackSettingsWindow: @MainActor () -> Void = {
|
||||
SettingsWindowController.shared.show()
|
||||
navigationTarget: SettingsNavigationTarget? = nil,
|
||||
showFallbackSettingsWindow: @MainActor (SettingsNavigationTarget?) -> Void = { target in
|
||||
SettingsWindowController.shared.show(navigationTarget: target)
|
||||
},
|
||||
activateApplication: @MainActor () -> Void = {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
|
@ -4868,7 +4851,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
#if DEBUG
|
||||
dlog("settings.open.present path=customWindowDirect")
|
||||
#endif
|
||||
showFallbackSettingsWindow()
|
||||
showFallbackSettingsWindow(navigationTarget)
|
||||
activateApplication()
|
||||
#if DEBUG
|
||||
dlog("settings.open.present activate=1")
|
||||
|
|
@ -4876,11 +4859,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
|
||||
@MainActor
|
||||
func openPreferencesWindow(debugSource: String) {
|
||||
func openPreferencesWindow(debugSource: String, navigationTarget: SettingsNavigationTarget? = nil) {
|
||||
#if DEBUG
|
||||
dlog("settings.open.request source=\(debugSource)")
|
||||
#endif
|
||||
Self.presentPreferencesWindow()
|
||||
Self.presentPreferencesWindow(navigationTarget: navigationTarget)
|
||||
}
|
||||
|
||||
@objc func openPreferencesWindow() {
|
||||
|
|
@ -5777,19 +5760,64 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
try? FileManager.default.removeItem(atPath: path)
|
||||
|
||||
let deadline = Date().addingTimeInterval(8.0)
|
||||
let contextDeadline = Date().addingTimeInterval(8.0)
|
||||
func waitForContexts(minCount: Int, _ completion: @escaping () -> Void) {
|
||||
if mainWindowContexts.count >= minCount,
|
||||
mainWindowContexts.values.allSatisfy({ $0.window != nil }) {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
guard Date() < deadline else { return }
|
||||
guard Date() < contextDeadline else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
waitForContexts(minCount: minCount, completion)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForSurfaceId(
|
||||
on tabManager: TabManager,
|
||||
tabId: UUID,
|
||||
timeout: TimeInterval = 8.0,
|
||||
_ completion: @escaping (UUID) -> Void
|
||||
) {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
func resolvedSurfaceId() -> UUID? {
|
||||
if let surfaceId = tabManager.focusedPanelId(for: tabId) {
|
||||
return surfaceId
|
||||
}
|
||||
|
||||
guard let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let terminalPanelId = workspace.focusedTerminalPanel?.id {
|
||||
return terminalPanelId
|
||||
}
|
||||
|
||||
if let terminalPanelId = workspace.terminalPanelForConfigInheritance()?.id {
|
||||
return terminalPanelId
|
||||
}
|
||||
|
||||
return workspace.panels.values
|
||||
.compactMap { ($0 as? TerminalPanel)?.id }
|
||||
.sorted(by: { $0.uuidString < $1.uuidString })
|
||||
.first
|
||||
}
|
||||
|
||||
func poll() {
|
||||
if let surfaceId = resolvedSurfaceId() {
|
||||
completion(surfaceId)
|
||||
return
|
||||
}
|
||||
guard Date() < deadline else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
poll()
|
||||
}
|
||||
}
|
||||
|
||||
poll()
|
||||
}
|
||||
|
||||
waitForContexts(minCount: 1) { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let window1 = self.mainWindowContexts.values.first else { return }
|
||||
|
|
@ -5803,39 +5831,193 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
let contexts = Array(self.mainWindowContexts.values)
|
||||
guard let window2 = contexts.first(where: { $0.windowId != window1.windowId }) else { return }
|
||||
guard let tabId2 = window2.tabManager.selectedTabId ?? window2.tabManager.tabs.first?.id else { return }
|
||||
guard let store = self.notificationStore else { return }
|
||||
waitForSurfaceId(on: window1.tabManager, tabId: tabId1) { [weak self] surfaceId1 in
|
||||
guard let self else { return }
|
||||
waitForSurfaceId(on: window2.tabManager, tabId: tabId2) { [weak self] surfaceId2 in
|
||||
guard let self else { return }
|
||||
guard let store = self.notificationStore else { return }
|
||||
|
||||
// Ensure the target window is currently showing the Notifications overlay,
|
||||
// so opening a notification must switch it back to the terminal UI.
|
||||
window2.sidebarSelectionState.selection = .notifications
|
||||
// Ensure the target window is currently showing the Notifications overlay,
|
||||
// so opening a notification must switch it back to the terminal UI.
|
||||
window2.sidebarSelectionState.selection = .notifications
|
||||
|
||||
// Create notifications for both windows. Ensure W2 isn't suppressed just because it's focused.
|
||||
let prevOverride = AppFocusState.overrideIsFocused
|
||||
AppFocusState.overrideIsFocused = false
|
||||
store.addNotification(tabId: tabId2, surfaceId: nil, title: "W2", subtitle: "multiwindow", body: "")
|
||||
AppFocusState.overrideIsFocused = prevOverride
|
||||
// Create notifications for both windows. Ensure W2 isn't suppressed just because it's focused.
|
||||
let prevOverride = AppFocusState.overrideIsFocused
|
||||
AppFocusState.overrideIsFocused = false
|
||||
store.addNotification(tabId: tabId2, surfaceId: nil, title: "W2", subtitle: "multiwindow", body: "")
|
||||
AppFocusState.overrideIsFocused = prevOverride
|
||||
|
||||
// Insert after W2 so it becomes "latest unread" (first in list).
|
||||
store.addNotification(tabId: tabId1, surfaceId: nil, title: "W1", subtitle: "multiwindow", body: "")
|
||||
// Insert after W2 so it becomes "latest unread" (first in list).
|
||||
store.addNotification(tabId: tabId1, surfaceId: nil, title: "W1", subtitle: "multiwindow", body: "")
|
||||
|
||||
let notif1 = store.notifications.first(where: { $0.tabId == tabId1 && $0.title == "W1" })
|
||||
let notif2 = store.notifications.first(where: { $0.tabId == tabId2 && $0.title == "W2" })
|
||||
let notif1 = store.notifications.first(where: { $0.tabId == tabId1 && $0.title == "W1" })
|
||||
let notif2 = store.notifications.first(where: { $0.tabId == tabId2 && $0.title == "W2" })
|
||||
|
||||
self.writeMultiWindowNotificationTestData([
|
||||
"window1Id": window1.windowId.uuidString,
|
||||
"window2Id": window2.windowId.uuidString,
|
||||
"window2InitialSidebarSelection": "notifications",
|
||||
"tabId1": tabId1.uuidString,
|
||||
"tabId2": tabId2.uuidString,
|
||||
"notifId1": notif1?.id.uuidString ?? "",
|
||||
"notifId2": notif2?.id.uuidString ?? "",
|
||||
"expectedLatestWindowId": window1.windowId.uuidString,
|
||||
"expectedLatestTabId": tabId1.uuidString,
|
||||
], at: path)
|
||||
self.writeMultiWindowNotificationTestData([
|
||||
"window1Id": window1.windowId.uuidString,
|
||||
"window2Id": window2.windowId.uuidString,
|
||||
"window2InitialSidebarSelection": "notifications",
|
||||
"tabId1": tabId1.uuidString,
|
||||
"tabId2": tabId2.uuidString,
|
||||
"surfaceId1": surfaceId1.uuidString,
|
||||
"surfaceId2": surfaceId2.uuidString,
|
||||
"notifId1": notif1?.id.uuidString ?? "",
|
||||
"notifId2": notif2?.id.uuidString ?? "",
|
||||
"expectedLatestWindowId": window1.windowId.uuidString,
|
||||
"expectedLatestTabId": tabId1.uuidString,
|
||||
], at: path)
|
||||
self.prepareMultiWindowNotificationSourceTerminalIfNeeded(
|
||||
at: path,
|
||||
windowId: window1.windowId,
|
||||
tabManager: window1.tabManager,
|
||||
tabId: tabId1,
|
||||
surfaceId: surfaceId1
|
||||
)
|
||||
self.publishMultiWindowNotificationSocketStateIfNeeded(at: path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareMultiWindowNotificationSourceTerminalIfNeeded(
|
||||
at path: String,
|
||||
windowId: UUID,
|
||||
tabManager: TabManager,
|
||||
tabId: UUID,
|
||||
surfaceId: UUID
|
||||
) {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard env["CMUX_UI_TEST_NOTIFY_SOURCE_TERMINAL_READY"] == "1" else { return }
|
||||
|
||||
writeMultiWindowNotificationTestData([
|
||||
"sourceTerminalReady": "pending",
|
||||
"sourceTerminalFocusFailure": "",
|
||||
], at: path)
|
||||
|
||||
let deadline = Date().addingTimeInterval(8.0)
|
||||
|
||||
func publish(ready: Bool, failure: String = "") {
|
||||
writeMultiWindowNotificationTestData([
|
||||
"sourceTerminalReady": ready ? "1" : "0",
|
||||
"sourceTerminalFocusFailure": failure,
|
||||
], at: path)
|
||||
}
|
||||
|
||||
func poll() {
|
||||
guard let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else {
|
||||
publish(ready: false, failure: "workspace_missing")
|
||||
return
|
||||
}
|
||||
guard let terminalPanel = workspace.terminalPanel(for: surfaceId) else {
|
||||
publish(ready: false, failure: "terminal_missing")
|
||||
return
|
||||
}
|
||||
|
||||
let isWindowFrontmost = {
|
||||
guard let window = self.mainWindow(for: windowId) else { return false }
|
||||
return NSApp.keyWindow === window || NSApp.mainWindow === window
|
||||
}()
|
||||
if isWindowFrontmost && terminalPanel.hostedView.isSurfaceViewFirstResponder() {
|
||||
publish(ready: true)
|
||||
return
|
||||
}
|
||||
|
||||
guard Date() < deadline else {
|
||||
publish(
|
||||
ready: false,
|
||||
failure: isWindowFrontmost ? "terminal_not_first_responder" : "window_not_frontmost"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
_ = self.focusMainWindow(windowId: windowId)
|
||||
if let tab = tabManager.tabs.first(where: { $0.id == tabId }) {
|
||||
tabManager.selectTab(tab)
|
||||
tabManager.focusSurface(tabId: tabId, surfaceId: surfaceId)
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
poll()
|
||||
}
|
||||
}
|
||||
|
||||
poll()
|
||||
}
|
||||
|
||||
private func publishMultiWindowNotificationSocketStateIfNeeded(at path: String) {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard env["CMUX_UI_TEST_SOCKET_SANITY"] == "1" else { return }
|
||||
|
||||
guard let config = socketListenerConfigurationIfEnabled() else {
|
||||
writeMultiWindowNotificationTestData([
|
||||
"socketExpectedPath": env["CMUX_SOCKET_PATH"] ?? "",
|
||||
"socketMode": "off",
|
||||
"socketReady": "0",
|
||||
"socketPingResponse": "",
|
||||
"socketIsRunning": "0",
|
||||
"socketAcceptLoopAlive": "0",
|
||||
"socketPathMatches": "0",
|
||||
"socketPathExists": "0",
|
||||
"socketFailureSignals": "socket_disabled",
|
||||
], at: path)
|
||||
return
|
||||
}
|
||||
|
||||
writeMultiWindowNotificationTestData([
|
||||
"socketExpectedPath": config.path,
|
||||
"socketMode": config.mode.rawValue,
|
||||
"socketReady": "pending",
|
||||
"socketPingResponse": "",
|
||||
], at: path)
|
||||
|
||||
restartSocketListenerIfEnabled(source: "uiTest.multiWindowNotifications.setup")
|
||||
|
||||
let deadline = Date().addingTimeInterval(20.0)
|
||||
func publish() {
|
||||
let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: config.path)
|
||||
let isTimedOut = Date() >= deadline
|
||||
let socketPath = config.path
|
||||
let socketMode = config.mode.rawValue
|
||||
let dataPath = path
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||
let pingResponse = health.isHealthy
|
||||
? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0)
|
||||
: nil
|
||||
let isReady = health.isHealthy && pingResponse == "PONG"
|
||||
let failureSignals = {
|
||||
var signals = health.failureSignals
|
||||
if health.isHealthy && pingResponse != "PONG" {
|
||||
signals.append("ping_timeout")
|
||||
}
|
||||
return signals.joined(separator: ",")
|
||||
}()
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.writeMultiWindowNotificationTestData([
|
||||
"socketExpectedPath": socketPath,
|
||||
"socketMode": socketMode,
|
||||
"socketReady": isReady ? "1" : (isTimedOut ? "0" : "pending"),
|
||||
"socketPingResponse": pingResponse ?? "",
|
||||
"socketIsRunning": health.isRunning ? "1" : "0",
|
||||
"socketAcceptLoopAlive": health.acceptLoopAlive ? "1" : "0",
|
||||
"socketPathMatches": health.socketPathMatches ? "1" : "0",
|
||||
"socketPathExists": health.socketPathExists ? "1" : "0",
|
||||
"socketFailureSignals": failureSignals,
|
||||
], at: dataPath)
|
||||
guard !isTimedOut, !isReady else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
publish()
|
||||
}
|
||||
|
||||
private func writeMultiWindowNotificationTestData(_ updates: [String: String], at path: String) {
|
||||
var payload = loadMultiWindowNotificationTestData(at: path)
|
||||
for (key, value) in updates {
|
||||
|
|
@ -6610,6 +6792,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return true
|
||||
}
|
||||
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .sendFeedback)) {
|
||||
guard let targetContext = preferredMainWindowContextForShortcuts(event: event),
|
||||
let targetWindow = targetContext.window ?? windowForMainWindowId(targetContext.windowId) else {
|
||||
return false
|
||||
}
|
||||
setActiveMainWindow(targetWindow)
|
||||
bringToFront(targetWindow)
|
||||
NotificationCenter.default.post(name: .feedbackComposerRequested, object: targetWindow)
|
||||
return true
|
||||
}
|
||||
|
||||
// Check Jump to Unread shortcut
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .jumpToUnread)) {
|
||||
#if DEBUG
|
||||
|
|
@ -7850,6 +8043,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
private func observeDuplicateLaunches() {
|
||||
guard let bundleId = Bundle.main.bundleIdentifier else { return }
|
||||
let embeddedCLIURL = Bundle.main.bundleURL
|
||||
.appendingPathComponent("Contents/Resources/bin/cmux", isDirectory: false)
|
||||
.standardizedFileURL
|
||||
.resolvingSymlinksInPath()
|
||||
let currentPid = ProcessInfo.processInfo.processIdentifier
|
||||
|
||||
workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
||||
|
|
@ -7860,6 +8057,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
guard self != nil else { return }
|
||||
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
|
||||
guard app.bundleIdentifier == bundleId, app.processIdentifier != currentPid else { return }
|
||||
if let executableURL = app.executableURL?
|
||||
.standardizedFileURL
|
||||
.resolvingSymlinksInPath(),
|
||||
executableURL == embeddedCLIURL {
|
||||
return
|
||||
}
|
||||
|
||||
app.terminate()
|
||||
if !app.isTerminated {
|
||||
|
|
@ -8155,6 +8358,16 @@ 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,
|
||||
|
|
@ -8208,6 +8421,15 @@ 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"])
|
||||
|
|
@ -8267,6 +8489,22 @@ 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,
|
||||
|
|
@ -9177,23 +9415,6 @@ private extension NSWindow {
|
|||
return true
|
||||
}
|
||||
|
||||
// Support custom tmux prefixes (for example Cmd+C): when the terminal is focused
|
||||
// and no app-level shortcut matched, prefer forwarding Command-key input to the
|
||||
// terminal rather than consuming it as a menu key equivalent.
|
||||
if let ghosttyView = firstResponderGhosttyView,
|
||||
shouldRouteTerminalCommandShortcutToGhostty(
|
||||
flags: event.modifierFlags,
|
||||
chars: event.charactersIgnoringModifiers ?? "",
|
||||
keyCode: event.keyCode,
|
||||
terminalHasSelection: ghosttyView.terminalSurface?.hasSelection() ?? false
|
||||
) {
|
||||
ghosttyView.keyDown(with: event)
|
||||
#if DEBUG
|
||||
dlog(" → ghostty command passthrough")
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
// When the terminal is focused, skip the full NSWindow.performKeyEquivalent
|
||||
// (which walks the SwiftUI content view hierarchy) and dispatch Command-key
|
||||
// events directly to the main menu. This avoids the broken SwiftUI focus path.
|
||||
|
|
@ -9302,12 +9523,32 @@ private extension NSWindow {
|
|||
if let webView = candidate as? CmuxWebView {
|
||||
return webView
|
||||
}
|
||||
if String(describing: type(of: candidate)).contains("WindowBrowserSlotView"),
|
||||
let portalWebView = cmuxUniqueBrowserWebView(in: candidate) {
|
||||
return portalWebView
|
||||
}
|
||||
current = candidate.superview
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func cmuxUniqueBrowserWebView(in root: NSView) -> CmuxWebView? {
|
||||
var stack: [NSView] = [root]
|
||||
var found: CmuxWebView?
|
||||
while let current = stack.popLast() {
|
||||
if let webView = current as? CmuxWebView {
|
||||
if found == nil {
|
||||
found = webView
|
||||
} else if found !== webView {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
stack.append(contentsOf: current.subviews)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
private static func cmuxCurrentEvent(for _: NSWindow) -> NSEvent? {
|
||||
#if DEBUG
|
||||
if let override = cmuxFirstResponderGuardCurrentEventOverride {
|
||||
|
|
@ -9323,7 +9564,22 @@ private extension NSWindow {
|
|||
return override
|
||||
}
|
||||
#endif
|
||||
return window.contentView?.hitTest(event.locationInWindow)
|
||||
guard let contentView = window.contentView else { return nil }
|
||||
|
||||
if contentView.className == "NSGlassEffectView" {
|
||||
let pointInContent = contentView.convert(event.locationInWindow, from: nil)
|
||||
return contentView.hitTest(pointInContent)
|
||||
}
|
||||
|
||||
if let themeFrame = contentView.superview {
|
||||
let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil)
|
||||
if let hit = themeFrame.hitTest(pointInTheme) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
|
||||
let pointInContent = contentView.convert(event.locationInWindow, from: nil)
|
||||
return contentView.hitTest(pointInContent)
|
||||
}
|
||||
|
||||
private static func cmuxTrackFieldEditor(_ fieldEditor: NSTextView, owningWebView webView: CmuxWebView?) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -10,6 +10,7 @@ enum KeyboardShortcutSettings {
|
|||
case newWindow
|
||||
case closeWindow
|
||||
case openFolder
|
||||
case sendFeedback
|
||||
case showNotifications
|
||||
case jumpToUnread
|
||||
case triggerFlash
|
||||
|
|
@ -50,6 +51,7 @@ enum KeyboardShortcutSettings {
|
|||
case .newWindow: return String(localized: "shortcut.newWindow.label", defaultValue: "New Window")
|
||||
case .closeWindow: return String(localized: "shortcut.closeWindow.label", defaultValue: "Close Window")
|
||||
case .openFolder: return String(localized: "shortcut.openFolder.label", defaultValue: "Open Folder")
|
||||
case .sendFeedback: return String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback")
|
||||
case .showNotifications: return String(localized: "shortcut.showNotifications.label", defaultValue: "Show Notifications")
|
||||
case .jumpToUnread: return String(localized: "shortcut.jumpToUnread.label", defaultValue: "Jump to Latest Unread")
|
||||
case .triggerFlash: return String(localized: "shortcut.flashFocusedPanel.label", defaultValue: "Flash Focused Panel")
|
||||
|
|
@ -84,6 +86,7 @@ enum KeyboardShortcutSettings {
|
|||
case .newWindow: return "shortcut.newWindow"
|
||||
case .closeWindow: return "shortcut.closeWindow"
|
||||
case .openFolder: return "shortcut.openFolder"
|
||||
case .sendFeedback: return "shortcut.sendFeedback"
|
||||
case .showNotifications: return "shortcut.showNotifications"
|
||||
case .jumpToUnread: return "shortcut.jumpToUnread"
|
||||
case .triggerFlash: return "shortcut.triggerFlash"
|
||||
|
|
@ -123,6 +126,8 @@ enum KeyboardShortcutSettings {
|
|||
return StoredShortcut(key: "w", command: true, shift: false, option: false, control: true)
|
||||
case .openFolder:
|
||||
return StoredShortcut(key: "o", command: true, shift: false, option: false, control: false)
|
||||
case .sendFeedback:
|
||||
return StoredShortcut(key: "f", command: true, shift: false, option: true, control: false)
|
||||
case .showNotifications:
|
||||
return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false)
|
||||
case .jumpToUnread:
|
||||
|
|
|
|||
|
|
@ -1244,6 +1244,15 @@ final class BrowserSearchState: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
final class BrowserPortalAnchorView: NSView {
|
||||
override var acceptsFirstResponder: Bool { false }
|
||||
override var isOpaque: Bool { false }
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class BrowserPanel: Panel, ObservableObject {
|
||||
/// Shared process pool for cookie sharing across all browser panels
|
||||
|
|
@ -1481,6 +1490,7 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
}
|
||||
}
|
||||
private var searchNeedleCancellable: AnyCancellable?
|
||||
let portalAnchorView = BrowserPortalAnchorView(frame: .zero)
|
||||
private var webViewCancellables = Set<AnyCancellable>()
|
||||
private var navigationDelegate: BrowserNavigationDelegate?
|
||||
private var uiDelegate: BrowserUIDelegate?
|
||||
|
|
@ -2587,7 +2597,7 @@ extension BrowserPanel {
|
|||
/// while its container is off-window. Avoid detaching in that transient phase if
|
||||
/// DevTools is intended to remain open, because detach/reattach can blank inspector content.
|
||||
func shouldPreserveWebViewAttachmentDuringTransientHide() -> Bool {
|
||||
preferredDeveloperToolsVisible
|
||||
preferredDeveloperToolsVisible && !hasSideDockedDeveloperToolsLayout()
|
||||
}
|
||||
|
||||
func requestDeveloperToolsRefreshAfterNextAttach(reason: String) {
|
||||
|
|
@ -3045,6 +3055,7 @@ extension BrowserPanel {
|
|||
let containerType = container.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
return "webFrame=\(Self.debugRectDescription(webFrame)) webBounds=\(Self.debugRectDescription(webView.bounds)) webWin=\(webView.window?.windowNumber ?? -1) super=\(Self.debugObjectToken(container)) superType=\(containerType) superBounds=\(Self.debugRectDescription(containerBounds)) inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) inspectorInsets=\(String(format: "%.1f", inspectorInsets)) inspectorOverflow=\(String(format: "%.1f", inspectorOverflow)) inspectorSubviews=\(inspectorSubviews)"
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
|
||||
|
|
@ -3069,6 +3080,71 @@ private extension BrowserPanel {
|
|||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasSideDockedDeveloperToolsLayout() -> Bool {
|
||||
guard let container = webView.superview else { return false }
|
||||
return Self.visibleDescendants(in: container)
|
||||
.filter { Self.isVisibleSideDockInspectorCandidate($0) && Self.isInspectorView($0) }
|
||||
.contains { inspectorCandidate in
|
||||
hasSideDockedInspectorSibling(startingAt: inspectorCandidate, root: container)
|
||||
}
|
||||
}
|
||||
|
||||
func hasSideDockedInspectorSibling(startingAt inspectorLeaf: NSView, root: NSView) -> Bool {
|
||||
var current: NSView? = inspectorLeaf
|
||||
|
||||
while let inspectorView = current, inspectorView !== root {
|
||||
guard let containerView = inspectorView.superview else { break }
|
||||
let hasSideDockedSibling = containerView.subviews.contains { candidate in
|
||||
guard Self.isVisibleSideDockSiblingCandidate(candidate) else { return false }
|
||||
guard candidate !== inspectorView else { return false }
|
||||
let horizontallyAdjacent =
|
||||
candidate.frame.maxX <= inspectorView.frame.minX + 1 ||
|
||||
candidate.frame.minX >= inspectorView.frame.maxX - 1
|
||||
guard horizontallyAdjacent else { return false }
|
||||
return Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8
|
||||
}
|
||||
if hasSideDockedSibling {
|
||||
return true
|
||||
}
|
||||
|
||||
current = containerView
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
static func visibleDescendants(in root: NSView) -> [NSView] {
|
||||
var descendants: [NSView] = []
|
||||
var stack = Array(root.subviews.reversed())
|
||||
while let view = stack.popLast() {
|
||||
descendants.append(view)
|
||||
stack.append(contentsOf: view.subviews.reversed())
|
||||
}
|
||||
return descendants
|
||||
}
|
||||
|
||||
static func isInspectorView(_ view: NSView) -> Bool {
|
||||
String(describing: type(of: view)).contains("WKInspector")
|
||||
}
|
||||
|
||||
static func isVisibleSideDockInspectorCandidate(_ view: NSView) -> Bool {
|
||||
!view.isHidden &&
|
||||
view.alphaValue > 0 &&
|
||||
view.frame.width > 1 &&
|
||||
view.frame.height > 1
|
||||
}
|
||||
|
||||
static func isVisibleSideDockSiblingCandidate(_ view: NSView) -> Bool {
|
||||
!view.isHidden &&
|
||||
view.alphaValue > 0 &&
|
||||
view.frame.width > 1 &&
|
||||
view.frame.height > 1
|
||||
}
|
||||
|
||||
static func verticalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat {
|
||||
max(0, min(lhs.maxY, rhs.maxY) - max(lhs.minY, rhs.minY))
|
||||
}
|
||||
}
|
||||
|
||||
private extension WKWebView {
|
||||
|
|
|
|||
|
|
@ -230,6 +230,7 @@ struct BrowserPanelView: View {
|
|||
@State private var focusFlashOpacity: Double = 0.0
|
||||
@State private var focusFlashAnimationGeneration: Int = 0
|
||||
@State private var omnibarPillFrame: CGRect = .zero
|
||||
@State private var addressBarHeight: CGFloat = 0
|
||||
@State private var lastHandledAddressBarFocusRequestId: UUID?
|
||||
@State private var isBrowserThemeMenuPresented = false
|
||||
@State private var ghosttyBackgroundGeneration: Int = 0
|
||||
|
|
@ -298,6 +299,10 @@ struct BrowserPanelView: View {
|
|||
)
|
||||
}
|
||||
|
||||
private var browserContentAccessibilityIdentifier: String {
|
||||
"BrowserPanelContent.\(panel.id.uuidString)"
|
||||
}
|
||||
|
||||
private var omnibarPillBackgroundColor: NSColor {
|
||||
resolvedBrowserOmnibarPillBackgroundColor(
|
||||
for: browserChromeColorScheme,
|
||||
|
|
@ -306,21 +311,16 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
// Layering contract: browser Cmd+F UI is mounted in the portal-hosted AppKit
|
||||
// container. Rendering it here can hide it behind the portal-hosted WKWebView.
|
||||
VStack(spacing: 0) {
|
||||
addressBar
|
||||
webView
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius)
|
||||
.stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3)
|
||||
.shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10)
|
||||
.padding(FocusFlashPattern.ringInset)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay {
|
||||
// Keep Cmd+F usable when the browser is still in the empty new-tab
|
||||
// state (no WKWebView mounted yet). WebView-backed cases are hosted
|
||||
// in AppKit by WebViewRepresentable to avoid layering/clipping issues.
|
||||
// in AppKit by WindowBrowserPortal to avoid layering/clipping issues.
|
||||
if !panel.shouldRenderWebView, let searchState = panel.searchState {
|
||||
BrowserSearchOverlay(
|
||||
panelId: panel.id,
|
||||
|
|
@ -331,6 +331,13 @@ struct BrowserPanelView: View {
|
|||
)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius)
|
||||
.stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3)
|
||||
.shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10)
|
||||
.padding(FocusFlashPattern.ringInset)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
if addressBarFocused, !omnibarState.suggestions.isEmpty, omnibarPillFrame.width > 0 {
|
||||
OmnibarSuggestionsView(
|
||||
|
|
@ -357,6 +364,9 @@ struct BrowserPanelView: View {
|
|||
.onPreferenceChange(OmnibarPillFramePreferenceKey.self) { frame in
|
||||
omnibarPillFrame = frame
|
||||
}
|
||||
.onPreferenceChange(BrowserAddressBarHeightPreferenceKey.self) { height in
|
||||
addressBarHeight = height
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .webViewDidReceiveClick).filter { [weak panel] note in
|
||||
// Only handle clicks from our own webview.
|
||||
guard let webView = note.object as? CmuxWebView else { return false }
|
||||
|
|
@ -498,6 +508,15 @@ struct BrowserPanelView: View {
|
|||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, addressBarVerticalPadding)
|
||||
.background(browserChromeBackground)
|
||||
.background {
|
||||
GeometryReader { geo in
|
||||
Color.clear
|
||||
.preference(
|
||||
key: BrowserAddressBarHeightPreferenceKey.self,
|
||||
value: geo.size.height
|
||||
)
|
||||
}
|
||||
}
|
||||
// Keep the omnibar stack above WKWebView so the suggestions popup is visible.
|
||||
.zIndex(1)
|
||||
.environment(\.colorScheme, browserChromeColorScheme)
|
||||
|
|
@ -738,17 +757,27 @@ struct BrowserPanelView: View {
|
|||
if panel.shouldRenderWebView {
|
||||
WebViewRepresentable(
|
||||
panel: panel,
|
||||
browserSearchState: panel.searchState,
|
||||
shouldAttachWebView: isVisibleInUI,
|
||||
shouldFocusWebView: isFocused && !addressBarFocused,
|
||||
isPanelFocused: isFocused,
|
||||
portalZPriority: portalPriority,
|
||||
paneDropZone: paneDropZone
|
||||
paneDropZone: paneDropZone,
|
||||
searchOverlay: panel.searchState.map { searchState in
|
||||
BrowserPortalSearchOverlayConfiguration(
|
||||
panelId: panel.id,
|
||||
searchState: searchState,
|
||||
onNext: { panel.findNext() },
|
||||
onPrevious: { panel.findPrevious() },
|
||||
onClose: { panel.hideFind() }
|
||||
)
|
||||
},
|
||||
paneTopChromeHeight: addressBarHeight
|
||||
)
|
||||
// Keep the host stable for normal pane churn, but force a remount when
|
||||
// BrowserPanel replaces its underlying WKWebView after process termination.
|
||||
.id(panel.webViewInstanceID)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityIdentifier(browserContentAccessibilityIdentifier)
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
// Chrome-like behavior: clicking web content while editing the
|
||||
// omnibar should commit blur and revert transient edits.
|
||||
|
|
@ -759,6 +788,7 @@ struct BrowserPanelView: View {
|
|||
} else {
|
||||
Color(nsColor: browserChromeBackgroundColor)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityIdentifier(browserContentAccessibilityIdentifier)
|
||||
.onTapGesture {
|
||||
onRequestPanelFocus()
|
||||
if addressBarFocused {
|
||||
|
|
@ -1939,6 +1969,14 @@ private struct OmnibarPillFramePreferenceKey: PreferenceKey {
|
|||
}
|
||||
}
|
||||
|
||||
private struct BrowserAddressBarHeightPreferenceKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat = 0
|
||||
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = max(value, nextValue())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Omnibar State Machine
|
||||
|
||||
struct OmnibarState: Equatable {
|
||||
|
|
@ -3038,12 +3076,13 @@ 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?
|
||||
let searchOverlay: BrowserPortalSearchOverlayConfiguration?
|
||||
let paneTopChromeHeight: CGFloat
|
||||
|
||||
final class Coordinator {
|
||||
weak var panel: BrowserPanel?
|
||||
|
|
@ -3052,47 +3091,362 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
var desiredPortalVisibleInUI: Bool = true
|
||||
var desiredPortalZPriority: Int = 0
|
||||
var lastPortalHostId: ObjectIdentifier?
|
||||
var searchOverlayHostingView: NSHostingView<BrowserSearchOverlay>?
|
||||
}
|
||||
|
||||
private final class HostContainerView: NSView {
|
||||
final class HostContainerView: NSView {
|
||||
var onDidMoveToWindow: (() -> Void)?
|
||||
var onGeometryChanged: (() -> Void)?
|
||||
private struct HostedInspectorDividerHit {
|
||||
let containerView: NSView
|
||||
let pageView: NSView
|
||||
let inspectorView: NSView
|
||||
}
|
||||
|
||||
private struct HostedInspectorDividerDragState {
|
||||
let containerView: NSView
|
||||
let pageView: NSView
|
||||
let inspectorView: NSView
|
||||
let initialWindowX: CGFloat
|
||||
let initialPageFrame: NSRect
|
||||
let initialInspectorFrame: NSRect
|
||||
}
|
||||
|
||||
private enum DividerCursorKind: Equatable {
|
||||
case vertical
|
||||
|
||||
var cursor: NSCursor { .resizeLeftRight }
|
||||
}
|
||||
|
||||
private static let hostedInspectorDividerHitExpansion: CGFloat = 6
|
||||
private static let minimumHostedInspectorWidth: CGFloat = 120
|
||||
private var trackingArea: NSTrackingArea?
|
||||
private var activeDividerCursorKind: DividerCursorKind?
|
||||
private var hostedInspectorDividerDrag: HostedInspectorDividerDragState?
|
||||
private var preferredHostedInspectorWidth: CGFloat?
|
||||
private var isApplyingHostedInspectorLayout = false
|
||||
#if DEBUG
|
||||
private var lastLoggedHostedInspectorFrames: (page: NSRect, inspector: NSRect)?
|
||||
private var hasLoggedMissingHostedInspectorCandidate = false
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
private static func shouldLogPointerEvent(_ event: NSEvent?) -> Bool {
|
||||
switch event?.type {
|
||||
case .leftMouseDown, .leftMouseDragged, .leftMouseUp:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func debugLogHitTest(stage: String, point: NSPoint, passThrough: Bool, hitView: NSView?) {
|
||||
let event = NSApp.currentEvent
|
||||
guard Self.shouldLogPointerEvent(event) else { return }
|
||||
|
||||
let hitDesc: String = {
|
||||
guard let hitView else { return "nil" }
|
||||
let token = Unmanaged.passUnretained(hitView).toOpaque()
|
||||
return "\(type(of: hitView))@\(token)"
|
||||
}()
|
||||
let hostRectInContent: NSRect = {
|
||||
guard let window, let contentView = window.contentView else { return .zero }
|
||||
return contentView.convert(bounds, from: self)
|
||||
}()
|
||||
dlog(
|
||||
"browser.panel.host stage=\(stage) event=\(String(describing: event?.type)) " +
|
||||
"point=\(String(format: "%.1f,%.1f", point.x, point.y)) pass=\(passThrough ? 1 : 0) " +
|
||||
"hostFrameInContent=\(String(format: "%.1f,%.1f %.1fx%.1f", hostRectInContent.origin.x, hostRectInContent.origin.y, hostRectInContent.width, hostRectInContent.height)) " +
|
||||
"hit=\(hitDesc)"
|
||||
)
|
||||
}
|
||||
|
||||
private static func debugObjectID(_ object: AnyObject?) -> String {
|
||||
guard let object else { return "nil" }
|
||||
return String(describing: Unmanaged.passUnretained(object).toOpaque())
|
||||
}
|
||||
|
||||
private static func debugRect(_ rect: NSRect) -> String {
|
||||
String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.width, rect.height)
|
||||
}
|
||||
|
||||
private func debugLogHostedInspectorFrames(
|
||||
stage: String,
|
||||
point: NSPoint? = nil,
|
||||
hit: HostedInspectorDividerHit
|
||||
) {
|
||||
let pointDesc = point.map { String(format: "%.1f,%.1f", $0.x, $0.y) } ?? "nil"
|
||||
let preferredWidthDesc = preferredHostedInspectorWidth.map { String(format: "%.1f", $0) } ?? "nil"
|
||||
dlog(
|
||||
"browser.panel.hostedInspector stage=\(stage) point=\(pointDesc) " +
|
||||
"host=\(Self.debugObjectID(self)) container=\(Self.debugObjectID(hit.containerView)) " +
|
||||
"page=\(Self.debugObjectID(hit.pageView)) inspector=\(Self.debugObjectID(hit.inspectorView)) " +
|
||||
"preferredWidth=\(preferredWidthDesc) " +
|
||||
"hostFrame=\(Self.debugRect(frame)) hostBounds=\(Self.debugRect(bounds)) " +
|
||||
"containerBounds=\(Self.debugRect(hit.containerView.bounds)) " +
|
||||
"pageFrame=\(Self.debugRect(hit.pageView.frame)) " +
|
||||
"inspectorFrame=\(Self.debugRect(hit.inspectorView.frame))"
|
||||
)
|
||||
}
|
||||
|
||||
private func debugLogHostedInspectorLayoutIfNeeded(reason: String) {
|
||||
guard let hit = hostedInspectorDividerCandidate() else {
|
||||
if !hasLoggedMissingHostedInspectorCandidate,
|
||||
lastLoggedHostedInspectorFrames != nil || preferredHostedInspectorWidth != nil {
|
||||
let preferredWidthDesc = preferredHostedInspectorWidth.map {
|
||||
String(format: "%.1f", $0)
|
||||
} ?? "nil"
|
||||
lastLoggedHostedInspectorFrames = nil
|
||||
hasLoggedMissingHostedInspectorCandidate = true
|
||||
dlog(
|
||||
"browser.panel.hostedInspector stage=\(reason).candidateMissing " +
|
||||
"host=\(Self.debugObjectID(self)) preferredWidth=\(preferredWidthDesc)"
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
hasLoggedMissingHostedInspectorCandidate = false
|
||||
|
||||
let nextFrames = (page: hit.pageView.frame, inspector: hit.inspectorView.frame)
|
||||
if let lastLoggedHostedInspectorFrames,
|
||||
Self.rectApproximatelyEqual(lastLoggedHostedInspectorFrames.page, nextFrames.page),
|
||||
Self.rectApproximatelyEqual(lastLoggedHostedInspectorFrames.inspector, nextFrames.inspector) {
|
||||
return
|
||||
}
|
||||
|
||||
lastLoggedHostedInspectorFrames = nextFrames
|
||||
debugLogHostedInspectorFrames(stage: "\(reason).layout", hit: hit)
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool {
|
||||
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
|
||||
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
|
||||
abs(lhs.width - rhs.width) <= epsilon &&
|
||||
abs(lhs.height - rhs.height) <= epsilon
|
||||
}
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
if window == nil {
|
||||
clearActiveDividerCursor(restoreArrow: false)
|
||||
} else {
|
||||
reapplyHostedInspectorDividerIfNeeded(reason: "viewDidMoveToWindow")
|
||||
}
|
||||
window?.invalidateCursorRects(for: self)
|
||||
onDidMoveToWindow?()
|
||||
onGeometryChanged?()
|
||||
#if DEBUG
|
||||
debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToWindow")
|
||||
#endif
|
||||
}
|
||||
|
||||
override func viewDidMoveToSuperview() {
|
||||
super.viewDidMoveToSuperview()
|
||||
reapplyHostedInspectorDividerIfNeeded(reason: "viewDidMoveToSuperview")
|
||||
onGeometryChanged?()
|
||||
#if DEBUG
|
||||
debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToSuperview")
|
||||
#endif
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
reapplyHostedInspectorDividerIfNeeded(reason: "layout")
|
||||
onGeometryChanged?()
|
||||
#if DEBUG
|
||||
debugLogHostedInspectorLayoutIfNeeded(reason: "layout")
|
||||
#endif
|
||||
}
|
||||
|
||||
override func setFrameOrigin(_ newOrigin: NSPoint) {
|
||||
super.setFrameOrigin(newOrigin)
|
||||
window?.invalidateCursorRects(for: self)
|
||||
reapplyHostedInspectorDividerIfNeeded(reason: "setFrameOrigin")
|
||||
onGeometryChanged?()
|
||||
#if DEBUG
|
||||
debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameOrigin")
|
||||
#endif
|
||||
}
|
||||
|
||||
override func setFrameSize(_ newSize: NSSize) {
|
||||
super.setFrameSize(newSize)
|
||||
window?.invalidateCursorRects(for: self)
|
||||
reapplyHostedInspectorDividerIfNeeded(reason: "setFrameSize")
|
||||
onGeometryChanged?()
|
||||
#if DEBUG
|
||||
debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameSize")
|
||||
#endif
|
||||
}
|
||||
|
||||
override func resetCursorRects() {
|
||||
super.resetCursorRects()
|
||||
guard let hostedInspectorHit = hostedInspectorDividerCandidate() else { return }
|
||||
let clipped = hostedInspectorDividerHitRect(for: hostedInspectorHit).intersection(bounds)
|
||||
guard !clipped.isNull, clipped.width > 0, clipped.height > 0 else { return }
|
||||
addCursorRect(clipped, cursor: NSCursor.resizeLeftRight)
|
||||
}
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
if let trackingArea {
|
||||
removeTrackingArea(trackingArea)
|
||||
}
|
||||
let options: NSTrackingArea.Options = [
|
||||
.inVisibleRect,
|
||||
.activeAlways,
|
||||
.cursorUpdate,
|
||||
.mouseMoved,
|
||||
.mouseEnteredAndExited,
|
||||
.enabledDuringMouseDrag,
|
||||
]
|
||||
let next = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
|
||||
addTrackingArea(next)
|
||||
trackingArea = next
|
||||
super.updateTrackingAreas()
|
||||
}
|
||||
|
||||
override func cursorUpdate(with event: NSEvent) {
|
||||
updateDividerCursor(at: convert(event.locationInWindow, from: nil))
|
||||
}
|
||||
|
||||
override func mouseMoved(with event: NSEvent) {
|
||||
updateDividerCursor(at: convert(event.locationInWindow, from: nil))
|
||||
}
|
||||
|
||||
override func mouseExited(with event: NSEvent) {
|
||||
clearActiveDividerCursor(restoreArrow: true)
|
||||
}
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
if shouldPassThroughToSidebarResizer(at: point) {
|
||||
let hostedInspectorHit = hostedInspectorDividerHit(at: point)
|
||||
updateDividerCursor(at: point, hostedInspectorHit: hostedInspectorHit)
|
||||
let passThrough = shouldPassThroughToSidebarResizer(at: point, hostedInspectorHit: hostedInspectorHit)
|
||||
if passThrough {
|
||||
#if DEBUG
|
||||
debugLogHitTest(stage: "hitTest.pass", point: point, passThrough: true, hitView: nil)
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
return super.hitTest(point)
|
||||
if let hostedInspectorHit {
|
||||
if let nativeHit = nativeHostedInspectorHit(at: point, hostedInspectorHit: hostedInspectorHit) {
|
||||
#if DEBUG
|
||||
debugLogHitTest(stage: "hitTest.hostedInspectorNative", point: point, passThrough: false, hitView: nativeHit)
|
||||
#endif
|
||||
return nativeHit
|
||||
}
|
||||
#if DEBUG
|
||||
debugLogHitTest(stage: "hitTest.hostedInspectorManual", point: point, passThrough: false, hitView: hostedInspectorHit.inspectorView)
|
||||
#endif
|
||||
return self
|
||||
}
|
||||
let hit = super.hitTest(point)
|
||||
#if DEBUG
|
||||
debugLogHitTest(stage: "hitTest.result", point: point, passThrough: false, hitView: hit)
|
||||
#endif
|
||||
return hit
|
||||
}
|
||||
|
||||
private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
guard let hostedInspectorHit = hostedInspectorDividerHit(at: point) else {
|
||||
super.mouseDown(with: event)
|
||||
return
|
||||
}
|
||||
|
||||
hostedInspectorDividerDrag = HostedInspectorDividerDragState(
|
||||
containerView: hostedInspectorHit.containerView,
|
||||
pageView: hostedInspectorHit.pageView,
|
||||
inspectorView: hostedInspectorHit.inspectorView,
|
||||
initialWindowX: event.locationInWindow.x,
|
||||
initialPageFrame: hostedInspectorHit.pageView.frame,
|
||||
initialInspectorFrame: hostedInspectorHit.inspectorView.frame
|
||||
)
|
||||
#if DEBUG
|
||||
debugLogHostedInspectorFrames(stage: "drag.start", point: point, hit: hostedInspectorHit)
|
||||
#endif
|
||||
}
|
||||
|
||||
override func mouseDragged(with event: NSEvent) {
|
||||
guard let dragState = hostedInspectorDividerDrag else {
|
||||
super.mouseDragged(with: event)
|
||||
return
|
||||
}
|
||||
|
||||
let containerBounds = dragState.containerView.bounds
|
||||
let minimumInspectorWidth = min(
|
||||
Self.minimumHostedInspectorWidth,
|
||||
max(60, dragState.initialInspectorFrame.width)
|
||||
)
|
||||
let minDividerX = max(containerBounds.minX, dragState.initialPageFrame.minX)
|
||||
let maxDividerX = max(minDividerX, containerBounds.maxX - minimumInspectorWidth)
|
||||
let proposedDividerX = dragState.initialInspectorFrame.minX + (event.locationInWindow.x - dragState.initialWindowX)
|
||||
let clampedDividerX = max(minDividerX, min(maxDividerX, proposedDividerX))
|
||||
let inspectorWidth = max(0, containerBounds.maxX - clampedDividerX)
|
||||
preferredHostedInspectorWidth = inspectorWidth
|
||||
_ = applyHostedInspectorDividerWidth(
|
||||
inspectorWidth,
|
||||
to: HostedInspectorDividerHit(
|
||||
containerView: dragState.containerView,
|
||||
pageView: dragState.pageView,
|
||||
inspectorView: dragState.inspectorView
|
||||
),
|
||||
reason: "drag"
|
||||
)
|
||||
#if DEBUG
|
||||
debugLogHostedInspectorFrames(
|
||||
stage: "drag.update",
|
||||
point: convert(event.locationInWindow, from: nil),
|
||||
hit: HostedInspectorDividerHit(
|
||||
containerView: dragState.containerView,
|
||||
pageView: dragState.pageView,
|
||||
inspectorView: dragState.inspectorView
|
||||
)
|
||||
)
|
||||
#endif
|
||||
updateDividerCursor(
|
||||
at: convert(event.locationInWindow, from: nil),
|
||||
hostedInspectorHit: HostedInspectorDividerHit(
|
||||
containerView: dragState.containerView,
|
||||
pageView: dragState.pageView,
|
||||
inspectorView: dragState.inspectorView
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
let finalDragState = hostedInspectorDividerDrag
|
||||
hostedInspectorDividerDrag = nil
|
||||
updateDividerCursor(at: convert(event.locationInWindow, from: nil))
|
||||
scheduleHostedInspectorDividerReapply(reason: "dragEndAsync")
|
||||
#if DEBUG
|
||||
if let finalDragState {
|
||||
let finalHit = HostedInspectorDividerHit(
|
||||
containerView: finalDragState.containerView,
|
||||
pageView: finalDragState.pageView,
|
||||
inspectorView: finalDragState.inspectorView
|
||||
)
|
||||
debugLogHostedInspectorFrames(
|
||||
stage: "drag.end",
|
||||
point: convert(event.locationInWindow, from: nil),
|
||||
hit: finalHit
|
||||
)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.reapplyHostedInspectorDividerIfNeeded(reason: "drag.end.async")
|
||||
self.debugLogHostedInspectorFrames(stage: "drag.end.async", hit: finalHit)
|
||||
self.debugLogHostedInspectorLayoutIfNeeded(reason: "dragEndAsync")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
super.mouseUp(with: event)
|
||||
}
|
||||
|
||||
private func shouldPassThroughToSidebarResizer(
|
||||
at point: NSPoint,
|
||||
hostedInspectorHit: HostedInspectorDividerHit? = nil
|
||||
) -> Bool {
|
||||
if hostedInspectorHit != nil {
|
||||
return false
|
||||
}
|
||||
// Pass through a narrow leading-edge band so the shared sidebar divider
|
||||
// handle can receive hover/click even when WKWebView is attached here.
|
||||
// Keeping this deterministic avoids flicker from dynamic left-edge scans.
|
||||
|
|
@ -3105,6 +3459,250 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
let hostRectInContent = contentView.convert(bounds, from: self)
|
||||
return hostRectInContent.minX > 1
|
||||
}
|
||||
|
||||
private func updateDividerCursor(
|
||||
at point: NSPoint,
|
||||
hostedInspectorHit: HostedInspectorDividerHit? = nil
|
||||
) {
|
||||
let resolvedHostedInspectorHit = hostedInspectorHit ?? hostedInspectorDividerHit(at: point)
|
||||
if shouldPassThroughToSidebarResizer(at: point, hostedInspectorHit: resolvedHostedInspectorHit) {
|
||||
clearActiveDividerCursor(restoreArrow: false)
|
||||
return
|
||||
}
|
||||
guard resolvedHostedInspectorHit != nil else {
|
||||
clearActiveDividerCursor(restoreArrow: true)
|
||||
return
|
||||
}
|
||||
activeDividerCursorKind = .vertical
|
||||
NSCursor.resizeLeftRight.set()
|
||||
}
|
||||
|
||||
private func clearActiveDividerCursor(restoreArrow: Bool) {
|
||||
guard activeDividerCursorKind != nil else { return }
|
||||
window?.invalidateCursorRects(for: self)
|
||||
activeDividerCursorKind = nil
|
||||
if restoreArrow {
|
||||
NSCursor.arrow.set()
|
||||
}
|
||||
}
|
||||
|
||||
private func nativeHostedInspectorHit(
|
||||
at point: NSPoint,
|
||||
hostedInspectorHit: HostedInspectorDividerHit
|
||||
) -> NSView? {
|
||||
guard let nativeHit = super.hitTest(point), nativeHit !== self else { return nil }
|
||||
if nativeHit === hostedInspectorHit.pageView ||
|
||||
nativeHit.isDescendant(of: hostedInspectorHit.pageView) {
|
||||
return nil
|
||||
}
|
||||
if nativeHit === hostedInspectorHit.inspectorView ||
|
||||
nativeHit.isDescendant(of: hostedInspectorHit.inspectorView) {
|
||||
return nativeHit
|
||||
}
|
||||
if hostedInspectorHit.inspectorView.isDescendant(of: nativeHit),
|
||||
!(hostedInspectorHit.pageView === nativeHit || hostedInspectorHit.pageView.isDescendant(of: nativeHit)) {
|
||||
return nativeHit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func hostedInspectorDividerHit(at point: NSPoint) -> HostedInspectorDividerHit? {
|
||||
guard let hit = hostedInspectorDividerCandidate(),
|
||||
hostedInspectorDividerHitRect(for: hit).contains(point) else {
|
||||
return nil
|
||||
}
|
||||
return hit
|
||||
}
|
||||
|
||||
private func hostedInspectorDividerCandidate() -> HostedInspectorDividerHit? {
|
||||
let inspectorCandidates = Self.visibleDescendants(in: self)
|
||||
.filter { Self.isVisibleHostedInspectorCandidate($0) && Self.isInspectorView($0) }
|
||||
.sorted { lhs, rhs in
|
||||
let lhsFrame = convert(lhs.bounds, from: lhs)
|
||||
let rhsFrame = convert(rhs.bounds, from: rhs)
|
||||
return lhsFrame.minX < rhsFrame.minX
|
||||
}
|
||||
|
||||
var bestHit: HostedInspectorDividerHit?
|
||||
var bestScore = -CGFloat.greatestFiniteMagnitude
|
||||
|
||||
for inspectorCandidate in inspectorCandidates {
|
||||
guard let candidate = hostedInspectorDividerCandidate(startingAt: inspectorCandidate) else {
|
||||
continue
|
||||
}
|
||||
let score = hostedInspectorDividerCandidateScore(candidate)
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestHit = candidate
|
||||
}
|
||||
}
|
||||
|
||||
return bestHit
|
||||
}
|
||||
|
||||
private func hostedInspectorDividerHitRect(for hit: HostedInspectorDividerHit) -> NSRect {
|
||||
let pageFrame = convert(hit.pageView.bounds, from: hit.pageView)
|
||||
let inspectorFrame = convert(hit.inspectorView.bounds, from: hit.inspectorView)
|
||||
let minY = max(bounds.minY, min(pageFrame.minY, inspectorFrame.minY))
|
||||
let maxY = min(bounds.maxY, max(pageFrame.maxY, inspectorFrame.maxY))
|
||||
return NSRect(
|
||||
x: inspectorFrame.minX - Self.hostedInspectorDividerHitExpansion,
|
||||
y: minY,
|
||||
width: Self.hostedInspectorDividerHitExpansion * 2,
|
||||
height: max(0, maxY - minY)
|
||||
)
|
||||
}
|
||||
|
||||
private func hostedInspectorDividerCandidate(startingAt inspectorLeaf: NSView) -> HostedInspectorDividerHit? {
|
||||
var current: NSView? = inspectorLeaf
|
||||
var bestHit: HostedInspectorDividerHit?
|
||||
|
||||
while let inspectorView = current, inspectorView !== self {
|
||||
guard let containerView = inspectorView.superview else { break }
|
||||
|
||||
let pageCandidates = containerView.subviews.filter { candidate in
|
||||
guard Self.isVisibleHostedInspectorSiblingCandidate(candidate) else { return false }
|
||||
guard candidate !== inspectorView else { return false }
|
||||
guard candidate.frame.maxX <= inspectorView.frame.minX + 1 else { return false }
|
||||
return Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8
|
||||
}
|
||||
|
||||
if let pageView = pageCandidates.max(by: {
|
||||
hostedInspectorPageCandidateScore($0, inspectorView: inspectorView)
|
||||
< hostedInspectorPageCandidateScore($1, inspectorView: inspectorView)
|
||||
}) {
|
||||
bestHit = HostedInspectorDividerHit(
|
||||
containerView: containerView,
|
||||
pageView: pageView,
|
||||
inspectorView: inspectorView
|
||||
)
|
||||
}
|
||||
|
||||
current = containerView
|
||||
}
|
||||
|
||||
return bestHit
|
||||
}
|
||||
|
||||
private func hostedInspectorDividerCandidateScore(_ hit: HostedInspectorDividerHit) -> CGFloat {
|
||||
let pageFrame = convert(hit.pageView.bounds, from: hit.pageView)
|
||||
let inspectorFrame = convert(hit.inspectorView.bounds, from: hit.inspectorView)
|
||||
let overlap = Self.verticalOverlap(between: pageFrame, and: inspectorFrame)
|
||||
let coverageWidth = max(pageFrame.maxX, inspectorFrame.maxX) - min(pageFrame.minX, inspectorFrame.minX)
|
||||
return (overlap * 1_000) + coverageWidth + pageFrame.width
|
||||
}
|
||||
|
||||
private func hostedInspectorPageCandidateScore(_ pageView: NSView, inspectorView: NSView) -> CGFloat {
|
||||
let overlap = Self.verticalOverlap(between: pageView.frame, and: inspectorView.frame)
|
||||
let coverageWidth = max(pageView.frame.maxX, inspectorView.frame.maxX) - min(pageView.frame.minX, inspectorView.frame.minX)
|
||||
return (overlap * 1_000) + coverageWidth + pageView.frame.width
|
||||
}
|
||||
|
||||
private func scheduleHostedInspectorDividerReapply(reason: String) {
|
||||
guard preferredHostedInspectorWidth != nil else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.reapplyHostedInspectorDividerIfNeeded(reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
private func reapplyHostedInspectorDividerIfNeeded(reason: String) {
|
||||
guard !isApplyingHostedInspectorLayout else { return }
|
||||
guard let preferredWidth = preferredHostedInspectorWidth else { return }
|
||||
guard let hit = hostedInspectorDividerCandidate() else {
|
||||
#if DEBUG
|
||||
if !hasLoggedMissingHostedInspectorCandidate {
|
||||
hasLoggedMissingHostedInspectorCandidate = true
|
||||
dlog(
|
||||
"browser.panel.hostedInspector stage=\(reason).reapplyMissingCandidate " +
|
||||
"host=\(Self.debugObjectID(self)) preferredWidth=\(String(format: "%.1f", preferredWidth))"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
return
|
||||
}
|
||||
#if DEBUG
|
||||
hasLoggedMissingHostedInspectorCandidate = false
|
||||
#endif
|
||||
_ = applyHostedInspectorDividerWidth(preferredWidth, to: hit, reason: reason)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func applyHostedInspectorDividerWidth(
|
||||
_ preferredWidth: CGFloat,
|
||||
to hit: HostedInspectorDividerHit,
|
||||
reason: String
|
||||
) -> (pageFrame: NSRect, inspectorFrame: NSRect) {
|
||||
let containerBounds = hit.containerView.bounds
|
||||
let maximumInspectorWidth = max(0, containerBounds.maxX - hit.pageView.frame.minX)
|
||||
let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth))
|
||||
let dividerX = max(hit.pageView.frame.minX, containerBounds.maxX - clampedInspectorWidth)
|
||||
|
||||
var pageFrame = hit.pageView.frame
|
||||
pageFrame.size.width = max(0, dividerX - pageFrame.minX)
|
||||
|
||||
var inspectorFrame = hit.inspectorView.frame
|
||||
inspectorFrame.origin.x = dividerX
|
||||
inspectorFrame.size.width = max(0, containerBounds.maxX - dividerX)
|
||||
|
||||
let pageChanged = !Self.rectApproximatelyEqual(pageFrame, hit.pageView.frame, epsilon: 0.5)
|
||||
let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, hit.inspectorView.frame, epsilon: 0.5)
|
||||
guard pageChanged || inspectorChanged else {
|
||||
return (pageFrame, inspectorFrame)
|
||||
}
|
||||
|
||||
isApplyingHostedInspectorLayout = true
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
hit.pageView.frame = pageFrame
|
||||
hit.inspectorView.frame = inspectorFrame
|
||||
CATransaction.commit()
|
||||
isApplyingHostedInspectorLayout = false
|
||||
|
||||
hit.pageView.needsLayout = true
|
||||
hit.inspectorView.needsLayout = true
|
||||
hit.containerView.needsLayout = true
|
||||
needsLayout = true
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.panel.hostedInspector stage=\(reason).reapply " +
|
||||
"host=\(Self.debugObjectID(self)) preferredWidth=\(String(format: "%.1f", preferredWidth)) " +
|
||||
"container=\(Self.debugObjectID(hit.containerView)) " +
|
||||
"pageFrame=\(Self.debugRect(pageFrame)) inspectorFrame=\(Self.debugRect(inspectorFrame))"
|
||||
)
|
||||
#endif
|
||||
return (pageFrame, inspectorFrame)
|
||||
}
|
||||
|
||||
private static func visibleDescendants(in root: NSView) -> [NSView] {
|
||||
var descendants: [NSView] = []
|
||||
var stack = Array(root.subviews.reversed())
|
||||
while let view = stack.popLast() {
|
||||
descendants.append(view)
|
||||
stack.append(contentsOf: view.subviews.reversed())
|
||||
}
|
||||
return descendants
|
||||
}
|
||||
|
||||
private static func isInspectorView(_ view: NSView) -> Bool {
|
||||
String(describing: type(of: view)).contains("WKInspector")
|
||||
}
|
||||
|
||||
private static func isVisibleHostedInspectorCandidate(_ view: NSView) -> Bool {
|
||||
!view.isHidden &&
|
||||
view.alphaValue > 0 &&
|
||||
view.frame.width > 1 &&
|
||||
view.frame.height > 1
|
||||
}
|
||||
|
||||
private static func isVisibleHostedInspectorSiblingCandidate(_ view: NSView) -> Bool {
|
||||
!view.isHidden &&
|
||||
view.alphaValue > 0 &&
|
||||
view.frame.height > 1
|
||||
}
|
||||
|
||||
private static func verticalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat {
|
||||
max(0, min(lhs.maxY, rhs.maxY) - max(lhs.minY, rhs.minY))
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
|
@ -3205,65 +3803,16 @@ 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
|
||||
private static func installPortalAnchorView(_ anchorView: NSView, in host: NSView) {
|
||||
if anchorView.superview !== host {
|
||||
anchorView.removeFromSuperview()
|
||||
anchorView.frame = host.bounds
|
||||
anchorView.translatesAutoresizingMaskIntoConstraints = true
|
||||
anchorView.autoresizingMask = [.width, .height]
|
||||
host.addSubview(anchorView)
|
||||
} else if anchorView.frame != host.bounds {
|
||||
anchorView.frame = host.bounds
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -3277,32 +3826,35 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
coordinator.attachGeneration += 1
|
||||
let generation = coordinator.attachGeneration
|
||||
let paneDropContext = shouldAttachWebView ? currentPaneDropContext() : nil
|
||||
let activeSearchOverlay = shouldAttachWebView ? searchOverlay : nil
|
||||
let portalAnchorView = panel.portalAnchorView
|
||||
Self.installPortalAnchorView(portalAnchorView, in: host)
|
||||
|
||||
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator] in
|
||||
guard let host, let webView, let coordinator else { return }
|
||||
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator, weak portalAnchorView] in
|
||||
guard let host, let webView, let coordinator, let portalAnchorView else { return }
|
||||
guard coordinator.attachGeneration == generation else { return }
|
||||
Self.installPortalAnchorView(portalAnchorView, in: host)
|
||||
guard host.window != nil else { return }
|
||||
BrowserWindowPortalRegistry.bind(
|
||||
webView: webView,
|
||||
to: host,
|
||||
to: portalAnchorView,
|
||||
visibleInUI: coordinator.desiredPortalVisibleInUI,
|
||||
zPriority: coordinator.desiredPortalZPriority
|
||||
)
|
||||
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
|
||||
for: webView,
|
||||
height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0
|
||||
)
|
||||
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: paneDropContext)
|
||||
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
|
||||
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 }
|
||||
host.onGeometryChanged = { [weak host, weak coordinator, weak portalAnchorView] in
|
||||
guard let host, let coordinator, let portalAnchorView else { return }
|
||||
guard coordinator.attachGeneration == generation else { return }
|
||||
guard coordinator.lastPortalHostId == ObjectIdentifier(host) else { return }
|
||||
BrowserWindowPortalRegistry.synchronizeForAnchor(host)
|
||||
Self.installPortalAnchorView(portalAnchorView, in: host)
|
||||
BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView)
|
||||
}
|
||||
|
||||
if !shouldAttachWebView {
|
||||
|
|
@ -3319,20 +3871,21 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
previousVisible != shouldAttachWebView ||
|
||||
previousZPriority != portalZPriority
|
||||
if shouldBindNow {
|
||||
Self.installPortalAnchorView(portalAnchorView, in: host)
|
||||
BrowserWindowPortalRegistry.bind(
|
||||
webView: webView,
|
||||
to: host,
|
||||
to: portalAnchorView,
|
||||
visibleInUI: coordinator.desiredPortalVisibleInUI,
|
||||
zPriority: coordinator.desiredPortalZPriority
|
||||
)
|
||||
coordinator.lastPortalHostId = hostId
|
||||
}
|
||||
BrowserWindowPortalRegistry.synchronizeForAnchor(host)
|
||||
Self.updateSearchOverlay(
|
||||
panel: panel,
|
||||
coordinator: coordinator,
|
||||
containerView: webView.superview
|
||||
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
|
||||
for: webView,
|
||||
height: shouldAttachWebView ? paneTopChromeHeight : 0
|
||||
)
|
||||
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
|
||||
BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView)
|
||||
} 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
|
||||
|
|
@ -3342,17 +3895,21 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
visibleInUI: coordinator.desiredPortalVisibleInUI,
|
||||
zPriority: coordinator.desiredPortalZPriority
|
||||
)
|
||||
Self.removeSearchOverlay(from: coordinator)
|
||||
}
|
||||
|
||||
BrowserWindowPortalRegistry.updateDropZoneOverlay(
|
||||
for: webView,
|
||||
zone: shouldAttachWebView ? paneDropZone : nil
|
||||
)
|
||||
BrowserWindowPortalRegistry.updatePaneTopChromeHeight(
|
||||
for: webView,
|
||||
height: shouldAttachWebView ? paneTopChromeHeight : 0
|
||||
)
|
||||
BrowserWindowPortalRegistry.updatePaneDropContext(
|
||||
for: webView,
|
||||
context: paneDropContext
|
||||
)
|
||||
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay)
|
||||
|
||||
panel.restoreDeveloperToolsAfterAttachIfNeeded()
|
||||
|
||||
|
|
@ -3371,7 +3928,6 @@ 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
|
||||
}
|
||||
|
|
@ -3443,7 +3999,6 @@ 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
|
||||
|
|
@ -3473,7 +4028,9 @@ struct WebViewRepresentable: NSViewRepresentable {
|
|||
// 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.updatePaneTopChromeHeight(for: webView, height: 0)
|
||||
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: nil)
|
||||
BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: nil)
|
||||
coordinator.lastPortalHostId = nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import AppKit
|
||||
import SwiftUI
|
||||
import MarkdownUI
|
||||
|
||||
|
|
@ -30,9 +31,12 @@ struct MarkdownPanelView: View {
|
|||
.padding(FocusFlashPattern.ringInset)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onRequestPanelFocus()
|
||||
.overlay {
|
||||
if isVisibleInUI {
|
||||
// Observe left-clicks without intercepting them so markdown text
|
||||
// selection and link activation continue to use the native path.
|
||||
MarkdownPointerObserver(onPointerDown: onRequestPanelFocus)
|
||||
}
|
||||
}
|
||||
.onChange(of: panel.focusFlashToken) { _ in
|
||||
triggerFocusFlashAnimation()
|
||||
|
|
@ -283,3 +287,67 @@ struct MarkdownPanelView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MarkdownPointerObserver: NSViewRepresentable {
|
||||
let onPointerDown: () -> Void
|
||||
|
||||
func makeNSView(context: Context) -> MarkdownPanelPointerObserverView {
|
||||
let view = MarkdownPanelPointerObserverView()
|
||||
view.onPointerDown = onPointerDown
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: MarkdownPanelPointerObserverView, context: Context) {
|
||||
nsView.onPointerDown = onPointerDown
|
||||
}
|
||||
}
|
||||
|
||||
final class MarkdownPanelPointerObserverView: NSView {
|
||||
var onPointerDown: (() -> Void)?
|
||||
private var eventMonitor: Any?
|
||||
|
||||
override var mouseDownCanMoveWindow: Bool { false }
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
installEventMonitorIfNeeded()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let eventMonitor {
|
||||
NSEvent.removeMonitor(eventMonitor)
|
||||
}
|
||||
}
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
nil
|
||||
}
|
||||
|
||||
func shouldHandle(_ event: NSEvent) -> Bool {
|
||||
guard event.type == .leftMouseDown,
|
||||
let window,
|
||||
event.window === window,
|
||||
!isHiddenOrHasHiddenAncestor else { return false }
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
return bounds.contains(point)
|
||||
}
|
||||
|
||||
func handleEventIfNeeded(_ event: NSEvent) -> NSEvent {
|
||||
guard shouldHandle(event) else { return event }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onPointerDown?()
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
private func installEventMonitorIfNeeded() {
|
||||
guard eventMonitor == nil else { return }
|
||||
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in
|
||||
self?.handleEventIfNeeded(event) ?? event
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -558,16 +558,23 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback(
|
|||
|
||||
@MainActor
|
||||
class TabManager: ObservableObject {
|
||||
private struct InitialWorkspaceGitMetadataSnapshot: Equatable {
|
||||
let branch: String?
|
||||
let isDirty: Bool
|
||||
}
|
||||
|
||||
/// The window that owns this TabManager. Set by AppDelegate.registerMainWindow().
|
||||
/// Used to apply title updates to the correct window instead of NSApp.keyWindow.
|
||||
weak var window: NSWindow?
|
||||
|
||||
@Published var tabs: [Workspace] = []
|
||||
@Published private(set) var isWorkspaceCycleHot: Bool = false
|
||||
@Published private(set) var pendingBackgroundWorkspaceLoadIds: Set<UUID> = []
|
||||
|
||||
/// Global monotonically increasing counter for CMUX_PORT ordinal assignment.
|
||||
/// Static so port ranges don't overlap across multiple windows (each window has its own TabManager).
|
||||
private static var nextPortOrdinal: Int = 0
|
||||
private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0]
|
||||
@Published var selectedTabId: UUID? {
|
||||
didSet {
|
||||
guard selectedTabId != oldValue else { return }
|
||||
|
|
@ -599,7 +606,7 @@ class TabManager: ObservableObject {
|
|||
self.focusSelectedTabPanel(previousTabId: previousTabId)
|
||||
self.updateWindowTitleForSelectedTab()
|
||||
if let selectedTabId = self.selectedTabId {
|
||||
self.flashFocusedPanelIfUnreadAndActive(tabId: selectedTabId)
|
||||
self.markFocusedPanelReadIfActive(tabId: selectedTabId)
|
||||
}
|
||||
#if DEBUG
|
||||
let dtMs = self.debugWorkspaceSwitchStartTime > 0
|
||||
|
|
@ -623,6 +630,12 @@ class TabManager: ObservableObject {
|
|||
private var pendingPanelTitleUpdates: [PanelTitleUpdateKey: String] = [:]
|
||||
private let panelTitleUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
|
||||
private var recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20)
|
||||
private let initialWorkspaceGitProbeQueue = DispatchQueue(
|
||||
label: "com.cmux.initial-workspace-git-probe",
|
||||
qos: .utility
|
||||
)
|
||||
private var initialWorkspaceGitProbeGenerationByWorkspace: [UUID: UUID] = [:]
|
||||
private var initialWorkspaceGitProbeTimersByWorkspace: [UUID: [DispatchSourceTimer]] = [:]
|
||||
|
||||
// Recent tab history for back/forward navigation (like browser history)
|
||||
private var tabHistory: [UUID] = []
|
||||
|
|
@ -671,7 +684,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 }
|
||||
flashPanelIfUnreadAndActive(tabId: tabId, panelId: surfaceId)
|
||||
markPanelReadOnFocusIfActive(tabId: tabId, panelId: surfaceId)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -799,10 +812,12 @@ class TabManager: ObservableObject {
|
|||
func addWorkspace(
|
||||
workingDirectory overrideWorkingDirectory: String? = nil,
|
||||
select: Bool = true,
|
||||
eagerLoadTerminal: Bool = false,
|
||||
placementOverride: NewWorkspacePlacement? = nil
|
||||
) -> Workspace {
|
||||
sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1])
|
||||
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
|
||||
let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory)
|
||||
let workingDirectory = explicitWorkingDirectory ?? preferredWorkingDirectoryForNewTab()
|
||||
let inheritedConfig = inheritedTerminalConfigForNewWorkspace()
|
||||
let ordinal = Self.nextPortOrdinal
|
||||
Self.nextPortOrdinal += 1
|
||||
|
|
@ -819,6 +834,18 @@ class TabManager: ObservableObject {
|
|||
} else {
|
||||
tabs.append(newWorkspace)
|
||||
}
|
||||
if let explicitWorkingDirectory,
|
||||
let terminalPanel = newWorkspace.focusedTerminalPanel {
|
||||
scheduleInitialWorkspaceGitMetadataRefresh(
|
||||
workspaceId: newWorkspace.id,
|
||||
panelId: terminalPanel.id,
|
||||
directory: explicitWorkingDirectory
|
||||
)
|
||||
}
|
||||
if eagerLoadTerminal {
|
||||
requestBackgroundWorkspaceLoad(for: newWorkspace.id)
|
||||
newWorkspace.requestBackgroundTerminalSurfaceStartIfNeeded()
|
||||
}
|
||||
if select {
|
||||
selectedTabId = newWorkspace.id
|
||||
NotificationCenter.default.post(
|
||||
|
|
@ -837,9 +864,187 @@ class TabManager: ObservableObject {
|
|||
return newWorkspace
|
||||
}
|
||||
|
||||
private func scheduleInitialWorkspaceGitMetadataRefresh(
|
||||
workspaceId: UUID,
|
||||
panelId: UUID,
|
||||
directory: String
|
||||
) {
|
||||
let normalizedDirectory = normalizeDirectory(directory)
|
||||
let generation = UUID()
|
||||
cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId)
|
||||
initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] = generation
|
||||
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"workspace.gitProbe.schedule workspace=\(workspaceId.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) dir=\(normalizedDirectory)"
|
||||
)
|
||||
#endif
|
||||
|
||||
let delays = Self.initialWorkspaceGitProbeDelays
|
||||
var timers: [DispatchSourceTimer] = []
|
||||
for (index, delay) in delays.enumerated() {
|
||||
let isLastAttempt = index == delays.count - 1
|
||||
let timer = DispatchSource.makeTimerSource(queue: initialWorkspaceGitProbeQueue)
|
||||
timer.schedule(deadline: .now() + delay, repeating: .never)
|
||||
timer.setEventHandler { [weak self] in
|
||||
let snapshot = Self.initialWorkspaceGitMetadataSnapshot(for: normalizedDirectory)
|
||||
Task { @MainActor [weak self] in
|
||||
self?.applyInitialWorkspaceGitMetadataSnapshot(
|
||||
snapshot,
|
||||
generation: generation,
|
||||
workspaceId: workspaceId,
|
||||
panelId: panelId,
|
||||
expectedDirectory: normalizedDirectory,
|
||||
isLastAttempt: isLastAttempt
|
||||
)
|
||||
}
|
||||
}
|
||||
timers.append(timer)
|
||||
timer.resume()
|
||||
}
|
||||
initialWorkspaceGitProbeTimersByWorkspace[workspaceId] = timers
|
||||
}
|
||||
|
||||
private func cancelInitialWorkspaceGitProbeTimers(workspaceId: UUID) {
|
||||
guard let timers = initialWorkspaceGitProbeTimersByWorkspace.removeValue(forKey: workspaceId) else {
|
||||
return
|
||||
}
|
||||
for timer in timers {
|
||||
timer.setEventHandler {}
|
||||
timer.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func clearInitialWorkspaceGitProbe(workspaceId: UUID) {
|
||||
initialWorkspaceGitProbeGenerationByWorkspace.removeValue(forKey: workspaceId)
|
||||
cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId)
|
||||
}
|
||||
|
||||
private func applyInitialWorkspaceGitMetadataSnapshot(
|
||||
_ snapshot: InitialWorkspaceGitMetadataSnapshot,
|
||||
generation: UUID,
|
||||
workspaceId: UUID,
|
||||
panelId: UUID,
|
||||
expectedDirectory: String,
|
||||
isLastAttempt: Bool
|
||||
) {
|
||||
defer {
|
||||
if isLastAttempt,
|
||||
initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation {
|
||||
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
|
||||
}
|
||||
}
|
||||
|
||||
guard initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation else { return }
|
||||
guard let workspace = tabs.first(where: { $0.id == workspaceId }) else {
|
||||
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
|
||||
return
|
||||
}
|
||||
guard workspace.panels[panelId] != nil else {
|
||||
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
|
||||
return
|
||||
}
|
||||
|
||||
let currentDirectory = normalizedWorkingDirectory(
|
||||
workspace.panelDirectories[panelId] ?? workspace.currentDirectory
|
||||
)
|
||||
if let currentDirectory, currentDirectory != expectedDirectory {
|
||||
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"workspace.gitProbe.skip workspace=\(workspaceId.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) reason=directoryChanged " +
|
||||
"expected=\(expectedDirectory) current=\(currentDirectory)"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
workspace.updatePanelDirectory(panelId: panelId, directory: expectedDirectory)
|
||||
|
||||
let previousBranch = Self.normalizedBranchName(workspace.panelGitBranches[panelId]?.branch)
|
||||
let nextBranch = snapshot.branch
|
||||
if let nextBranch {
|
||||
workspace.updatePanelGitBranch(panelId: panelId, branch: nextBranch, isDirty: snapshot.isDirty)
|
||||
} else {
|
||||
workspace.clearPanelGitBranch(panelId: panelId)
|
||||
}
|
||||
|
||||
if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) {
|
||||
workspace.clearPanelPullRequest(panelId: panelId)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
let branchLabel = snapshot.branch ?? "none"
|
||||
dlog(
|
||||
"workspace.gitProbe.apply workspace=\(workspaceId.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
private nonisolated static func initialWorkspaceGitMetadataSnapshot(
|
||||
for directory: String
|
||||
) -> InitialWorkspaceGitMetadataSnapshot {
|
||||
let branch = normalizedBranchName(runGitCommand(directory: directory, arguments: ["branch", "--show-current"]))
|
||||
guard let branch else {
|
||||
return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false)
|
||||
}
|
||||
|
||||
let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"])
|
||||
let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
|
||||
return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty)
|
||||
}
|
||||
|
||||
private nonisolated static func runGitCommand(directory: String, arguments: [String]) -> String? {
|
||||
let process = Process()
|
||||
let stdout = Pipe()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
process.arguments = ["git", "-C", directory] + arguments
|
||||
process.standardOutput = stdout
|
||||
process.standardError = FileHandle.nullDevice
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Drain stdout while the subprocess is active so large repos cannot fill the pipe buffer.
|
||||
let data = stdout.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
guard process.terminationStatus == 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private nonisolated static func normalizedBranchName(_ branch: String?) -> String? {
|
||||
let trimmed = branch?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
func requestBackgroundWorkspaceLoad(for workspaceId: UUID) {
|
||||
guard pendingBackgroundWorkspaceLoadIds.insert(workspaceId).inserted else { return }
|
||||
}
|
||||
|
||||
func completeBackgroundWorkspaceLoad(for workspaceId: UUID) {
|
||||
guard pendingBackgroundWorkspaceLoadIds.remove(workspaceId) != nil else { return }
|
||||
}
|
||||
|
||||
func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) {
|
||||
let pruned = pendingBackgroundWorkspaceLoadIds.intersection(existingIds)
|
||||
guard pruned != pendingBackgroundWorkspaceLoadIds else { return }
|
||||
pendingBackgroundWorkspaceLoadIds = pruned
|
||||
}
|
||||
|
||||
// Keep addTab as convenience alias
|
||||
@discardableResult
|
||||
func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) }
|
||||
func addTab(select: Bool = true, eagerLoadTerminal: Bool = false) -> Workspace {
|
||||
addWorkspace(select: select, eagerLoadTerminal: eagerLoadTerminal)
|
||||
}
|
||||
|
||||
func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? {
|
||||
guard let workspace = selectedWorkspace else { return nil }
|
||||
|
|
@ -1015,6 +1220,7 @@ class TabManager: ObservableObject {
|
|||
guard tabs.count > 1 else { return }
|
||||
guard let index = tabs.firstIndex(where: { $0.id == workspace.id }) else { return }
|
||||
sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])
|
||||
clearInitialWorkspaceGitProbe(workspaceId: workspace.id)
|
||||
|
||||
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
|
||||
unwireClosedBrowserTracking(for: workspace)
|
||||
|
|
@ -1036,6 +1242,7 @@ class TabManager: ObservableObject {
|
|||
@discardableResult
|
||||
func detachWorkspace(tabId: UUID) -> Workspace? {
|
||||
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil }
|
||||
clearInitialWorkspaceGitProbe(workspaceId: tabId)
|
||||
|
||||
let removed = tabs.remove(at: index)
|
||||
unwireClosedBrowserTracking(for: removed)
|
||||
|
|
@ -1596,16 +1803,16 @@ class TabManager: ObservableObject {
|
|||
selectedTabId != pendingTabId
|
||||
}
|
||||
|
||||
private func flashFocusedPanelIfUnreadAndActive(tabId: UUID) {
|
||||
private func markFocusedPanelReadIfActive(tabId: UUID) {
|
||||
let shouldSuppressFlash = suppressFocusFlash
|
||||
suppressFocusFlash = false
|
||||
guard !shouldSuppressFlash else { return }
|
||||
guard AppFocusState.isAppActive() else { return }
|
||||
guard let panelId = focusedPanelId(for: tabId) else { return }
|
||||
flashPanelIfUnreadAndActive(tabId: tabId, panelId: panelId)
|
||||
markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId)
|
||||
}
|
||||
|
||||
private func flashPanelIfUnreadAndActive(tabId: UUID, panelId: UUID) {
|
||||
private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) {
|
||||
guard selectedTabId == tabId else { return }
|
||||
guard !suppressFocusFlash else { return }
|
||||
guard AppFocusState.isAppActive() else { return }
|
||||
|
|
@ -1614,6 +1821,7 @@ 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) {
|
||||
|
|
@ -1739,6 +1947,7 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3676,6 +3885,7 @@ extension Notification.Name {
|
|||
static let commandPaletteMoveSelection = Notification.Name("cmux.commandPaletteMoveSelection")
|
||||
static let commandPaletteRenameInputInteractionRequested = Notification.Name("cmux.commandPaletteRenameInputInteractionRequested")
|
||||
static let commandPaletteRenameInputDeleteBackwardRequested = Notification.Name("cmux.commandPaletteRenameInputDeleteBackwardRequested")
|
||||
static let feedbackComposerRequested = Notification.Name("cmux.feedbackComposerRequested")
|
||||
static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle")
|
||||
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
|
||||
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")
|
||||
|
|
|
|||
|
|
@ -775,6 +775,100 @@ class TerminalController {
|
|||
)
|
||||
}
|
||||
|
||||
nonisolated static func probeSocketCommand(
|
||||
_ command: String,
|
||||
at socketPath: String,
|
||||
timeout: TimeInterval
|
||||
) -> String? {
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { return nil }
|
||||
defer { close(fd) }
|
||||
|
||||
#if os(macOS)
|
||||
var noSigPipe: Int32 = 1
|
||||
_ = withUnsafePointer(to: &noSigPipe) { ptr in
|
||||
setsockopt(
|
||||
fd,
|
||||
SOL_SOCKET,
|
||||
SO_NOSIGPIPE,
|
||||
ptr,
|
||||
socklen_t(MemoryLayout<Int32>.size)
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
var addr = sockaddr_un()
|
||||
memset(&addr, 0, MemoryLayout<sockaddr_un>.size)
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
|
||||
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
|
||||
let pathBytes = Array(socketPath.utf8CString)
|
||||
guard pathBytes.count <= maxLen else { return nil }
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
|
||||
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self)
|
||||
memset(raw, 0, maxLen)
|
||||
for index in 0..<pathBytes.count {
|
||||
raw[index] = pathBytes[index]
|
||||
}
|
||||
}
|
||||
|
||||
let pathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0
|
||||
let addrLen = socklen_t(pathOffset + pathBytes.count)
|
||||
#if os(macOS)
|
||||
addr.sun_len = UInt8(min(Int(addrLen), 255))
|
||||
#endif
|
||||
|
||||
let connectResult = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
|
||||
connect(fd, sockaddrPtr, addrLen)
|
||||
}
|
||||
}
|
||||
guard connectResult == 0 else { return nil }
|
||||
|
||||
let payload = command + "\n"
|
||||
let wroteAll = payload.withCString { cString in
|
||||
var remaining = strlen(cString)
|
||||
var pointer = UnsafeRawPointer(cString)
|
||||
while remaining > 0 {
|
||||
let written = write(fd, pointer, remaining)
|
||||
if written <= 0 { return false }
|
||||
remaining -= written
|
||||
pointer = pointer.advanced(by: written)
|
||||
}
|
||||
return true
|
||||
}
|
||||
guard wroteAll else { return nil }
|
||||
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
var response = ""
|
||||
|
||||
while Date() < deadline {
|
||||
var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
|
||||
let ready = poll(&pollDescriptor, 1, 100)
|
||||
if ready < 0 {
|
||||
return nil
|
||||
}
|
||||
if ready == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
let count = read(fd, &buffer, buffer.count)
|
||||
if count <= 0 {
|
||||
break
|
||||
}
|
||||
if let chunk = String(bytes: buffer[0..<count], encoding: .utf8) {
|
||||
response.append(chunk)
|
||||
if let newlineIndex = response.firstIndex(of: "\n") {
|
||||
return String(response[..<newlineIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
nonisolated func stop() {
|
||||
let (socketToClose, socketPathToUnlink) = withListenerState {
|
||||
isRunning = false
|
||||
|
|
@ -1376,6 +1470,9 @@ class TerminalController {
|
|||
|
||||
|
||||
#if DEBUG
|
||||
case "send_workspace":
|
||||
return sendInputToWorkspace(args)
|
||||
|
||||
case "set_shortcut":
|
||||
return setShortcut(args)
|
||||
|
||||
|
|
@ -2800,10 +2897,11 @@ class TerminalController {
|
|||
let startedAt = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
v2MainSync {
|
||||
let ws = tabManager.addWorkspace(workingDirectory: cwd, select: shouldFocus)
|
||||
if !shouldFocus, let terminalPanel = ws.focusedTerminalPanel {
|
||||
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
|
||||
}
|
||||
let ws = tabManager.addWorkspace(
|
||||
workingDirectory: cwd,
|
||||
select: shouldFocus,
|
||||
eagerLoadTerminal: !shouldFocus
|
||||
)
|
||||
newId = ws.id
|
||||
}
|
||||
#if DEBUG
|
||||
|
|
@ -9523,6 +9621,7 @@ class TerminalController {
|
|||
sidebar_overlay_gate [active|inactive] - Return true/false if sidebar outside-drop overlay would capture (test-only)
|
||||
terminal_drop_overlay_probe [deferred|direct] - Trigger focused terminal drop-overlay show path and report animation counts (test-only)
|
||||
activate_app - Bring app + main window to front (test-only)
|
||||
send_workspace <workspace_id> <text> - Send text to a workspace's selected terminal (test-only)
|
||||
is_terminal_focused <id|idx> - Return true/false if terminal surface is first responder (test-only)
|
||||
read_terminal_text [id|idx] - Read visible terminal text (base64, test-only)
|
||||
render_stats [id|idx] - Read terminal render stats (draw counters, test-only)
|
||||
|
|
@ -10574,10 +10673,7 @@ class TerminalController {
|
|||
let startedAt = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
DispatchQueue.main.sync {
|
||||
let workspace = tabManager.addTab(select: focus)
|
||||
if !focus, let terminalPanel = workspace.focusedTerminalPanel {
|
||||
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
|
||||
}
|
||||
let workspace = tabManager.addTab(select: focus, eagerLoadTerminal: !focus)
|
||||
newTabId = workspace.id
|
||||
}
|
||||
#if DEBUG
|
||||
|
|
@ -10760,7 +10856,13 @@ class TerminalController {
|
|||
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else {
|
||||
let tab: Tab?
|
||||
if let tabId = UUID(uuidString: tabArg) {
|
||||
tab = tabForSidebarMutation(id: tabId)
|
||||
} else {
|
||||
tab = resolveTab(from: tabArg, tabManager: tabManager)
|
||||
}
|
||||
guard let tab else {
|
||||
result = "ERROR: Tab not found"
|
||||
return
|
||||
}
|
||||
|
|
@ -11775,6 +11877,97 @@ class TerminalController {
|
|||
return success ? "OK" : "ERROR: Failed to send input"
|
||||
}
|
||||
|
||||
private func sendInputToWorkspace(_ args: String) -> String {
|
||||
guard let tabManager else { return "ERROR: TabManager not available" }
|
||||
let parts = args.split(separator: " ", maxSplits: 1).map(String.init)
|
||||
guard parts.count == 2 else { return "ERROR: Usage: send_workspace <workspace_id> <text>" }
|
||||
|
||||
let workspaceArg = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let text = parts[1]
|
||||
guard let workspaceId = UUID(uuidString: workspaceArg) else {
|
||||
return "ERROR: Invalid workspace ID"
|
||||
}
|
||||
|
||||
var success = false
|
||||
var error: String?
|
||||
DispatchQueue.main.sync {
|
||||
guard let targetManager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId)
|
||||
?? (tabManager.tabs.contains(where: { $0.id == workspaceId }) ? tabManager : nil) else {
|
||||
error = "ERROR: Workspace not found"
|
||||
return
|
||||
}
|
||||
guard let tab = targetManager.tabs.first(where: { $0.id == workspaceId }) else {
|
||||
error = "ERROR: Workspace not found"
|
||||
return
|
||||
}
|
||||
|
||||
guard let terminalPanel = sendableWorkspaceTerminalPanel(in: tab) else {
|
||||
error = "ERROR: No selected terminal in workspace"
|
||||
return
|
||||
}
|
||||
|
||||
let unescaped = text
|
||||
.replacingOccurrences(of: "\\n", with: "\r")
|
||||
.replacingOccurrences(of: "\\r", with: "\r")
|
||||
.replacingOccurrences(of: "\\t", with: "\t")
|
||||
|
||||
// This DEBUG-only command is used by UI tests to enqueue shell work in an
|
||||
// existing workspace. Return once the input is queued on main so a long
|
||||
// payload does not hold the control-socket response open in CI.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
if let surface = terminalPanel.surface.surface {
|
||||
self.sendSocketText(unescaped, surface: surface)
|
||||
} else {
|
||||
terminalPanel.sendText(unescaped)
|
||||
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
|
||||
}
|
||||
}
|
||||
success = true
|
||||
}
|
||||
|
||||
if let error { return error }
|
||||
return success ? "OK" : "ERROR: Failed to send input"
|
||||
}
|
||||
|
||||
private func sendableWorkspaceTerminalPanel(in workspace: Workspace) -> TerminalPanel? {
|
||||
func selectedTerminalPanel(in paneId: PaneID) -> TerminalPanel? {
|
||||
guard let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId),
|
||||
let panelId = workspace.panelIdFromSurfaceId(selectedTab.id),
|
||||
let terminalPanel = workspace.panels[panelId] as? TerminalPanel else {
|
||||
return nil
|
||||
}
|
||||
return terminalPanel
|
||||
}
|
||||
|
||||
func isSelectedTerminalPanel(_ terminalPanel: TerminalPanel) -> Bool {
|
||||
guard let surfaceId = workspace.surfaceIdFromPanelId(terminalPanel.id) else {
|
||||
return false
|
||||
}
|
||||
return workspace.bonsplitController.allPaneIds.contains { paneId in
|
||||
workspace.bonsplitController.selectedTab(inPane: paneId)?.id == surfaceId
|
||||
}
|
||||
}
|
||||
|
||||
if let focusedPane = workspace.bonsplitController.focusedPaneId,
|
||||
let terminalPanel = selectedTerminalPanel(in: focusedPane) {
|
||||
return terminalPanel
|
||||
}
|
||||
|
||||
if let rememberedTerminal = workspace.lastRememberedTerminalPanelForConfigInheritance(),
|
||||
isSelectedTerminalPanel(rememberedTerminal) {
|
||||
return rememberedTerminal
|
||||
}
|
||||
|
||||
for paneId in workspace.bonsplitController.allPaneIds {
|
||||
if let terminalPanel = selectedTerminalPanel(in: paneId) {
|
||||
return terminalPanel
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func sendInputToSurface(_ args: String) -> String {
|
||||
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
||||
let parts = args.split(separator: " ", maxSplits: 1).map(String.init)
|
||||
|
|
|
|||
|
|
@ -833,9 +833,16 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
let isFocusedSurface = surfaceId == nil || focusedSurfaceId == surfaceId
|
||||
let isFocusedPanel = isActiveTab && isFocusedSurface
|
||||
let isAppFocused = AppFocusState.isAppFocused()
|
||||
let suppressNativeDelivery = isAppFocused && isFocusedPanel
|
||||
if isAppFocused && isFocusedPanel {
|
||||
if !idsToClear.isEmpty {
|
||||
notifications = updated
|
||||
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
||||
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if WorkspaceAutoReorderSettings.isEnabled() && !suppressNativeDelivery {
|
||||
if WorkspaceAutoReorderSettings.isEnabled() {
|
||||
AppDelegate.shared?.tabManager?.moveTabToTop(tabId)
|
||||
}
|
||||
|
||||
|
|
@ -855,11 +862,7 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
|
||||
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
|
||||
}
|
||||
if suppressNativeDelivery {
|
||||
Self.runNotificationCustomCommand(notification)
|
||||
} else {
|
||||
scheduleUserNotification(notification)
|
||||
}
|
||||
scheduleUserNotification(notification)
|
||||
}
|
||||
|
||||
func markRead(id: UUID) {
|
||||
|
|
@ -990,7 +993,10 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
guard let self, authorized else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = Self.notificationDisplayTitle(notification)
|
||||
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.subtitle = notification.subtitle
|
||||
content.body = notification.body
|
||||
content.sound = NotificationSoundSettings.sound()
|
||||
|
|
@ -1013,27 +1019,16 @@ final class TerminalNotificationStore: ObservableObject {
|
|||
if let error {
|
||||
NSLog("Failed to schedule notification: \(error)")
|
||||
} else {
|
||||
Self.runNotificationCustomCommand(notification)
|
||||
NotificationSoundSettings.runCustomCommand(
|
||||
title: content.title,
|
||||
subtitle: content.subtitle,
|
||||
body: content.body
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1480,6 +1480,18 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
return surfaceKind(for: panel)
|
||||
}
|
||||
|
||||
func requestBackgroundTerminalSurfaceStartIfNeeded() {
|
||||
for terminalPanel in panels.values.compactMap({ $0 as? TerminalPanel }) {
|
||||
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func hasLoadedTerminalSurface() -> Bool {
|
||||
let terminalPanels = panels.values.compactMap { $0 as? TerminalPanel }
|
||||
guard !terminalPanels.isEmpty else { return true }
|
||||
return terminalPanels.contains { $0.surface.surface != nil }
|
||||
}
|
||||
|
||||
func panelTitle(panelId: UUID) -> String? {
|
||||
guard let panel = panels[panelId] else { return nil }
|
||||
let fallback = panelTitles[panelId] ?? panel.displayTitle
|
||||
|
|
@ -3268,6 +3280,19 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
/// Hide all browser portal views for this workspace.
|
||||
/// Called before the workspace is unmounted so a portal-hosted WKWebView
|
||||
/// cannot remain visible after this workspace stops being selected.
|
||||
func hideAllBrowserPortalViews() {
|
||||
for panel in panels.values {
|
||||
guard let browser = panel as? BrowserPanel else { continue }
|
||||
BrowserWindowPortalRegistry.hide(
|
||||
webView: browser.webView,
|
||||
source: "workspaceRetire"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Utility
|
||||
|
||||
/// Create a new terminal panel (used when replacing the last panel)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ struct cmuxApp: App {
|
|||
@AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue
|
||||
@AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue
|
||||
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
||||
@AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey)
|
||||
private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner
|
||||
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
|
||||
@AppStorage(KeyboardShortcutSettings.Action.toggleSidebar.defaultsKey) private var toggleSidebarShortcutData = Data()
|
||||
@AppStorage(KeyboardShortcutSettings.Action.newTab.defaultsKey) private var newWorkspaceShortcutData = Data()
|
||||
|
|
@ -268,15 +270,6 @@ struct cmuxApp: App {
|
|||
}
|
||||
#endif
|
||||
|
||||
CommandMenu(String(localized: "menu.updateLogs.title", defaultValue: "Update Logs")) {
|
||||
Button(String(localized: "menu.updateLogs.copyUpdateLogs", defaultValue: "Copy Update Logs")) {
|
||||
appDelegate.copyUpdateLogs(nil)
|
||||
}
|
||||
Button(String(localized: "menu.updateLogs.copyFocusLogs", defaultValue: "Copy Focus Logs")) {
|
||||
appDelegate.copyFocusLogs(nil)
|
||||
}
|
||||
}
|
||||
|
||||
CommandMenu(String(localized: "menu.notifications.title", defaultValue: "Notifications")) {
|
||||
let snapshot = notificationMenuSnapshot
|
||||
|
||||
|
|
@ -360,6 +353,10 @@ struct cmuxApp: App {
|
|||
}
|
||||
|
||||
Toggle("Always Show Shortcut Hints", isOn: $alwaysShowShortcutHints)
|
||||
Toggle(
|
||||
String(localized: "debug.devBuildBanner.show", defaultValue: "Show Dev Build Banner"),
|
||||
isOn: $showSidebarDevBuildBanner
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
|
|
@ -371,6 +368,15 @@ struct cmuxApp: App {
|
|||
|
||||
Divider()
|
||||
|
||||
Button(String(localized: "menu.updateLogs.copyUpdateLogs", defaultValue: "Copy Update Logs")) {
|
||||
appDelegate.copyUpdateLogs(nil)
|
||||
}
|
||||
Button(String(localized: "menu.updateLogs.copyFocusLogs", defaultValue: "Copy Focus Logs")) {
|
||||
appDelegate.copyFocusLogs(nil)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Trigger Sentry Test Crash") {
|
||||
appDelegate.triggerSentryTestCrash(nil)
|
||||
}
|
||||
|
|
@ -1321,6 +1327,7 @@ private enum DebugWindowConfigSnapshot {
|
|||
sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0)))
|
||||
sidebarBranchVerticalLayout=\(boolValue(defaults, key: SidebarBranchLayoutSettings.key, fallback: SidebarBranchLayoutSettings.defaultVerticalLayout))
|
||||
sidebarActiveTabIndicatorStyle=\(stringValue(defaults, key: SidebarActiveTabIndicatorSettings.styleKey, fallback: SidebarActiveTabIndicatorSettings.defaultStyle.rawValue))
|
||||
sidebarDevBuildBannerVisible=\(boolValue(defaults, key: DevBuildBannerDebugSettings.sidebarBannerVisibleKey, fallback: DevBuildBannerDebugSettings.defaultShowSidebarBanner))
|
||||
shortcutHintSidebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintXKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintX)))
|
||||
shortcutHintSidebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintYKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintY)))
|
||||
shortcutHintTitlebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintXKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintX)))
|
||||
|
|
@ -1775,7 +1782,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
|||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func show() {
|
||||
func show(navigationTarget: SettingsNavigationTarget? = nil) {
|
||||
guard let window else { return }
|
||||
#if DEBUG
|
||||
dlog("settings.window.show requested isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)")
|
||||
|
|
@ -1785,12 +1792,39 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
|||
window.center()
|
||||
}
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
if let navigationTarget {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
SettingsNavigationRequest.post(navigationTarget)
|
||||
}
|
||||
}
|
||||
#if DEBUG
|
||||
dlog("settings.window.show completed isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
enum SettingsNavigationTarget: String {
|
||||
case keyboardShortcuts
|
||||
}
|
||||
|
||||
enum SettingsNavigationRequest {
|
||||
static let notificationName = Notification.Name("cmux.settings.navigate")
|
||||
private static let targetKey = "target"
|
||||
|
||||
static func post(_ target: SettingsNavigationTarget) {
|
||||
NotificationCenter.default.post(
|
||||
name: notificationName,
|
||||
object: nil,
|
||||
userInfo: [targetKey: target.rawValue]
|
||||
)
|
||||
}
|
||||
|
||||
static func target(from notification: Notification) -> SettingsNavigationTarget? {
|
||||
guard let rawValue = notification.userInfo?[targetKey] as? String else { return nil }
|
||||
return SettingsNavigationTarget(rawValue: rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
private final class SidebarDebugWindowController: NSWindowController, NSWindowDelegate {
|
||||
static let shared = SidebarDebugWindowController()
|
||||
|
||||
|
|
@ -1931,6 +1965,8 @@ private struct SidebarDebugView: View {
|
|||
@AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX
|
||||
@AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
|
||||
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
||||
@AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey)
|
||||
private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner
|
||||
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
|
||||
private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
|
||||
|
||||
|
|
@ -2154,6 +2190,7 @@ private struct SidebarDebugView: View {
|
|||
sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius))
|
||||
sidebarBranchVerticalLayout=\(sidebarBranchVerticalLayout)
|
||||
sidebarActiveTabIndicatorStyle=\(sidebarActiveTabIndicatorStyle)
|
||||
sidebarDevBuildBannerVisible=\(showSidebarDevBuildBanner)
|
||||
shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset)))
|
||||
shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset)))
|
||||
shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset)))
|
||||
|
|
@ -3168,7 +3205,8 @@ struct SettingsView: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
ScrollViewReader { proxy in
|
||||
ZStack(alignment: .top) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App"))
|
||||
|
|
@ -3864,6 +3902,8 @@ struct SettingsView: View {
|
|||
}
|
||||
|
||||
SettingsSectionHeader(title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"))
|
||||
.id(SettingsNavigationTarget.keyboardShortcuts)
|
||||
.accessibilityIdentifier("SettingsKeyboardShortcutsSection")
|
||||
SettingsCard {
|
||||
SettingsCardRow(
|
||||
String(localized: "settings.shortcuts.showHints", defaultValue: "Show Cmd/Ctrl-Hold Shortcut Hints"),
|
||||
|
|
@ -3894,6 +3934,7 @@ struct SettingsView: View {
|
|||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.leading, 2)
|
||||
.accessibilityIdentifier("ShortcutRecordingHint")
|
||||
|
||||
SettingsSectionHeader(title: String(localized: "settings.section.reset", defaultValue: "Reset"))
|
||||
SettingsCard {
|
||||
|
|
@ -4013,6 +4054,14 @@ struct SettingsView: View {
|
|||
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
|
||||
reloadWorkspaceTabColorSettings()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: SettingsNavigationRequest.notificationName)) { notification in
|
||||
guard let target = SettingsNavigationRequest.target(from: notification) else { return }
|
||||
DispatchQueue.main.async {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
proxy.scrollTo(target, anchor: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
String(localized: "settings.browser.history.clearDialog.title", defaultValue: "Clear browser history?"),
|
||||
isPresented: $showClearBrowserHistoryConfirmation,
|
||||
|
|
@ -4061,6 +4110,7 @@ struct SettingsView: View {
|
|||
} message: {
|
||||
Text(notificationCustomSoundErrorAlertMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func relaunchApp() {
|
||||
|
|
|
|||
|
|
@ -2071,9 +2071,11 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
|||
func testPresentPreferencesWindowShowsCustomSettingsWindowAndActivates() {
|
||||
var showFallbackSettingsWindowCallCount = 0
|
||||
var activateApplicationCallCount = 0
|
||||
var receivedNavigationTargets: [SettingsNavigationTarget?] = []
|
||||
|
||||
AppDelegate.presentPreferencesWindow(
|
||||
showFallbackSettingsWindow: {
|
||||
showFallbackSettingsWindow: { navigationTarget in
|
||||
receivedNavigationTargets.append(navigationTarget)
|
||||
showFallbackSettingsWindowCallCount += 1
|
||||
},
|
||||
activateApplication: {
|
||||
|
|
@ -2083,14 +2085,17 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
|||
|
||||
XCTAssertEqual(showFallbackSettingsWindowCallCount, 1)
|
||||
XCTAssertEqual(activateApplicationCallCount, 1)
|
||||
XCTAssertEqual(receivedNavigationTargets, [nil])
|
||||
}
|
||||
|
||||
func testPresentPreferencesWindowSupportsRepeatedCalls() {
|
||||
var showFallbackSettingsWindowCallCount = 0
|
||||
var activateApplicationCallCount = 0
|
||||
var receivedNavigationTargets: [SettingsNavigationTarget?] = []
|
||||
|
||||
AppDelegate.presentPreferencesWindow(
|
||||
showFallbackSettingsWindow: {
|
||||
showFallbackSettingsWindow: { navigationTarget in
|
||||
receivedNavigationTargets.append(navigationTarget)
|
||||
showFallbackSettingsWindowCallCount += 1
|
||||
},
|
||||
activateApplication: {
|
||||
|
|
@ -2099,7 +2104,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
|||
)
|
||||
|
||||
AppDelegate.presentPreferencesWindow(
|
||||
showFallbackSettingsWindow: {
|
||||
showFallbackSettingsWindow: { navigationTarget in
|
||||
receivedNavigationTargets.append(navigationTarget)
|
||||
showFallbackSettingsWindowCallCount += 1
|
||||
},
|
||||
activateApplication: {
|
||||
|
|
@ -2109,6 +2115,25 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
|||
|
||||
XCTAssertEqual(showFallbackSettingsWindowCallCount, 2)
|
||||
XCTAssertEqual(activateApplicationCallCount, 2)
|
||||
XCTAssertEqual(receivedNavigationTargets, [nil, nil])
|
||||
}
|
||||
|
||||
func testPresentPreferencesWindowForwardsNavigationTarget() {
|
||||
var receivedNavigationTarget: SettingsNavigationTarget?
|
||||
var activateApplicationCallCount = 0
|
||||
|
||||
AppDelegate.presentPreferencesWindow(
|
||||
navigationTarget: .keyboardShortcuts,
|
||||
showFallbackSettingsWindow: { navigationTarget in
|
||||
receivedNavigationTarget = navigationTarget
|
||||
},
|
||||
activateApplication: {
|
||||
activateApplicationCallCount += 1
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertEqual(receivedNavigationTarget, .keyboardShortcuts)
|
||||
XCTAssertEqual(activateApplicationCallCount, 1)
|
||||
}
|
||||
|
||||
private func makeKeyDownEvent(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -275,6 +275,71 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testClickingBrowserDismissesCommandPaletteAndKeepsBrowserFocus() {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1"
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForData(keys: ["browserPanelId", "terminalPaneId", "webViewFocused"], timeout: 10.0),
|
||||
"Expected goto_split setup data to be written"
|
||||
)
|
||||
|
||||
guard let setup = loadData() else {
|
||||
XCTFail("Missing goto_split setup data")
|
||||
return
|
||||
}
|
||||
|
||||
guard let expectedBrowserPanelId = setup["browserPanelId"] else {
|
||||
XCTFail("Missing browserPanelId in goto_split setup data")
|
||||
return
|
||||
}
|
||||
|
||||
guard let expectedTerminalPaneId = setup["terminalPaneId"] else {
|
||||
XCTFail("Missing terminalPaneId in goto_split setup data")
|
||||
return
|
||||
}
|
||||
|
||||
// Move focus away from browser to terminal first so Cmd+R opens the rename overlay.
|
||||
app.typeKey("h", modifierFlags: [.command, .control])
|
||||
XCTAssertTrue(
|
||||
waitForDataMatch(timeout: 5.0) { data in
|
||||
data["lastMoveDirection"] == "left" && data["focusedPaneId"] == expectedTerminalPaneId
|
||||
},
|
||||
"Expected Cmd+Ctrl+H to move focus to left pane (terminal)"
|
||||
)
|
||||
|
||||
let renameField = app.textFields["CommandPaletteRenameField"].firstMatch
|
||||
app.typeKey("r", modifierFlags: [.command])
|
||||
XCTAssertTrue(
|
||||
renameField.waitForExistence(timeout: 5.0),
|
||||
"Expected Cmd+R to open the rename command palette while terminal is focused"
|
||||
)
|
||||
|
||||
let browserPane = app.otherElements["BrowserPanelContent.\(expectedBrowserPanelId)"].firstMatch
|
||||
XCTAssertTrue(browserPane.waitForExistence(timeout: 5.0), "Expected browser pane content for click target")
|
||||
browserPane.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click()
|
||||
XCTAssertTrue(
|
||||
waitForNonExistence(renameField, timeout: 5.0),
|
||||
"Expected clicking the browser pane to dismiss the command palette"
|
||||
)
|
||||
|
||||
// Cmd+L behavior is context-aware:
|
||||
// - If terminal is still focused: opens a new browser in that pane.
|
||||
// - If the original browser took focus: focuses that existing browser's omnibar.
|
||||
app.typeKey("l", modifierFlags: [.command])
|
||||
XCTAssertTrue(
|
||||
waitForDataMatch(timeout: 5.0) { data in
|
||||
guard data["webViewFocusedAfterAddressBarFocus"] == "false" else { return false }
|
||||
return data["webViewFocusedAfterAddressBarFocusPanelId"] == expectedBrowserPanelId
|
||||
},
|
||||
"Expected clicking browser content to dismiss the palette and keep focus on the existing browser pane"
|
||||
)
|
||||
}
|
||||
|
||||
func testCmdDSplitsRightWhenWebViewFocused() {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
|
|
@ -644,6 +709,12 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
return false
|
||||
}
|
||||
|
||||
private func waitForNonExistence(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||
let predicate = NSPredicate(format: "exists == false")
|
||||
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
|
||||
return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
|
||||
}
|
||||
|
||||
private func loadData() -> [String: String]? {
|
||||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: dataPath)) else {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -190,6 +190,159 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
XCTAssertFalse(after.contains(marker), "Expected typing to be blocked while empty notifications popover is open")
|
||||
}
|
||||
|
||||
func testNotifyCLIDoesNotStealFocusAcrossWindows() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments += ["-socketControlMode", "allowAll"]
|
||||
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_SOCKET_MODE"] = "allowAll"
|
||||
app.launchEnvironment["CMUX_SOCKET_ENABLE"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_NOTIFY_SOURCE_TERMINAL_READY"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_ENABLE_DUPLICATE_LAUNCH_OBSERVER"] = "1"
|
||||
app.launchEnvironment["CMUX_TAG"] = launchTag
|
||||
app.launch()
|
||||
XCTAssertTrue(
|
||||
ensureForegroundAfterLaunch(app, timeout: 12.0),
|
||||
"Expected app to launch for notify focus regression test. state=\(app.state.rawValue)"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
waitForDataMatch(timeout: 20.0) { data in
|
||||
let tabId2 = data["tabId2"] ?? ""
|
||||
let surfaceId2 = data["surfaceId2"] ?? ""
|
||||
let socketReady = data["socketReady"] ?? ""
|
||||
let sourceTerminalReady = data["sourceTerminalReady"] ?? ""
|
||||
return !tabId2.isEmpty &&
|
||||
!surfaceId2.isEmpty &&
|
||||
!socketReady.isEmpty &&
|
||||
socketReady != "pending" &&
|
||||
!sourceTerminalReady.isEmpty &&
|
||||
sourceTerminalReady != "pending"
|
||||
},
|
||||
"Expected multi-window notification setup data, socket readiness, and source terminal focus"
|
||||
)
|
||||
|
||||
guard let setup = loadData() else {
|
||||
XCTFail("Missing setup data")
|
||||
return
|
||||
}
|
||||
guard let tabId2 = setup["tabId2"], !tabId2.isEmpty else {
|
||||
XCTFail("Missing setup workspace id")
|
||||
return
|
||||
}
|
||||
if let expectedSocketPath = setup["socketExpectedPath"], !expectedSocketPath.isEmpty {
|
||||
socketPath = expectedSocketPath
|
||||
}
|
||||
if setup["socketReady"] != "1" {
|
||||
XCTFail(
|
||||
"Control socket unavailable in this test environment. expected=\(socketPath) " +
|
||||
socketDiagnostics(from: setup)
|
||||
)
|
||||
return
|
||||
}
|
||||
guard setup["socketPingResponse"] == "PONG" else {
|
||||
XCTFail(
|
||||
"Control socket ping sanity check failed. path=\(socketPath) " +
|
||||
socketDiagnostics(from: setup)
|
||||
)
|
||||
return
|
||||
}
|
||||
guard let surfaceId = setup["surfaceId2"], !surfaceId.isEmpty else {
|
||||
XCTFail("Missing target surface id for workspace \(tabId2)")
|
||||
return
|
||||
}
|
||||
guard setup["sourceTerminalReady"] == "1" else {
|
||||
XCTFail(
|
||||
"Expected source terminal to be focused before typing. " +
|
||||
"failure=\(setup["sourceTerminalFocusFailure"] ?? "<unknown>")"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertTrue(waitForWindowCount(atLeast: 2, app: app, timeout: 6.0))
|
||||
|
||||
let title = "focus-regression-\(UUID().uuidString.prefix(8))"
|
||||
let commandResultStem = UUID().uuidString
|
||||
let commandStatusPath = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-ui-test-notify-\(commandResultStem).status")
|
||||
.path
|
||||
let commandStdoutPath = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-ui-test-notify-\(commandResultStem).stdout")
|
||||
.path
|
||||
let commandStderrPath = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-ui-test-notify-\(commandResultStem).stderr")
|
||||
.path
|
||||
let commandScriptPath = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-ui-test-notify-\(commandResultStem).sh")
|
||||
.path
|
||||
defer {
|
||||
try? FileManager.default.removeItem(atPath: commandStatusPath)
|
||||
try? FileManager.default.removeItem(atPath: commandStdoutPath)
|
||||
try? FileManager.default.removeItem(atPath: commandStderrPath)
|
||||
try? FileManager.default.removeItem(atPath: commandScriptPath)
|
||||
}
|
||||
|
||||
guard let bundledCLIPath = resolveCmuxCLIPaths(strategy: .bundledOnly).first else {
|
||||
XCTFail("Failed to locate bundled cmux CLI for notify regression test")
|
||||
return
|
||||
}
|
||||
|
||||
let notifyScript = [
|
||||
"#!/bin/sh",
|
||||
"sleep 1",
|
||||
"rm -f \(shellSingleQuote(commandStatusPath)) \(shellSingleQuote(commandStdoutPath)) \(shellSingleQuote(commandStderrPath))",
|
||||
"\(shellSingleQuote(bundledCLIPath)) --socket \(shellSingleQuote(socketPath)) notify --workspace \(shellSingleQuote(tabId2)) --surface \(shellSingleQuote(surfaceId)) --title \(shellSingleQuote(title)) --subtitle \(shellSingleQuote("ui-test")) --body \(shellSingleQuote("focus-regression")) >\(shellSingleQuote(commandStdoutPath)) 2>\(shellSingleQuote(commandStderrPath))",
|
||||
"printf '%s' $? >\(shellSingleQuote(commandStatusPath))"
|
||||
].joined(separator: "\n")
|
||||
do {
|
||||
try notifyScript.write(toFile: commandScriptPath, atomically: true, encoding: .utf8)
|
||||
} catch {
|
||||
XCTFail(
|
||||
"Failed to write delayed bundled `cmux notify` script. " +
|
||||
"path=\(commandScriptPath) error=\(error)"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
app.typeText("sh \(commandScriptPath)")
|
||||
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
|
||||
|
||||
let finder = XCUIApplication(bundleIdentifier: "com.apple.finder")
|
||||
finder.activate()
|
||||
XCTAssertTrue(
|
||||
waitForAppToLeaveForeground(app, timeout: 8.0),
|
||||
"Expected cmux to move to background before delayed notify command runs. state=\(app.state.rawValue)"
|
||||
)
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForCommandCompletionWhileBackgrounded(
|
||||
statusPath: commandStatusPath,
|
||||
app: app,
|
||||
timeout: 15.0
|
||||
),
|
||||
"Expected delayed bundled `cmux notify` command to finish without foregrounding cmux. state=\(app.state.rawValue)"
|
||||
)
|
||||
|
||||
let notifyExitStatus = readTrimmedFile(atPath: commandStatusPath) ?? "<missing>"
|
||||
let notifyStdout = readTrimmedFile(atPath: commandStdoutPath) ?? ""
|
||||
let notifyStderr = readTrimmedFile(atPath: commandStderrPath) ?? ""
|
||||
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
XCTAssertFalse(
|
||||
app.state == .runningForeground,
|
||||
"Expected cmux to remain in background after bundled `cmux notify`. state=\(app.state.rawValue) stderr=\(notifyStderr)"
|
||||
)
|
||||
guard notifyExitStatus == "0" else {
|
||||
XCTFail(
|
||||
"Expected bundled `cmux notify` launched from the in-app shell to succeed. " +
|
||||
"status=\(notifyExitStatus) stdout=\(notifyStdout) stderr=\(notifyStderr)"
|
||||
)
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(notifyStdout.contains("OK"), "Expected notify command to return OK. stdout=\(notifyStdout) stderr=\(notifyStderr)")
|
||||
}
|
||||
|
||||
private func clickNotificationPopoverRowAndWaitForFocusChange(
|
||||
button: XCUIElement,
|
||||
app: XCUIApplication,
|
||||
|
|
@ -274,6 +427,20 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
return false
|
||||
}
|
||||
|
||||
private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let data = loadData(), predicate(data) {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
if let data = loadData(), predicate(data) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func waitForSocketPong(timeout: TimeInterval) -> String? {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
var lastResponse: String?
|
||||
|
|
@ -287,33 +454,549 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
return socketCommand("ping") ?? lastResponse
|
||||
}
|
||||
|
||||
private func resolveSocketPath(timeout: TimeInterval) -> String? {
|
||||
private func waitForTerminalFocus(surfaceId: String, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
for candidate in expectedSocketCandidates() {
|
||||
guard FileManager.default.fileExists(atPath: candidate) else { continue }
|
||||
if socketRespondsToPing(at: candidate) {
|
||||
return candidate
|
||||
}
|
||||
if socketCommand("is_terminal_focused \(surfaceId)") == "true" {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
for candidate in expectedSocketCandidates() {
|
||||
guard FileManager.default.fileExists(atPath: candidate) else { continue }
|
||||
if socketRespondsToPing(at: candidate) {
|
||||
return socketCommand("is_terminal_focused \(surfaceId)") == "true"
|
||||
}
|
||||
|
||||
private func waitForCmuxPing(timeout: TimeInterval) -> (stdout: String?, stderr: String?) {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
var lastStdout: String?
|
||||
var lastStderr: String?
|
||||
while Date() < deadline {
|
||||
let result = runCmuxCommand(
|
||||
socketPath: socketPath,
|
||||
arguments: ["ping"],
|
||||
responseTimeoutSeconds: 2.0
|
||||
)
|
||||
let stdout = result.stdout.isEmpty ? nil : result.stdout
|
||||
let stderr = result.stderr.isEmpty ? nil : result.stderr
|
||||
if let stdout {
|
||||
lastStdout = stdout
|
||||
}
|
||||
if let stderr {
|
||||
lastStderr = stderr
|
||||
}
|
||||
if result.terminationStatus == 0, stdout == "PONG" {
|
||||
return ("PONG", stderr)
|
||||
}
|
||||
if isSocketPermissionFailure(stderr),
|
||||
waitForSocketPong(timeout: 0.5) == "PONG" {
|
||||
return ("PONG", stderr)
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
|
||||
let result = runCmuxCommand(
|
||||
socketPath: socketPath,
|
||||
arguments: ["ping"],
|
||||
responseTimeoutSeconds: 2.0
|
||||
)
|
||||
let stdout = result.stdout.isEmpty ? nil : result.stdout
|
||||
let stderr = result.stderr.isEmpty ? nil : result.stderr
|
||||
if isSocketPermissionFailure(stderr),
|
||||
waitForSocketPong(timeout: 0.5) == "PONG" {
|
||||
return ("PONG", stderr)
|
||||
}
|
||||
return (stdout ?? lastStdout, stderr ?? lastStderr)
|
||||
}
|
||||
|
||||
private func waitForCommandCompletionWhileBackgrounded(
|
||||
statusPath: String,
|
||||
app: XCUIApplication,
|
||||
timeout: TimeInterval
|
||||
) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
var sawCompletion = false
|
||||
while Date() < deadline {
|
||||
if app.state == .runningForeground {
|
||||
return false
|
||||
}
|
||||
if FileManager.default.fileExists(atPath: statusPath) {
|
||||
sawCompletion = true
|
||||
break
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
guard sawCompletion || FileManager.default.fileExists(atPath: statusPath) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let postCompletionDeadline = Date().addingTimeInterval(0.75)
|
||||
while Date() < postCompletionDeadline {
|
||||
if app.state == .runningForeground {
|
||||
return false
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return app.state != .runningForeground
|
||||
}
|
||||
|
||||
private func waitForAppToLeaveForeground(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if app.state != .runningForeground {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return app.state != .runningForeground
|
||||
}
|
||||
|
||||
private func firstSurfaceId(forWorkspaceId workspaceId: String) -> String? {
|
||||
guard let response = socketCommand("list_surfaces \(workspaceId)"),
|
||||
!response.isEmpty,
|
||||
!response.hasPrefix("ERROR"),
|
||||
response != "No surfaces" else {
|
||||
return nil
|
||||
}
|
||||
|
||||
for line in response.split(separator: "\n", omittingEmptySubsequences: true) {
|
||||
let parts = line.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
guard parts.count == 2 else { continue }
|
||||
let candidate = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if UUID(uuidString: candidate) != nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func expectedSocketCandidates() -> [String] {
|
||||
private func waitForSurfaceId(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let surfaceId = firstSurfaceId(forWorkspaceId: workspaceId) {
|
||||
return surfaceId
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return firstSurfaceId(forWorkspaceId: workspaceId)
|
||||
}
|
||||
|
||||
private func waitForSurfaceIdViaCLI(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if let surfaceId = firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) {
|
||||
return surfaceId
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return firstSurfaceIdViaCLI(forWorkspaceId: workspaceId)
|
||||
}
|
||||
|
||||
private func firstSurfaceIdViaCLI(forWorkspaceId workspaceId: String) -> String? {
|
||||
guard let paneId = firstPaneIdViaCLI(forWorkspaceId: workspaceId) else {
|
||||
return firstSurfaceId(forWorkspaceId: workspaceId)
|
||||
}
|
||||
let result = runCmuxCommand(
|
||||
socketPath: socketPath,
|
||||
arguments: [
|
||||
"list-pane-surfaces",
|
||||
"--workspace",
|
||||
workspaceId,
|
||||
"--pane",
|
||||
paneId,
|
||||
"--id-format",
|
||||
"uuids"
|
||||
],
|
||||
responseTimeoutSeconds: 3.0
|
||||
)
|
||||
guard result.terminationStatus == 0 else {
|
||||
if isSocketPermissionFailure(result.stderr) {
|
||||
return firstSurfaceId(forWorkspaceId: workspaceId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return firstHandle(in: result.stdout)
|
||||
}
|
||||
|
||||
private func firstPaneIdViaCLI(forWorkspaceId workspaceId: String) -> String? {
|
||||
let result = runCmuxCommand(
|
||||
socketPath: socketPath,
|
||||
arguments: [
|
||||
"list-panes",
|
||||
"--workspace",
|
||||
workspaceId,
|
||||
"--id-format",
|
||||
"uuids"
|
||||
],
|
||||
responseTimeoutSeconds: 3.0
|
||||
)
|
||||
guard result.terminationStatus == 0 else {
|
||||
if isSocketPermissionFailure(result.stderr) {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return firstHandle(in: result.stdout)
|
||||
}
|
||||
|
||||
private func firstHandle(in output: String) -> String? {
|
||||
for rawLine in output.split(separator: "\n", omittingEmptySubsequences: true) {
|
||||
var line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !line.isEmpty, !line.hasPrefix("No ") else { continue }
|
||||
if line.hasPrefix("* ") || line.hasPrefix(" ") {
|
||||
line = String(line.dropFirst(2))
|
||||
}
|
||||
guard let token = line.split(whereSeparator: \.isWhitespace).first else { continue }
|
||||
return String(token)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func runCmuxNotify(
|
||||
socketPath: String,
|
||||
workspaceId: String,
|
||||
surfaceId: String,
|
||||
title: String
|
||||
) -> (terminationStatus: Int32, stdout: String, stderr: String) {
|
||||
runCmuxCommand(
|
||||
socketPath: socketPath,
|
||||
arguments: [
|
||||
"notify",
|
||||
"--workspace",
|
||||
workspaceId,
|
||||
"--surface",
|
||||
surfaceId,
|
||||
"--title",
|
||||
title,
|
||||
"--subtitle",
|
||||
"ui-test",
|
||||
"--body",
|
||||
"focus-regression"
|
||||
],
|
||||
responseTimeoutSeconds: 4.0,
|
||||
cliStrategy: .bundledOnly
|
||||
)
|
||||
}
|
||||
|
||||
private func runCmuxCommand(
|
||||
socketPath: String,
|
||||
arguments: [String],
|
||||
responseTimeoutSeconds: Double = 3.0,
|
||||
cliStrategy: CmuxCLIStrategy = .any
|
||||
) -> (terminationStatus: Int32, stdout: String, stderr: String) {
|
||||
var args = ["--socket", socketPath]
|
||||
args.append(contentsOf: arguments)
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
environment["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"] = String(responseTimeoutSeconds)
|
||||
|
||||
let cliPaths = resolveCmuxCLIPaths(strategy: cliStrategy)
|
||||
if cliPaths.isEmpty, cliStrategy == .bundledOnly {
|
||||
return (
|
||||
terminationStatus: -1,
|
||||
stdout: "",
|
||||
stderr: "Failed to locate bundled cmux CLI"
|
||||
)
|
||||
}
|
||||
|
||||
var lastPermissionFailure: (terminationStatus: Int32, stdout: String, stderr: String)?
|
||||
for cliPath in cliPaths {
|
||||
let result = executeCmuxCommand(
|
||||
executablePath: cliPath,
|
||||
arguments: args,
|
||||
environment: environment
|
||||
)
|
||||
if result.terminationStatus == 0 {
|
||||
return result
|
||||
}
|
||||
if result.stderr.localizedCaseInsensitiveContains("operation not permitted") {
|
||||
lastPermissionFailure = result
|
||||
continue
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
if cliStrategy == .bundledOnly {
|
||||
return lastPermissionFailure ?? (
|
||||
terminationStatus: -1,
|
||||
stdout: "",
|
||||
stderr: "Bundled cmux CLI command failed without an executable path"
|
||||
)
|
||||
}
|
||||
|
||||
let fallbackArgs = ["cmux"] + args
|
||||
let fallbackResult = executeCmuxCommand(
|
||||
executablePath: "/usr/bin/env",
|
||||
arguments: fallbackArgs,
|
||||
environment: environment
|
||||
)
|
||||
if fallbackResult.terminationStatus == 0 || lastPermissionFailure == nil {
|
||||
return fallbackResult
|
||||
}
|
||||
return lastPermissionFailure ?? fallbackResult
|
||||
}
|
||||
|
||||
private enum CmuxCLIStrategy: Equatable {
|
||||
case any
|
||||
case bundledOnly
|
||||
}
|
||||
|
||||
private func socketDiagnostics(from data: [String: String]) -> String {
|
||||
let pingResponse = data["socketPingResponse"].flatMap { $0.isEmpty ? nil : $0 } ?? "<nil>"
|
||||
return "mode=\(data["socketMode"] ?? "") running=\(data["socketIsRunning"] ?? "") " +
|
||||
"acceptLoopAlive=\(data["socketAcceptLoopAlive"] ?? "") pathMatches=\(data["socketPathMatches"] ?? "") " +
|
||||
"pathExists=\(data["socketPathExists"] ?? "") ping=\(pingResponse) " +
|
||||
"signals=\(data["socketFailureSignals"] ?? "")"
|
||||
}
|
||||
|
||||
private func resolveCmuxCLIPaths(strategy: CmuxCLIStrategy) -> [String] {
|
||||
let fileManager = FileManager.default
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
var candidates: [String] = []
|
||||
var productDirectories: [String] = []
|
||||
|
||||
if strategy == .any {
|
||||
for key in ["CMUX_UI_TEST_CLI_PATH", "CMUXTERM_CLI"] {
|
||||
if let value = env[key], !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
candidates.append(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let builtProductsDir = env["BUILT_PRODUCTS_DIR"], !builtProductsDir.isEmpty {
|
||||
productDirectories.append(builtProductsDir)
|
||||
}
|
||||
|
||||
if let hostPath = env["TEST_HOST"], !hostPath.isEmpty {
|
||||
let hostURL = URL(fileURLWithPath: hostPath)
|
||||
let productsDir = hostURL
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.path
|
||||
productDirectories.append(productsDir)
|
||||
}
|
||||
|
||||
productDirectories.append(contentsOf: inferredBuildProductsDirectories())
|
||||
for productsDir in uniquePaths(productDirectories) {
|
||||
appendCLIPathCandidates(fromProductsDirectory: productsDir, strategy: strategy, to: &candidates)
|
||||
}
|
||||
|
||||
candidates.append("/tmp/cmux-\(launchTag)/Build/Products/Debug/cmux DEV.app/Contents/Resources/bin/cmux")
|
||||
candidates.append("/tmp/cmux-\(launchTag)/Build/Products/Debug/cmux.app/Contents/Resources/bin/cmux")
|
||||
if strategy == .any {
|
||||
candidates.append("/tmp/cmux-\(launchTag)/Build/Products/Debug/cmux")
|
||||
}
|
||||
|
||||
var resolvedPaths: [String] = []
|
||||
for path in uniquePaths(candidates) {
|
||||
guard fileManager.isExecutableFile(atPath: path) else { continue }
|
||||
resolvedPaths.append(URL(fileURLWithPath: path).resolvingSymlinksInPath().path)
|
||||
}
|
||||
return uniquePaths(resolvedPaths)
|
||||
}
|
||||
|
||||
private func inferredBuildProductsDirectories() -> [String] {
|
||||
let bundleURLs = [
|
||||
Bundle.main.bundleURL,
|
||||
Bundle(for: Self.self).bundleURL,
|
||||
]
|
||||
|
||||
return bundleURLs.compactMap { bundleURL in
|
||||
let standardizedPath = bundleURL.standardizedFileURL.path
|
||||
let components = standardizedPath.split(separator: "/")
|
||||
guard let productsIndex = components.firstIndex(of: "Products"),
|
||||
productsIndex + 1 < components.count else {
|
||||
return nil
|
||||
}
|
||||
let prefixComponents = components.prefix(productsIndex + 2)
|
||||
return "/" + prefixComponents.joined(separator: "/")
|
||||
}
|
||||
}
|
||||
|
||||
private func appendCLIPathCandidates(
|
||||
fromProductsDirectory productsDir: String,
|
||||
strategy: CmuxCLIStrategy,
|
||||
to candidates: inout [String]
|
||||
) {
|
||||
candidates.append("\(productsDir)/cmux DEV.app/Contents/Resources/bin/cmux")
|
||||
candidates.append("\(productsDir)/cmux.app/Contents/Resources/bin/cmux")
|
||||
if strategy == .any {
|
||||
candidates.append("\(productsDir)/cmux")
|
||||
}
|
||||
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: productsDir) else {
|
||||
return
|
||||
}
|
||||
|
||||
for entry in entries.sorted() where entry.hasSuffix(".app") {
|
||||
let cliPath = URL(fileURLWithPath: productsDir)
|
||||
.appendingPathComponent(entry)
|
||||
.appendingPathComponent("Contents/Resources/bin/cmux")
|
||||
.path
|
||||
candidates.append(cliPath)
|
||||
}
|
||||
if strategy == .any {
|
||||
for entry in entries.sorted() where entry == "cmux" {
|
||||
let cliPath = URL(fileURLWithPath: productsDir)
|
||||
.appendingPathComponent(entry)
|
||||
.path
|
||||
candidates.append(cliPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func executeCmuxCommand(
|
||||
executablePath: String,
|
||||
arguments: [String],
|
||||
environment: [String: String]
|
||||
) -> (terminationStatus: Int32, stdout: String, stderr: String) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: executablePath)
|
||||
process.arguments = arguments
|
||||
process.environment = environment
|
||||
|
||||
let stdoutPipe = Pipe()
|
||||
let stderrPipe = Pipe()
|
||||
process.standardOutput = stdoutPipe
|
||||
process.standardError = stderrPipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
} catch {
|
||||
return (
|
||||
terminationStatus: -1,
|
||||
stdout: "",
|
||||
stderr: "Failed to run cmux command: \(error.localizedDescription) (cliPath=\(executablePath))"
|
||||
)
|
||||
}
|
||||
|
||||
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let stdout = String(data: stdoutData, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let rawStderr = String(data: stderrData, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let stderr = rawStderr.isEmpty ? "" : "\(rawStderr) (cliPath=\(executablePath))"
|
||||
return (process.terminationStatus, stdout, stderr)
|
||||
}
|
||||
|
||||
private func isSocketPermissionFailure(_ stderr: String?) -> Bool {
|
||||
guard let stderr, !stderr.isEmpty else { return false }
|
||||
return stderr.localizedCaseInsensitiveContains("failed to connect to socket") &&
|
||||
stderr.localizedCaseInsensitiveContains("operation not permitted")
|
||||
}
|
||||
|
||||
private func uniquePaths(_ paths: [String]) -> [String] {
|
||||
var unique: [String] = []
|
||||
var seen = Set<String>()
|
||||
for path in paths {
|
||||
if seen.insert(path).inserted {
|
||||
unique.append(path)
|
||||
}
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
private func resolveSocketPath(timeout: TimeInterval, requiredWorkspaceId: String? = nil) -> String? {
|
||||
let primaryCandidates = expectedSocketCandidates(includeGlobalFallback: false)
|
||||
let fallbackCandidates: [String]
|
||||
if let requiredWorkspaceId, !requiredWorkspaceId.isEmpty {
|
||||
fallbackCandidates = expectedSocketCandidates(includeGlobalFallback: true)
|
||||
.filter { !primaryCandidates.contains($0) }
|
||||
} else {
|
||||
fallbackCandidates = []
|
||||
}
|
||||
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
for candidate in primaryCandidates {
|
||||
guard FileManager.default.fileExists(atPath: candidate) else { continue }
|
||||
// Primary candidate is the explicitly requested CMUX_SOCKET_PATH. If it responds,
|
||||
// prefer it even before workspace contents are fully initialized.
|
||||
if socketRespondsToPing(at: candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
for candidate in fallbackCandidates {
|
||||
guard FileManager.default.fileExists(atPath: candidate) else { continue }
|
||||
if socketRespondsToPing(at: candidate),
|
||||
socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
for candidate in primaryCandidates {
|
||||
guard FileManager.default.fileExists(atPath: candidate) else { continue }
|
||||
if socketRespondsToPing(at: candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
for candidate in fallbackCandidates {
|
||||
guard FileManager.default.fileExists(atPath: candidate) else { continue }
|
||||
if socketRespondsToPing(at: candidate),
|
||||
socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func expectedSocketCandidates(includeGlobalFallback: Bool) -> [String] {
|
||||
var candidates = [socketPath]
|
||||
let taggedDebugSocket = "/tmp/cmux-debug-\(launchTag).sock"
|
||||
if taggedDebugSocket != socketPath {
|
||||
if !taggedDebugSocket.isEmpty {
|
||||
candidates.append(taggedDebugSocket)
|
||||
}
|
||||
return candidates
|
||||
if includeGlobalFallback {
|
||||
candidates.append(contentsOf: discoverTmpSocketCandidates(limit: 12))
|
||||
candidates.append("/tmp/cmux-debug.sock")
|
||||
candidates.append("/tmp/cmux.sock")
|
||||
}
|
||||
|
||||
var unique: [String] = []
|
||||
var seen = Set<String>()
|
||||
for candidate in candidates {
|
||||
if seen.insert(candidate).inserted {
|
||||
unique.append(candidate)
|
||||
}
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
private func socketMatchesRequiredWorkspace(_ candidatePath: String, workspaceId: String?) -> Bool {
|
||||
guard let workspaceId, !workspaceId.isEmpty else { return true }
|
||||
let originalPath = socketPath
|
||||
socketPath = candidatePath
|
||||
defer { socketPath = originalPath }
|
||||
|
||||
guard let response = socketCommand("list_surfaces \(workspaceId)"),
|
||||
!response.isEmpty,
|
||||
!response.hasPrefix("ERROR"),
|
||||
response != "No surfaces" else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func discoverTmpSocketCandidates(limit: Int) -> [String] {
|
||||
let tmpPath = "/tmp"
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: tmpPath) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let matches = entries.filter { $0.hasPrefix("cmux") && $0.hasSuffix(".sock") }
|
||||
let sorted = matches.compactMap { entry -> (path: String, mtime: Date)? in
|
||||
let fullPath = (tmpPath as NSString).appendingPathComponent(entry)
|
||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: fullPath) else {
|
||||
return nil
|
||||
}
|
||||
let mtime = (attrs[.modificationDate] as? Date) ?? .distantPast
|
||||
return (fullPath, mtime)
|
||||
}
|
||||
.sorted { $0.mtime > $1.mtime }
|
||||
|
||||
return Array(sorted.prefix(limit)).map(\.path)
|
||||
}
|
||||
|
||||
private func socketRespondsToPing(at path: String) -> Bool {
|
||||
|
|
@ -323,20 +1006,21 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
return socketCommand("ping") == "PONG"
|
||||
}
|
||||
|
||||
private func socketCommand(_ cmd: String) -> String? {
|
||||
if let response = ControlSocketClient(path: socketPath).sendLine(cmd) {
|
||||
private func socketCommand(_ cmd: String, responseTimeout: TimeInterval = 2.0) -> String? {
|
||||
if let response = ControlSocketClient(path: socketPath, responseTimeout: responseTimeout).sendLine(cmd) {
|
||||
return response
|
||||
}
|
||||
return socketCommandViaNetcat(cmd)
|
||||
return socketCommandViaNetcat(cmd, responseTimeout: responseTimeout)
|
||||
}
|
||||
|
||||
private func socketCommandViaNetcat(_ cmd: String) -> String? {
|
||||
private func socketCommandViaNetcat(_ cmd: String, responseTimeout: TimeInterval = 2.0) -> String? {
|
||||
let nc = "/usr/bin/nc"
|
||||
guard FileManager.default.isExecutableFile(atPath: nc) else { return nil }
|
||||
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||||
let script = "printf '%s\\n' \(shellSingleQuote(cmd)) | \(nc) -U \(shellSingleQuote(socketPath)) -w 2 2>/dev/null"
|
||||
let timeoutSeconds = max(1, Int(ceil(responseTimeout)))
|
||||
let script = "printf '%s\\n' \(shellSingleQuote(cmd)) | \(nc) -U \(shellSingleQuote(socketPath)) -w \(timeoutSeconds) 2>/dev/null"
|
||||
proc.arguments = ["-lc", script]
|
||||
|
||||
let outPipe = Pipe()
|
||||
|
|
@ -364,11 +1048,21 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
|
||||
}
|
||||
|
||||
private func readTrimmedFile(atPath path: String) -> String? {
|
||||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
|
||||
let value = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private final class ControlSocketClient {
|
||||
private let path: String
|
||||
private let responseTimeout: TimeInterval
|
||||
|
||||
init(path: String) {
|
||||
init(path: String, responseTimeout: TimeInterval = 2.0) {
|
||||
self.path = path
|
||||
self.responseTimeout = responseTimeout
|
||||
}
|
||||
|
||||
func sendLine(_ line: String) -> String? {
|
||||
|
|
@ -431,9 +1125,18 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
}
|
||||
guard wrote else { return nil }
|
||||
|
||||
let deadline = Date().addingTimeInterval(responseTimeout)
|
||||
var buf = [UInt8](repeating: 0, count: 4096)
|
||||
var accum = ""
|
||||
while true {
|
||||
while Date() < deadline {
|
||||
var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
|
||||
let ready = poll(&pollDescriptor, 1, 100)
|
||||
if ready < 0 {
|
||||
return nil
|
||||
}
|
||||
if ready == 0 {
|
||||
continue
|
||||
}
|
||||
let n = read(fd, &buf, buf.count)
|
||||
if n <= 0 { break }
|
||||
if let chunk = String(bytes: buf[0..<n], encoding: .utf8) {
|
||||
|
|
|
|||
296
cmuxUITests/SidebarHelpMenuUITests.swift
Normal file
296
cmuxUITests/SidebarHelpMenuUITests.swift
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import XCTest
|
||||
|
||||
private func sidebarHelpPollUntil(
|
||||
timeout: TimeInterval,
|
||||
pollInterval: TimeInterval = 0.05,
|
||||
condition: () -> Bool
|
||||
) -> Bool {
|
||||
let start = ProcessInfo.processInfo.systemUptime
|
||||
while true {
|
||||
if condition() {
|
||||
return true
|
||||
}
|
||||
if (ProcessInfo.processInfo.systemUptime - start) >= timeout {
|
||||
return false
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(pollInterval))
|
||||
}
|
||||
}
|
||||
|
||||
final class SidebarHelpMenuUITests: XCTestCase {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testHelpMenuOpensKeyboardShortcutsSection() {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
||||
launchAndActivate(app)
|
||||
|
||||
XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0))
|
||||
|
||||
let helpButton = requireElement(
|
||||
candidates: helpButtonCandidates(in: app),
|
||||
timeout: 6.0,
|
||||
description: "sidebar help button"
|
||||
)
|
||||
helpButton.click()
|
||||
|
||||
let keyboardShortcutsItem = requireElement(
|
||||
candidates: helpMenuItemCandidates(in: app, identifier: "SidebarHelpMenuOptionKeyboardShortcuts", title: "Keyboard Shortcuts"),
|
||||
timeout: 3.0,
|
||||
description: "Keyboard Shortcuts help menu item"
|
||||
)
|
||||
keyboardShortcutsItem.click()
|
||||
|
||||
XCTAssertTrue(app.staticTexts["ShortcutRecordingHint"].waitForExistence(timeout: 6.0))
|
||||
}
|
||||
|
||||
func testHelpMenuCheckForUpdatesTriggersSidebarUpdatePill() {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_FEED_URL"] = "https://cmux.test/appcast.xml"
|
||||
app.launchEnvironment["CMUX_UI_TEST_FEED_MODE"] = "available"
|
||||
app.launchEnvironment["CMUX_UI_TEST_UPDATE_VERSION"] = "9.9.9"
|
||||
app.launchEnvironment["CMUX_UI_TEST_AUTO_ALLOW_PERMISSION"] = "1"
|
||||
launchAndActivate(app)
|
||||
|
||||
XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0))
|
||||
|
||||
let helpButton = requireElement(
|
||||
candidates: helpButtonCandidates(in: app),
|
||||
timeout: 6.0,
|
||||
description: "sidebar help button"
|
||||
)
|
||||
helpButton.click()
|
||||
|
||||
let checkForUpdatesItem = requireElement(
|
||||
candidates: helpMenuItemCandidates(in: app, identifier: "SidebarHelpMenuOptionCheckForUpdates", title: "Check for Updates"),
|
||||
timeout: 3.0,
|
||||
description: "Check for Updates help menu item"
|
||||
)
|
||||
checkForUpdatesItem.click()
|
||||
|
||||
let updatePill = app.buttons["UpdatePill"]
|
||||
XCTAssertTrue(updatePill.waitForExistence(timeout: 6.0))
|
||||
XCTAssertEqual(updatePill.label, "Update Available: 9.9.9")
|
||||
}
|
||||
|
||||
func testHelpMenuSendFeedbackOpensComposerSheet() {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
||||
launchAndActivate(app)
|
||||
|
||||
XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0))
|
||||
|
||||
let helpButton = requireElement(
|
||||
candidates: helpButtonCandidates(in: app),
|
||||
timeout: 6.0,
|
||||
description: "sidebar help button"
|
||||
)
|
||||
helpButton.click()
|
||||
|
||||
let sendFeedbackItem = requireElement(
|
||||
candidates: helpMenuItemCandidates(in: app, identifier: "SidebarHelpMenuOptionSendFeedback", title: "Send Feedback"),
|
||||
timeout: 3.0,
|
||||
description: "Send Feedback help menu item"
|
||||
)
|
||||
sendFeedbackItem.click()
|
||||
|
||||
XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0))
|
||||
XCTAssertTrue(
|
||||
firstExistingElement(
|
||||
candidates: [
|
||||
app.textFields["SidebarFeedbackEmailField"],
|
||||
app.textFields["Your Email"],
|
||||
],
|
||||
timeout: 2.0
|
||||
) != nil
|
||||
)
|
||||
XCTAssertTrue(
|
||||
firstExistingElement(
|
||||
candidates: [
|
||||
app.buttons["SidebarFeedbackAttachButton"],
|
||||
app.buttons["Attach Images"],
|
||||
],
|
||||
timeout: 2.0
|
||||
) != nil
|
||||
)
|
||||
XCTAssertTrue(
|
||||
firstExistingElement(
|
||||
candidates: [
|
||||
app.buttons["SidebarFeedbackSendButton"],
|
||||
app.buttons["Send"],
|
||||
],
|
||||
timeout: 2.0
|
||||
) != nil
|
||||
)
|
||||
XCTAssertTrue(
|
||||
app.staticTexts[
|
||||
"A human will read this! You can also reach us at founders@manaflow.com."
|
||||
].waitForExistence(timeout: 2.0)
|
||||
)
|
||||
|
||||
let messageEditor = requireElement(
|
||||
candidates: [
|
||||
app.textViews["SidebarFeedbackMessageEditor"],
|
||||
app.scrollViews["SidebarFeedbackMessageEditor"],
|
||||
app.otherElements["SidebarFeedbackMessageEditor"],
|
||||
app.textViews["Message"],
|
||||
],
|
||||
timeout: 2.0,
|
||||
description: "feedback message editor"
|
||||
)
|
||||
messageEditor.click()
|
||||
app.typeText("hello")
|
||||
XCTAssertTrue(app.staticTexts["5/4000"].waitForExistence(timeout: 2.0))
|
||||
}
|
||||
|
||||
private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
sidebarHelpPollUntil(timeout: timeout) {
|
||||
app.windows.count >= count
|
||||
}
|
||||
}
|
||||
|
||||
private func helpButtonCandidates(in app: XCUIApplication) -> [XCUIElement] {
|
||||
let sidebar = app.otherElements["Sidebar"]
|
||||
return [
|
||||
app.buttons["SidebarHelpMenuButton"],
|
||||
app.buttons["Help"],
|
||||
sidebar.buttons["SidebarHelpMenuButton"],
|
||||
sidebar.buttons["Help"],
|
||||
]
|
||||
}
|
||||
|
||||
private func helpMenuItemCandidates(
|
||||
in app: XCUIApplication,
|
||||
identifier: String,
|
||||
title: String
|
||||
) -> [XCUIElement] {
|
||||
[
|
||||
app.buttons[identifier],
|
||||
app.buttons[title],
|
||||
]
|
||||
}
|
||||
|
||||
private func firstExistingElement(
|
||||
candidates: [XCUIElement],
|
||||
timeout: TimeInterval
|
||||
) -> XCUIElement? {
|
||||
var match: XCUIElement?
|
||||
let found = sidebarHelpPollUntil(timeout: timeout) {
|
||||
for candidate in candidates where candidate.exists {
|
||||
match = candidate
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return found ? match : nil
|
||||
}
|
||||
|
||||
private func requireElement(
|
||||
candidates: [XCUIElement],
|
||||
timeout: TimeInterval,
|
||||
description: String
|
||||
) -> XCUIElement {
|
||||
guard let element = firstExistingElement(candidates: candidates, timeout: timeout) else {
|
||||
XCTFail("Expected \(description) to exist")
|
||||
return candidates[0]
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
private func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) {
|
||||
app.launch()
|
||||
let activated = sidebarHelpPollUntil(timeout: activateTimeout) {
|
||||
guard app.state != .runningForeground else {
|
||||
return true
|
||||
}
|
||||
app.activate()
|
||||
return app.state == .runningForeground
|
||||
}
|
||||
if !activated {
|
||||
app.activate()
|
||||
}
|
||||
XCTAssertTrue(
|
||||
sidebarHelpPollUntil(timeout: 2.0) { app.state == .runningForeground },
|
||||
"App did not reach runningForeground before UI interactions"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class FeedbackComposerShortcutUITests: XCTestCase {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testCmdOptionFOpensFeedbackComposer() {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
||||
app.launch()
|
||||
app.activate()
|
||||
|
||||
XCTAssertTrue(
|
||||
sidebarHelpPollUntil(timeout: 6.0) {
|
||||
app.windows.count >= 1
|
||||
}
|
||||
)
|
||||
|
||||
app.typeKey("f", modifierFlags: [.command, .option])
|
||||
|
||||
XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0))
|
||||
XCTAssertTrue(
|
||||
app.textFields["SidebarFeedbackEmailField"].waitForExistence(timeout: 2.0)
|
||||
|| app.textFields["Your Email"].waitForExistence(timeout: 2.0)
|
||||
)
|
||||
}
|
||||
|
||||
func testCmdOptionFWorksWithHiddenSidebar() {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
||||
app.launch()
|
||||
app.activate()
|
||||
|
||||
XCTAssertTrue(
|
||||
sidebarHelpPollUntil(timeout: 6.0) {
|
||||
app.windows.count >= 1
|
||||
}
|
||||
)
|
||||
|
||||
app.typeKey("b", modifierFlags: [.command])
|
||||
|
||||
XCTAssertTrue(
|
||||
sidebarHelpPollUntil(timeout: 3.0) {
|
||||
!app.buttons["SidebarHelpMenuButton"].exists && !app.buttons["Help"].exists
|
||||
}
|
||||
)
|
||||
|
||||
app.typeKey("f", modifierFlags: [.command, .option])
|
||||
|
||||
XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0))
|
||||
}
|
||||
|
||||
func testCmdOptionFWorksFromSettingsWindow() {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_SHOW_SETTINGS"] = "1"
|
||||
app.launch()
|
||||
app.activate()
|
||||
|
||||
XCTAssertTrue(
|
||||
sidebarHelpPollUntil(timeout: 6.0) {
|
||||
app.windows.count >= 2
|
||||
}
|
||||
)
|
||||
|
||||
app.typeKey("f", modifierFlags: [.command, .option])
|
||||
|
||||
XCTAssertTrue(app.staticTexts["Send Feedback"].waitForExistence(timeout: 3.0))
|
||||
XCTAssertTrue(
|
||||
app.textFields["SidebarFeedbackEmailField"].waitForExistence(timeout: 2.0)
|
||||
|| app.textFields["Your Email"].waitForExistence(timeout: 2.0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -318,14 +318,13 @@ OPEN_CLEAN_ENV=(
|
|||
|
||||
if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then
|
||||
# Ensure tag-specific socket paths win even if the caller has CMUX_* overrides.
|
||||
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open "$APP_PATH"
|
||||
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open -g "$APP_PATH"
|
||||
elif [[ -n "${TAG_SLUG:-}" ]]; then
|
||||
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open "$APP_PATH"
|
||||
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open -g "$APP_PATH"
|
||||
else
|
||||
echo "/tmp/cmux-debug.log" > /tmp/cmux-last-debug-log-path || true
|
||||
"${OPEN_CLEAN_ENV[@]}" open "$APP_PATH"
|
||||
"${OPEN_CLEAN_ENV[@]}" open -g "$APP_PATH"
|
||||
fi
|
||||
osascript -e "tell application id \"${BUNDLE_ID}\" to activate" || true
|
||||
|
||||
# Safety: ensure only one instance is running.
|
||||
sleep 0.2
|
||||
|
|
|
|||
|
|
@ -17,5 +17,4 @@ if [[ -z "${APP_PATH}" ]]; then
|
|||
fi
|
||||
# Dev shells (including CI/Codex) often force-disable paging by exporting these.
|
||||
# Don't leak that into cmux, otherwise `git diff` won't page even with PAGER=less.
|
||||
env -u GIT_PAGER -u GH_PAGER open "$APP_PATH"
|
||||
osascript -e 'tell application "cmux" to activate' || true
|
||||
env -u GIT_PAGER -u GH_PAGER open -g "$APP_PATH"
|
||||
|
|
|
|||
|
|
@ -251,8 +251,7 @@ OPEN_CLEAN_ENV=(
|
|||
|
||||
# Always inject staging socket paths via env to ensure they take effect
|
||||
# (LSEnvironment requires app restart to pick up plist changes).
|
||||
"${OPEN_CLEAN_ENV[@]}" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" open "$APP_PATH"
|
||||
osascript -e "tell application id \"${BUNDLE_ID}\" to activate" || true
|
||||
"${OPEN_CLEAN_ENV[@]}" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" open -g "$APP_PATH"
|
||||
|
||||
# Safety: ensure only one instance is running.
|
||||
sleep 0.2
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
E2E: focusing a panel preserves its notification and triggers a flash.
|
||||
E2E: focusing a panel clears its notification and triggers a flash.
|
||||
|
||||
Note: This uses the socket focus command (no assistive access needed).
|
||||
"""
|
||||
|
|
@ -74,12 +74,8 @@ def main() -> int:
|
|||
client.send("x")
|
||||
time.sleep(0.2)
|
||||
|
||||
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")
|
||||
if not wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
|
||||
print("FAIL: Notification did not become read after focus")
|
||||
return 1
|
||||
|
||||
final_flash = client.flash_count(term_b)
|
||||
|
|
@ -97,7 +93,7 @@ def main() -> int:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
print("PASS: Focus preserves notification and flashes panel")
|
||||
print("PASS: Focus clears notification and flashes panel")
|
||||
return 0
|
||||
except (cmuxError, RuntimeError) as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
|
|
|
|||
|
|
@ -58,15 +58,6 @@ 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:
|
||||
|
|
@ -224,8 +215,8 @@ def test_rxvt_notification_osc777(client: cmux) -> TestResult:
|
|||
return result
|
||||
|
||||
|
||||
def test_preserve_unread_on_focus_change(client: cmux) -> TestResult:
|
||||
result = TestResult("Preserve Unread On Panel Focus")
|
||||
def test_mark_read_on_focus_change(client: cmux) -> TestResult:
|
||||
result = TestResult("Mark Read On Panel Focus")
|
||||
try:
|
||||
client.clear_notifications()
|
||||
client.reset_flash_counts()
|
||||
|
|
@ -238,88 +229,81 @@ def test_preserve_unread_on_focus_change(client: cmux) -> TestResult:
|
|||
|
||||
client.set_app_focus(False)
|
||||
client.notify_surface(other[0], "focusread")
|
||||
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
|
||||
time.sleep(0.1)
|
||||
|
||||
client.set_app_focus(True)
|
||||
client.focus_surface(other[0])
|
||||
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
|
||||
time.sleep(0.1)
|
||||
|
||||
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 target["is_read"]:
|
||||
result.failure("Expected notification to remain unread on focus")
|
||||
elif not target["is_read"]:
|
||||
result.failure("Expected notification to be marked read on focus")
|
||||
else:
|
||||
result.success("Notification persisted across panel focus")
|
||||
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")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
||||
|
||||
def test_preserve_unread_on_app_active(client: cmux) -> TestResult:
|
||||
result = TestResult("Preserve Unread On App Active")
|
||||
def test_mark_read_on_app_active(client: cmux) -> TestResult:
|
||||
result = TestResult("Mark Read On App Active")
|
||||
try:
|
||||
client.clear_notifications()
|
||||
client.set_app_focus(False)
|
||||
client.notify("activate")
|
||||
items = wait_for_notifications(client, 1)
|
||||
time.sleep(0.1)
|
||||
|
||||
items = client.list_notifications()
|
||||
if not items or items[0]["is_read"]:
|
||||
result.failure("Expected unread notification before activation")
|
||||
return result
|
||||
|
||||
client.simulate_app_active()
|
||||
items = wait_for_notifications(client, 1)
|
||||
time.sleep(0.1)
|
||||
|
||||
items = client.list_notifications()
|
||||
if not items:
|
||||
result.failure("Expected notification to remain after activation")
|
||||
elif items[0]["is_read"]:
|
||||
result.failure("Expected notification to remain unread on app active")
|
||||
elif not items[0]["is_read"]:
|
||||
result.failure("Expected notification to be marked read on app active")
|
||||
else:
|
||||
result.success("Notification persisted across app activation")
|
||||
result.success("Notification marked read on app active")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
||||
|
||||
def test_preserve_unread_on_tab_switch(client: cmux) -> TestResult:
|
||||
result = TestResult("Preserve Unread On Tab Switch")
|
||||
def test_mark_read_on_tab_switch(client: cmux) -> TestResult:
|
||||
result = TestResult("Mark Read On Tab Switch")
|
||||
try:
|
||||
client.clear_notifications()
|
||||
client.set_app_focus(False)
|
||||
tab1 = client.current_workspace()
|
||||
client.notify("tabswitch")
|
||||
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
|
||||
time.sleep(0.1)
|
||||
|
||||
tab2 = client.new_workspace()
|
||||
if not wait_for_current_workspace(client, tab2):
|
||||
result.failure("Expected new workspace to become selected")
|
||||
return result
|
||||
time.sleep(0.1)
|
||||
|
||||
client.set_app_focus(True)
|
||||
client.select_workspace(tab1)
|
||||
if not wait_for_current_workspace(client, tab1):
|
||||
result.failure("Expected original workspace to become selected again")
|
||||
return result
|
||||
time.sleep(0.1)
|
||||
|
||||
items = wait_for_notifications(client, 1)
|
||||
items = client.list_notifications()
|
||||
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 target["is_read"]:
|
||||
result.failure("Expected notification to remain unread on tab switch")
|
||||
elif not target["is_read"]:
|
||||
result.failure("Expected notification to be marked read on tab switch")
|
||||
else:
|
||||
result.success("Notification persisted across tab switch")
|
||||
result.success("Notification marked read on tab switch")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
|
@ -387,20 +371,11 @@ 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, flashes, and preserves unread state")
|
||||
result.success("Notification click focuses and flashes panel")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
|
@ -480,9 +455,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_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_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_flash_on_tab_switch(client))
|
||||
results.append(test_focus_on_notification_click(client))
|
||||
results.append(test_restore_focus_on_tab_switch(client))
|
||||
|
|
|
|||
|
|
@ -91,6 +91,32 @@ def _run_cli_text(cli: str, args: list[str], retries: int = 3) -> str:
|
|||
|
||||
raise cmuxError(f"CLI failed ({' '.join(args)}): {last_merged}")
|
||||
|
||||
|
||||
def _run_cli_tail_json(cli: str, args: list[str], retries: int = 3) -> dict:
|
||||
last_merged = ""
|
||||
for attempt in range(1, retries + 1):
|
||||
proc = subprocess.run(
|
||||
[cli, "--socket", SOCKET_PATH] + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
try:
|
||||
return json.loads(proc.stdout or "{}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise cmuxError(f"Invalid CLI JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
|
||||
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
last_merged = merged
|
||||
if "Command timed out" in merged and attempt < retries:
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
raise cmuxError(f"CLI failed ({' '.join(args)}): {merged}")
|
||||
|
||||
raise cmuxError(f"CLI failed ({' '.join(args)}): {last_merged}")
|
||||
|
||||
|
||||
def _run_cli_expect_failure(cli: str, args: list[str], needles: list[str]) -> None:
|
||||
proc = subprocess.run(
|
||||
[cli, "--socket", SOCKET_PATH, "--json"] + args,
|
||||
|
|
@ -144,15 +170,6 @@ def main() -> int:
|
|||
cli = _find_cli_binary()
|
||||
|
||||
with _local_test_server() as page_url:
|
||||
opened = _run_cli_json(cli, ["browser", "open", page_url])
|
||||
surface = str(opened.get("surface_ref") or opened.get("surface_id") or "")
|
||||
_must(bool(surface), f"browser open returned no surface handle: {opened}")
|
||||
_must(surface.startswith("surface:"), f"Expected short surface ref from browser open, got: {opened}")
|
||||
|
||||
_run_cli_json(cli, ["browser", surface, "wait", "--load-state", "complete", "--timeout-ms", "15000"])
|
||||
snapshot_text = _run_cli_text(cli, ["browser", surface, "snapshot", "--interactive"])
|
||||
_must("ref=e" in snapshot_text, f"Expected snapshot text with refs from CLI: {snapshot_text!r}")
|
||||
|
||||
identify = _run_cli_json(cli, ["identify"])
|
||||
focused = identify.get("focused") or {}
|
||||
workspace = str(
|
||||
|
|
@ -163,6 +180,28 @@ def main() -> int:
|
|||
or ""
|
||||
)
|
||||
_must(bool(workspace), f"Expected workspace handle from identify: {identify}")
|
||||
os.environ["CMUX_WORKSPACE_ID"] = workspace
|
||||
|
||||
opened_tail_json = _run_cli_tail_json(
|
||||
cli,
|
||||
["browser", "open", page_url, "--workspace", workspace, "--id-format", "both", "--json"],
|
||||
)
|
||||
tail_surface = str(opened_tail_json.get("surface_ref") or "")
|
||||
_must(tail_surface.startswith("surface:"), f"Expected trailing --json browser open to return surface_ref: {opened_tail_json}")
|
||||
_must(bool(opened_tail_json.get("surface_id")), f"Expected trailing --id-format both to preserve surface_id: {opened_tail_json}")
|
||||
_must("--json" not in str(opened_tail_json.get("url") or ""), f"Trailing output flags leaked into browser open URL: {opened_tail_json}")
|
||||
_run_cli_json(cli, ["browser", tail_surface, "wait", "--load-state", "complete", "--timeout-ms", "15000"])
|
||||
tail_url_payload = _run_cli_json(cli, ["browser", tail_surface, "url"])
|
||||
_must(str(tail_url_payload.get("url") or "").startswith(page_url), f"Expected trailing --json browser open to navigate: {tail_url_payload}")
|
||||
|
||||
opened = _run_cli_json(cli, ["browser", "open", page_url])
|
||||
surface = str(opened.get("surface_ref") or opened.get("surface_id") or "")
|
||||
_must(bool(surface), f"browser open returned no surface handle: {opened}")
|
||||
_must(surface.startswith("surface:"), f"Expected short surface ref from browser open, got: {opened}")
|
||||
|
||||
_run_cli_json(cli, ["browser", surface, "wait", "--load-state", "complete", "--timeout-ms", "15000"])
|
||||
snapshot_text = _run_cli_text(cli, ["browser", surface, "snapshot", "--interactive"])
|
||||
_must("ref=e" in snapshot_text, f"Expected snapshot text with refs from CLI: {snapshot_text!r}")
|
||||
|
||||
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 "")
|
||||
|
|
@ -179,6 +218,14 @@ def main() -> int:
|
|||
_must(routed_url.startswith(page_url), f"Expected routed URL to start with page URL, got: {routed_url_payload}")
|
||||
_must("--workspace" not in routed_url and "--window" not in routed_url, f"Routing flags leaked into URL: {routed_url_payload}")
|
||||
|
||||
goto_url = f"{page_url}?goto=1"
|
||||
goto_payload = _run_cli_json(cli, ["browser", surface, "goto", goto_url, "--snapshot-after"])
|
||||
_must(bool(goto_payload.get("post_action_snapshot")), f"Expected goto --snapshot-after to include post_action_snapshot: {goto_payload}")
|
||||
goto_url_payload = _run_cli_json(cli, ["browser", surface, "url"])
|
||||
current_goto_url = str(goto_url_payload.get("url") or "")
|
||||
_must(current_goto_url.startswith(goto_url), f"Expected goto --snapshot-after current URL to match target URL: {goto_url_payload}")
|
||||
_must("--snapshot-after" not in current_goto_url, f"Expected goto URL to exclude trailing flag text: {goto_url_payload}")
|
||||
|
||||
find_text = _run_cli_json(cli, ["browser", surface, "find", "text", "row-b"])
|
||||
_must(str(find_text.get("element_ref") or "").startswith("@e"), f"Expected element_ref from find text: {find_text}")
|
||||
|
||||
|
|
|
|||
214
tests_v2/test_cli_new_workspace_external_git_branch_refresh.py
Normal file
214
tests_v2/test_cli_new_workspace_external_git_branch_refresh.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: background workspaces should refresh git branch after external repo changes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
def _resolve_socket_path() -> str:
|
||||
socket_path = os.environ.get("CMUX_SOCKET", "").strip()
|
||||
if not socket_path:
|
||||
raise cmuxError("CMUX_SOCKET is required (expected /tmp/cmux-debug-<tag>.sock)")
|
||||
if not re.fullmatch(r"/tmp/cmux-debug-[^/]+\.sock", socket_path):
|
||||
raise cmuxError(f"CMUX_SOCKET must be a tagged debug socket, got: {socket_path!r}")
|
||||
return socket_path
|
||||
|
||||
|
||||
SOCKET_PATH = _resolve_socket_path()
|
||||
|
||||
|
||||
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: list[str]) -> str:
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
env.pop("CMUX_TAB_ID", None)
|
||||
|
||||
cmd = [cli, "--socket", SOCKET_PATH] + args
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
|
||||
if proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
|
||||
return (proc.stdout or "").strip()
|
||||
|
||||
|
||||
def _parse_sidebar_state(text: str) -> dict[str, str]:
|
||||
parsed: dict[str, str] = {}
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
parsed[key.strip()] = value.strip()
|
||||
return parsed
|
||||
|
||||
|
||||
def _wait_for_sidebar_branch(
|
||||
cli: str,
|
||||
workspace: str,
|
||||
expected_branch: str,
|
||||
timeout: float = 15.0,
|
||||
) -> dict[str, str]:
|
||||
deadline = time.time() + timeout
|
||||
last_state = ""
|
||||
|
||||
while time.time() < deadline:
|
||||
state_text = _run_cli(cli, ["sidebar-state", "--workspace", workspace])
|
||||
last_state = state_text
|
||||
state = _parse_sidebar_state(state_text)
|
||||
raw_branch = state.get("git_branch", "")
|
||||
observed_branch = raw_branch.split(" ", 1)[0]
|
||||
if observed_branch == expected_branch:
|
||||
return state
|
||||
time.sleep(0.1)
|
||||
|
||||
raise cmuxError(
|
||||
f"Timed out waiting for branch {expected_branch!r} on workspace {workspace}. "
|
||||
f"Last sidebar-state: {last_state!r}"
|
||||
)
|
||||
|
||||
|
||||
def _create_git_repo(root: Path) -> Path:
|
||||
repo = root / "repo"
|
||||
repo.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
subprocess.run(
|
||||
["git", "-c", "init.defaultBranch=main", "init"],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "cmux-test"],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "cmux-test@example.com"],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
(repo / "README.md").write_text("issue 915 external refresh\n", encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", "README.md"],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "-c", "commit.gpgsign=false", "commit", "-m", "init"],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return repo
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cli = _find_cli_binary()
|
||||
temp_root = Path(tempfile.mkdtemp(prefix="cmux_issue_915_external_git_"))
|
||||
created_workspace: str | None = None
|
||||
|
||||
try:
|
||||
repo_path = _create_git_repo(temp_root)
|
||||
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
baseline_workspace = client.current_workspace()
|
||||
|
||||
created = _run_cli(cli, ["new-workspace", "--cwd", str(repo_path)])
|
||||
_must(created.startswith("OK "), f"new-workspace expected OK response, got: {created!r}")
|
||||
created_workspace = created.removeprefix("OK ").strip()
|
||||
_must(bool(created_workspace), f"new-workspace returned no workspace handle: {created!r}")
|
||||
|
||||
_must(
|
||||
client.current_workspace() == baseline_workspace,
|
||||
"new-workspace --cwd should preserve selected workspace",
|
||||
)
|
||||
|
||||
initial_state = _wait_for_sidebar_branch(cli, created_workspace, "main")
|
||||
_must(
|
||||
initial_state.get("cwd", "") == str(repo_path),
|
||||
f"Expected sidebar cwd={repo_path!r}, got {initial_state.get('cwd', '')!r}",
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "feature/external-refresh"],
|
||||
cwd=repo_path,
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
refreshed_state = _wait_for_sidebar_branch(
|
||||
cli,
|
||||
created_workspace,
|
||||
"feature/external-refresh",
|
||||
timeout=15.0,
|
||||
)
|
||||
_must(
|
||||
refreshed_state.get("cwd", "") == str(repo_path),
|
||||
f"Expected refreshed sidebar cwd={repo_path!r}, got {refreshed_state.get('cwd', '')!r}",
|
||||
)
|
||||
|
||||
_must(
|
||||
client.current_workspace() == baseline_workspace,
|
||||
"external git branch refresh should not switch selected workspace",
|
||||
)
|
||||
finally:
|
||||
if created_workspace:
|
||||
try:
|
||||
_run_cli(cli, ["close-workspace", "--workspace", created_workspace])
|
||||
except Exception:
|
||||
pass
|
||||
shutil.rmtree(temp_root, ignore_errors=True)
|
||||
|
||||
print("PASS: background workspace git branch refreshes after external repo checkout")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
E2E: focusing a panel preserves its notification and triggers a flash.
|
||||
E2E: focusing a panel clears its notification and triggers a flash.
|
||||
|
||||
Note: This uses the socket focus command (no assistive access needed).
|
||||
"""
|
||||
|
|
@ -74,12 +74,8 @@ def main() -> int:
|
|||
client.send("x")
|
||||
time.sleep(0.2)
|
||||
|
||||
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")
|
||||
if not wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
|
||||
print("FAIL: Notification did not become read after focus")
|
||||
return 1
|
||||
|
||||
final_flash = client.flash_count(term_b)
|
||||
|
|
@ -97,7 +93,7 @@ def main() -> int:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
print("PASS: Focus preserves notification and flashes panel")
|
||||
print("PASS: Focus clears notification and flashes panel")
|
||||
return 0
|
||||
except (cmuxError, RuntimeError) as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
|
|
|
|||
|
|
@ -58,15 +58,6 @@ 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:
|
||||
|
|
@ -224,8 +215,8 @@ def test_rxvt_notification_osc777(client: cmux) -> TestResult:
|
|||
return result
|
||||
|
||||
|
||||
def test_preserve_unread_on_focus_change(client: cmux) -> TestResult:
|
||||
result = TestResult("Preserve Unread On Panel Focus")
|
||||
def test_mark_read_on_focus_change(client: cmux) -> TestResult:
|
||||
result = TestResult("Mark Read On Panel Focus")
|
||||
try:
|
||||
client.clear_notifications()
|
||||
client.reset_flash_counts()
|
||||
|
|
@ -238,88 +229,81 @@ def test_preserve_unread_on_focus_change(client: cmux) -> TestResult:
|
|||
|
||||
client.set_app_focus(False)
|
||||
client.notify_surface(other[0], "focusread")
|
||||
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
|
||||
time.sleep(0.1)
|
||||
|
||||
client.set_app_focus(True)
|
||||
client.focus_surface(other[0])
|
||||
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
|
||||
time.sleep(0.1)
|
||||
|
||||
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 target["is_read"]:
|
||||
result.failure("Expected notification to remain unread on focus")
|
||||
elif not target["is_read"]:
|
||||
result.failure("Expected notification to be marked read on focus")
|
||||
else:
|
||||
result.success("Notification persisted across panel focus")
|
||||
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")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
||||
|
||||
def test_preserve_unread_on_app_active(client: cmux) -> TestResult:
|
||||
result = TestResult("Preserve Unread On App Active")
|
||||
def test_mark_read_on_app_active(client: cmux) -> TestResult:
|
||||
result = TestResult("Mark Read On App Active")
|
||||
try:
|
||||
client.clear_notifications()
|
||||
client.set_app_focus(False)
|
||||
client.notify("activate")
|
||||
items = wait_for_notifications(client, 1)
|
||||
time.sleep(0.1)
|
||||
|
||||
items = client.list_notifications()
|
||||
if not items or items[0]["is_read"]:
|
||||
result.failure("Expected unread notification before activation")
|
||||
return result
|
||||
|
||||
client.simulate_app_active()
|
||||
items = wait_for_notifications(client, 1)
|
||||
time.sleep(0.1)
|
||||
|
||||
items = client.list_notifications()
|
||||
if not items:
|
||||
result.failure("Expected notification to remain after activation")
|
||||
elif items[0]["is_read"]:
|
||||
result.failure("Expected notification to remain unread on app active")
|
||||
elif not items[0]["is_read"]:
|
||||
result.failure("Expected notification to be marked read on app active")
|
||||
else:
|
||||
result.success("Notification persisted across app activation")
|
||||
result.success("Notification marked read on app active")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
||||
|
||||
def test_preserve_unread_on_tab_switch(client: cmux) -> TestResult:
|
||||
result = TestResult("Preserve Unread On Tab Switch")
|
||||
def test_mark_read_on_tab_switch(client: cmux) -> TestResult:
|
||||
result = TestResult("Mark Read On Tab Switch")
|
||||
try:
|
||||
client.clear_notifications()
|
||||
client.set_app_focus(False)
|
||||
tab1 = client.current_workspace()
|
||||
client.notify("tabswitch")
|
||||
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
|
||||
time.sleep(0.1)
|
||||
|
||||
tab2 = client.new_workspace()
|
||||
if not wait_for_current_workspace(client, tab2):
|
||||
result.failure("Expected new workspace to become selected")
|
||||
return result
|
||||
time.sleep(0.1)
|
||||
|
||||
client.set_app_focus(True)
|
||||
client.select_workspace(tab1)
|
||||
if not wait_for_current_workspace(client, tab1):
|
||||
result.failure("Expected original workspace to become selected again")
|
||||
return result
|
||||
time.sleep(0.1)
|
||||
|
||||
items = wait_for_notifications(client, 1)
|
||||
items = client.list_notifications()
|
||||
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 target["is_read"]:
|
||||
result.failure("Expected notification to remain unread on tab switch")
|
||||
elif not target["is_read"]:
|
||||
result.failure("Expected notification to be marked read on tab switch")
|
||||
else:
|
||||
result.success("Notification persisted across tab switch")
|
||||
result.success("Notification marked read on tab switch")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
|
@ -387,20 +371,11 @@ 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, flashes, and preserves unread state")
|
||||
result.success("Notification click focuses and flashes panel")
|
||||
except Exception as e:
|
||||
result.failure(f"Exception: {e}")
|
||||
return result
|
||||
|
|
@ -480,9 +455,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_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_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_flash_on_tab_switch(client))
|
||||
results.append(test_focus_on_notification_click(client))
|
||||
results.append(test_restore_focus_on_tab_switch(client))
|
||||
|
|
|
|||
3
web/.env.example
Normal file
3
web/.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
RESEND_API_KEY=
|
||||
CMUX_FEEDBACK_FROM_EMAIL=
|
||||
CMUX_FEEDBACK_RATE_LIMIT_ID=
|
||||
340
web/app/api/feedback/route.ts
Normal file
340
web/app/api/feedback/route.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import { checkRateLimit } from "@vercel/firewall";
|
||||
import { NextResponse } from "next/server";
|
||||
import { Resend } from "resend";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "@/app/env";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const feedbackRecipient = "feedback@manaflow.com";
|
||||
const maxAttachmentCount = 10;
|
||||
const maxAttachmentBytes = 4 * 1024 * 1024;
|
||||
// Keep multipart requests below Vercel Functions' 4.5 MB request-body limit.
|
||||
const maxTotalAttachmentBytes = 4 * 1024 * 1024;
|
||||
const allowedImageTypes = new Set([
|
||||
"image/gif",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/tiff",
|
||||
"image/webp",
|
||||
]);
|
||||
|
||||
const feedbackSchema = z.object({
|
||||
email: z.string().trim().email().max(320),
|
||||
message: z.string().trim().min(1).max(4000),
|
||||
appVersion: z.string().trim().max(120).optional().default(""),
|
||||
appBuild: z.string().trim().max(120).optional().default(""),
|
||||
appCommit: z.string().trim().max(120).optional().default(""),
|
||||
bundleIdentifier: z.string().trim().max(200).optional().default(""),
|
||||
osVersion: z.string().trim().max(200).optional().default(""),
|
||||
locale: z.string().trim().max(120).optional().default(""),
|
||||
});
|
||||
|
||||
type PreparedAttachment = {
|
||||
content: Buffer;
|
||||
contentType: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const feedbackConfig = resolveFeedbackConfig();
|
||||
if (!feedbackConfig) {
|
||||
return jsonError("Feedback endpoint is not configured", 503);
|
||||
}
|
||||
|
||||
if (process.env.VERCEL === "1") {
|
||||
const { error, rateLimited } = await checkRateLimit(
|
||||
feedbackConfig.rateLimitId,
|
||||
{ request },
|
||||
);
|
||||
|
||||
if (rateLimited || error === "blocked") {
|
||||
return jsonError("Rate limit exceeded", 429);
|
||||
}
|
||||
|
||||
if (error === "not-found") {
|
||||
console.error(
|
||||
"feedback.route.rate_limit_not_found",
|
||||
feedbackConfig.rateLimitId,
|
||||
);
|
||||
} else if (error) {
|
||||
console.error("feedback.route.rate_limit_error", error);
|
||||
}
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
} catch {
|
||||
return jsonError("Invalid multipart payload", 400);
|
||||
}
|
||||
|
||||
const parsed = feedbackSchema.safeParse({
|
||||
email: getString(formData, "email"),
|
||||
message: getString(formData, "message"),
|
||||
appVersion: getString(formData, "appVersion"),
|
||||
appBuild: getString(formData, "appBuild"),
|
||||
appCommit: getString(formData, "appCommit"),
|
||||
bundleIdentifier: getString(formData, "bundleIdentifier"),
|
||||
osVersion: getString(formData, "osVersion"),
|
||||
locale: getString(formData, "locale"),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return jsonError("Invalid feedback payload", 400);
|
||||
}
|
||||
|
||||
const attachmentsResult = await prepareAttachments(
|
||||
formData.getAll("attachments"),
|
||||
);
|
||||
if ("errorResponse" in attachmentsResult) {
|
||||
return attachmentsResult.errorResponse;
|
||||
}
|
||||
|
||||
const { appBuild, appCommit, appVersion, bundleIdentifier, email, locale, message, osVersion } =
|
||||
parsed.data;
|
||||
const subject = buildSubject(email, message, appVersion);
|
||||
const attachments = attachmentsResult.attachments;
|
||||
const resend = new Resend(feedbackConfig.resendApiKey);
|
||||
|
||||
const { error } = await resend.emails.send({
|
||||
from: `Manaflow <${feedbackConfig.fromEmail}>`,
|
||||
to: [feedbackRecipient],
|
||||
replyTo: email,
|
||||
subject,
|
||||
text: buildTextBody({
|
||||
email,
|
||||
message,
|
||||
appVersion,
|
||||
appBuild,
|
||||
appCommit,
|
||||
bundleIdentifier,
|
||||
osVersion,
|
||||
locale,
|
||||
attachments,
|
||||
}),
|
||||
html: buildHtmlBody({
|
||||
email,
|
||||
message,
|
||||
appVersion,
|
||||
appBuild,
|
||||
appCommit,
|
||||
bundleIdentifier,
|
||||
osVersion,
|
||||
locale,
|
||||
attachments,
|
||||
}),
|
||||
attachments: attachments.map((attachment) => ({
|
||||
content: attachment.content,
|
||||
contentType: attachment.contentType,
|
||||
filename: attachment.filename,
|
||||
})),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("feedback.route.resend_failed", error);
|
||||
return jsonError("Failed to send feedback", 502);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: true },
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFeedbackConfig() {
|
||||
const resendApiKey = env.RESEND_API_KEY;
|
||||
const fromEmail = env.CMUX_FEEDBACK_FROM_EMAIL;
|
||||
const rateLimitId = env.CMUX_FEEDBACK_RATE_LIMIT_ID;
|
||||
|
||||
if (!resendApiKey || !fromEmail || !rateLimitId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
resendApiKey,
|
||||
fromEmail,
|
||||
rateLimitId,
|
||||
};
|
||||
}
|
||||
|
||||
function getString(formData: FormData, key: string) {
|
||||
const value = formData.get(key);
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
async function prepareAttachments(values: FormDataEntryValue[]) {
|
||||
const files = values.filter(
|
||||
(value): value is File => value instanceof File && value.name.length > 0,
|
||||
);
|
||||
|
||||
if (files.length > maxAttachmentCount) {
|
||||
return {
|
||||
errorResponse: jsonError("Too many images attached", 400),
|
||||
};
|
||||
}
|
||||
|
||||
let totalSize = 0;
|
||||
const attachments: PreparedAttachment[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!allowedImageTypes.has(file.type)) {
|
||||
return {
|
||||
errorResponse: jsonError("Unsupported image attachment type", 415),
|
||||
};
|
||||
}
|
||||
|
||||
if (file.size > maxAttachmentBytes) {
|
||||
return {
|
||||
errorResponse: jsonError("Image attachment is too large", 413),
|
||||
};
|
||||
}
|
||||
|
||||
totalSize += file.size;
|
||||
if (totalSize > maxTotalAttachmentBytes) {
|
||||
return {
|
||||
errorResponse: jsonError("Total image attachment size is too large", 413),
|
||||
};
|
||||
}
|
||||
|
||||
attachments.push({
|
||||
content: Buffer.from(await file.arrayBuffer()),
|
||||
contentType: file.type,
|
||||
filename: sanitizeFilename(file.name),
|
||||
size: file.size,
|
||||
});
|
||||
}
|
||||
|
||||
return { attachments };
|
||||
}
|
||||
|
||||
function buildSubject(email: string, message: string, appVersion: string) {
|
||||
const firstNonEmptyLine =
|
||||
message
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? "Feedback";
|
||||
const summary =
|
||||
firstNonEmptyLine.length > 72
|
||||
? `${firstNonEmptyLine.slice(0, 69)}...`
|
||||
: firstNonEmptyLine;
|
||||
const versionSuffix = appVersion ? ` (v${appVersion})` : "";
|
||||
|
||||
return `cmux feedback from ${email}${versionSuffix}: ${summary}`;
|
||||
}
|
||||
|
||||
function buildTextBody(input: {
|
||||
email: string;
|
||||
message: string;
|
||||
appVersion: string;
|
||||
appBuild: string;
|
||||
appCommit: string;
|
||||
bundleIdentifier: string;
|
||||
osVersion: string;
|
||||
locale: string;
|
||||
attachments: PreparedAttachment[];
|
||||
}) {
|
||||
const attachmentLines =
|
||||
input.attachments.length === 0
|
||||
? "Attachments: none"
|
||||
: [
|
||||
"Attachments:",
|
||||
...input.attachments.map(
|
||||
(attachment) =>
|
||||
`- ${attachment.filename} (${attachment.contentType}, ${attachment.size} bytes)`,
|
||||
),
|
||||
].join("\n");
|
||||
|
||||
return [
|
||||
`From: ${input.email}`,
|
||||
`App version: ${input.appVersion || "unknown"}`,
|
||||
`App build: ${input.appBuild || "unknown"}`,
|
||||
`App commit: ${input.appCommit || "unknown"}`,
|
||||
`Bundle identifier: ${input.bundleIdentifier || "unknown"}`,
|
||||
`macOS: ${input.osVersion || "unknown"}`,
|
||||
`Locale: ${input.locale || "unknown"}`,
|
||||
attachmentLines,
|
||||
"",
|
||||
"Message:",
|
||||
input.message,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildHtmlBody(input: {
|
||||
email: string;
|
||||
message: string;
|
||||
appVersion: string;
|
||||
appBuild: string;
|
||||
appCommit: string;
|
||||
bundleIdentifier: string;
|
||||
osVersion: string;
|
||||
locale: string;
|
||||
attachments: PreparedAttachment[];
|
||||
}) {
|
||||
const attachmentMarkup =
|
||||
input.attachments.length === 0
|
||||
? "<p><strong>Attachments:</strong> none</p>"
|
||||
: `<p><strong>Attachments:</strong></p><ul>${input.attachments
|
||||
.map(
|
||||
(attachment) =>
|
||||
`<li>${escapeHtml(attachment.filename)} (${escapeHtml(
|
||||
attachment.contentType,
|
||||
)}, ${attachment.size} bytes)</li>`,
|
||||
)
|
||||
.join("")}</ul>`;
|
||||
|
||||
return `
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#111827;line-height:1.5">
|
||||
<h1 style="font-size:18px;margin:0 0 16px">cmux feedback</h1>
|
||||
<p><strong>From:</strong> ${escapeHtml(input.email)}</p>
|
||||
<p><strong>App version:</strong> ${escapeHtml(input.appVersion || "unknown")}</p>
|
||||
<p><strong>App build:</strong> ${escapeHtml(input.appBuild || "unknown")}</p>
|
||||
<p><strong>App commit:</strong> ${escapeHtml(input.appCommit || "unknown")}</p>
|
||||
<p><strong>Bundle identifier:</strong> ${escapeHtml(
|
||||
input.bundleIdentifier || "unknown",
|
||||
)}</p>
|
||||
<p><strong>macOS:</strong> ${escapeHtml(input.osVersion || "unknown")}</p>
|
||||
<p><strong>Locale:</strong> ${escapeHtml(input.locale || "unknown")}</p>
|
||||
${attachmentMarkup}
|
||||
<h2 style="font-size:15px;margin:24px 0 8px">Message</h2>
|
||||
<pre style="white-space:pre-wrap;font:13px/1.6 SFMono-Regular,Menlo,monospace;background:#f3f4f6;border-radius:10px;padding:12px">${escapeHtml(
|
||||
input.message,
|
||||
)}</pre>
|
||||
</div>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
function sanitizeFilename(fileName: string) {
|
||||
const cleaned = fileName.replace(/[\r\n"]/g, "").trim();
|
||||
return cleaned.length > 0 ? cleaned : "attachment";
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function jsonError(message: string, status: number) {
|
||||
return NextResponse.json(
|
||||
{ error: message },
|
||||
{
|
||||
status,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
18
web/app/env.ts
Normal file
18
web/app/env.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
RESEND_API_KEY: z.string().min(1),
|
||||
CMUX_FEEDBACK_FROM_EMAIL: z.string().email(),
|
||||
CMUX_FEEDBACK_RATE_LIMIT_ID: z.string().min(1),
|
||||
},
|
||||
runtimeEnv: {
|
||||
RESEND_API_KEY: process.env.RESEND_API_KEY,
|
||||
CMUX_FEEDBACK_FROM_EMAIL: process.env.CMUX_FEEDBACK_FROM_EMAIL,
|
||||
CMUX_FEEDBACK_RATE_LIMIT_ID: process.env.CMUX_FEEDBACK_RATE_LIMIT_ID,
|
||||
},
|
||||
skipValidation:
|
||||
process.env.SKIP_ENV_VALIDATION === "1" ||
|
||||
process.env.VERCEL_ENV === "preview",
|
||||
});
|
||||
24
web/bun.lock
24
web/bun.lock
|
|
@ -5,6 +5,8 @@
|
|||
"": {
|
||||
"name": "web",
|
||||
"dependencies": {
|
||||
"@t3-oss/env-nextjs": "^0.13.10",
|
||||
"@vercel/firewall": "^1.1.2",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"posthog-js": "^1.350.0",
|
||||
|
|
@ -12,7 +14,9 @@
|
|||
"react-dom": "19.2.3",
|
||||
"react-tweet": "^3.3.0",
|
||||
"react-wrap-balancer": "^1.1.1",
|
||||
"resend": "^6.9.3",
|
||||
"shiki": "^3.22.0",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
|
@ -249,8 +253,14 @@
|
|||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@t3-oss/env-core": ["@t3-oss/env-core@0.13.10", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g=="],
|
||||
|
||||
"@t3-oss/env-nextjs": ["@t3-oss/env-nextjs@0.13.10", "", { "dependencies": { "@t3-oss/env-core": "0.13.10" }, "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-JfSA2WXOnvcc/uMdp31paMsfbYhhdvLLRxlwvrnlPE9bwM/n0Z+Qb9xRv48nPpvfMhOrkrTYw1I5Yc06WIKBJQ=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
|
||||
|
|
@ -363,6 +373,8 @@
|
|||
|
||||
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
|
||||
|
||||
"@vercel/firewall": ["@vercel/firewall@1.1.2", "", {}, "sha512-h0sdBVrloWx8TitvWla/rGj3AnJ5JEYfL5LaGHNNOWkyMuzNqfCcGTvJgnjL2A5eSpAAzoN7Xt609YQ0L7xZdw=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
|
@ -543,6 +555,8 @@
|
|||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
|
||||
|
||||
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
|
@ -819,6 +833,8 @@
|
|||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
|
||||
"postal-mime": ["postal-mime@2.7.3", "", {}, "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"posthog-js": ["posthog-js@1.350.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", "@opentelemetry/resources": "^2.2.0", "@opentelemetry/sdk-logs": "^0.208.0", "@posthog/core": "1.23.0", "@posthog/types": "1.350.0", "core-js": "^3.38.1", "dompurify": "^3.3.1", "fflate": "^0.4.8", "preact": "^10.28.2", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.1.0" } }, "sha512-Ab+dyQdlKUTrfUZ12+fvcBo75S4jw/3o2gMleDga21B1v9c15yybiX4S3JrX66uh5L1DYG1H8sxtd4BXIIodjQ=="],
|
||||
|
|
@ -859,6 +875,8 @@
|
|||
|
||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||
|
||||
"resend": ["resend@6.9.3", "", { "dependencies": { "postal-mime": "2.7.3", "svix": "1.84.1" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-GRXjH9XZBJA+daH7bBVDuTShr22iWCxXA8P7t495G4dM/RC+d+3gHBK/6bz9K6Vpcq11zRQKmD+B+jECwQlyGQ=="],
|
||||
|
||||
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
|
@ -907,6 +925,8 @@
|
|||
|
||||
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
|
||||
|
||||
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
|
||||
|
||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||
|
||||
"string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
|
||||
|
|
@ -933,6 +953,8 @@
|
|||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"svix": ["svix@1.84.1", "", { "dependencies": { "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ=="],
|
||||
|
||||
"swr": ["swr@2.4.0", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||
|
|
@ -987,6 +1009,8 @@
|
|||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "./app/env";
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
|
|
|
|||
192
web/package-lock.json
generated
192
web/package-lock.json
generated
|
|
@ -9,13 +9,18 @@
|
|||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@t3-oss/env-nextjs": "^0.13.10",
|
||||
"@vercel/firewall": "^1.1.2",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"posthog-js": "^1.350.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-tweet": "^3.3.0",
|
||||
"react-wrap-balancer": "^1.1.1",
|
||||
"shiki": "^3.22.0"
|
||||
"resend": "^6.9.3",
|
||||
"shiki": "^3.22.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
|
@ -1630,6 +1635,12 @@
|
|||
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stablelib/base64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
|
|
@ -1639,6 +1650,61 @@
|
|||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@t3-oss/env-core": {
|
||||
"version": "0.13.10",
|
||||
"resolved": "https://registry.npmjs.org/@t3-oss/env-core/-/env-core-0.13.10.tgz",
|
||||
"integrity": "sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"arktype": "^2.1.0",
|
||||
"typescript": ">=5.0.0",
|
||||
"valibot": "^1.0.0-beta.7 || ^1.0.0",
|
||||
"zod": "^3.24.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"arktype": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
},
|
||||
"valibot": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@t3-oss/env-nextjs": {
|
||||
"version": "0.13.10",
|
||||
"resolved": "https://registry.npmjs.org/@t3-oss/env-nextjs/-/env-nextjs-0.13.10.tgz",
|
||||
"integrity": "sha512-JfSA2WXOnvcc/uMdp31paMsfbYhhdvLLRxlwvrnlPE9bwM/n0Z+Qb9xRv48nPpvfMhOrkrTYw1I5Yc06WIKBJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@t3-oss/env-core": "0.13.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"arktype": "^2.1.0",
|
||||
"typescript": ">=5.0.0",
|
||||
"valibot": "^1.0.0-beta.7 || ^1.0.0",
|
||||
"zod": "^3.24.0 || ^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"arktype": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
},
|
||||
"valibot": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz",
|
||||
|
|
@ -2559,6 +2625,15 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@vercel/firewall": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/firewall/-/firewall-1.1.2.tgz",
|
||||
"integrity": "sha512-h0sdBVrloWx8TitvWla/rGj3AnJ5JEYfL5LaGHNNOWkyMuzNqfCcGTvJgnjL2A5eSpAAzoN7Xt609YQ0L7xZdw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
|
|
@ -3055,6 +3130,15 @@
|
|||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
|
|
@ -4031,6 +4115,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
|
|
@ -6044,6 +6134,12 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/postal-mime": {
|
||||
"version": "2.7.3",
|
||||
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz",
|
||||
"integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==",
|
||||
"license": "MIT-0"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
|
@ -6225,6 +6321,21 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-tweet": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-tweet/-/react-tweet-3.3.0.tgz",
|
||||
"integrity": "sha512-gSIG2169ZK7UH6rBzuU+j1xnQbH3IlOTLEkuGrRiJJTMgETik+h+26yHyyVKrLkzwrOaYPk4K3OtEKycqKgNLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.3",
|
||||
"clsx": "^2.0.0",
|
||||
"swr": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-wrap-balancer": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-wrap-balancer/-/react-wrap-balancer-1.1.1.tgz",
|
||||
|
|
@ -6302,6 +6413,27 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/resend": {
|
||||
"version": "6.9.3",
|
||||
"resolved": "https://registry.npmjs.org/resend/-/resend-6.9.3.tgz",
|
||||
"integrity": "sha512-GRXjH9XZBJA+daH7bBVDuTShr22iWCxXA8P7t495G4dM/RC+d+3gHBK/6bz9K6Vpcq11zRQKmD+B+jECwQlyGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postal-mime": "2.7.3",
|
||||
"svix": "1.84.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-email/render": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@react-email/render": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
|
|
@ -6695,6 +6827,16 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/standardwebhooks": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
|
|
@ -6908,6 +7050,29 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/svix": {
|
||||
"version": "1.84.1",
|
||||
"resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz",
|
||||
"integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"standardwebhooks": "1.0.0",
|
||||
"uuid": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/swr": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz",
|
||||
"integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz",
|
||||
|
|
@ -7140,7 +7305,7 @@
|
|||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
|
@ -7343,6 +7508,28 @@
|
|||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||
|
|
@ -7515,7 +7702,6 @@
|
|||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@t3-oss/env-nextjs": "^0.13.10",
|
||||
"@vercel/firewall": "^1.1.2",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"posthog-js": "^1.350.0",
|
||||
|
|
@ -17,7 +19,9 @@
|
|||
"react-dom": "19.2.3",
|
||||
"react-tweet": "^3.3.0",
|
||||
"react-wrap-balancer": "^1.1.1",
|
||||
"shiki": "^3.22.0"
|
||||
"resend": "^6.9.3",
|
||||
"shiki": "^3.22.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue