Merge remote-tracking branch 'origin/main' into pr-ssh-stack-main
# Conflicts: # CLI/cmux.swift # Sources/Panels/BrowserPanel.swift # Sources/TabManager.swift # Sources/Workspace.swift # cmuxTests/GhosttyConfigTests.swift
This commit is contained in:
commit
03dc055138
51 changed files with 13485 additions and 628 deletions
6
.github/workflows/ci-macos-compat.yml
vendored
6
.github/workflows/ci-macos-compat.yml
vendored
|
|
@ -70,6 +70,12 @@ jobs:
|
|||
rm GhosttyKit.xcframework.tar.gz
|
||||
test -d GhosttyKit.xcframework
|
||||
|
||||
- name: Install zig
|
||||
run: |
|
||||
if ! command -v zig >/dev/null 2>&1; then
|
||||
brew install zig
|
||||
fi
|
||||
|
||||
- name: Clean DerivedData
|
||||
run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
|
||||
|
||||
|
|
|
|||
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
|
|
@ -100,6 +100,12 @@ jobs:
|
|||
run: |
|
||||
./scripts/download-prebuilt-ghosttykit.sh
|
||||
|
||||
- name: Install zig
|
||||
run: |
|
||||
if ! command -v zig >/dev/null 2>&1; then
|
||||
brew install zig
|
||||
fi
|
||||
|
||||
- name: Clean DerivedData
|
||||
run: |
|
||||
# Remove stale build cache to avoid incremental build errors
|
||||
|
|
@ -175,6 +181,12 @@ jobs:
|
|||
fi
|
||||
fi
|
||||
|
||||
- name: Run bundled Ghostty theme picker helper regression
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CMUX_SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" \
|
||||
./tests/test_bundled_ghostty_theme_picker_helper.sh
|
||||
|
||||
- name: Run CLI version memory guard regression
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
|
@ -224,6 +236,12 @@ jobs:
|
|||
run: |
|
||||
./scripts/download-prebuilt-ghosttykit.sh
|
||||
|
||||
- name: Install zig
|
||||
run: |
|
||||
if ! command -v zig >/dev/null 2>&1; then
|
||||
brew install zig
|
||||
fi
|
||||
|
||||
- name: Clean DerivedData
|
||||
run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
|
||||
|
||||
|
|
|
|||
11
.github/workflows/nightly.yml
vendored
11
.github/workflows/nightly.yml
vendored
|
|
@ -153,6 +153,9 @@ jobs:
|
|||
- name: Install build deps
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
|
||||
run: |
|
||||
if ! command -v zig >/dev/null 2>&1; then
|
||||
brew install zig
|
||||
fi
|
||||
npm install --global "create-dmg@${CREATE_DMG_VERSION}"
|
||||
|
||||
- name: Download pre-built GhosttyKit.xcframework
|
||||
|
|
@ -202,12 +205,16 @@ jobs:
|
|||
set -euo pipefail
|
||||
APP_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/MacOS/cmux"
|
||||
CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux"
|
||||
HELPER_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/ghostty"
|
||||
APP_ARCHS="$(lipo -archs "$APP_BINARY")"
|
||||
CLI_ARCHS="$(lipo -archs "$CLI_BINARY")"
|
||||
HELPER_ARCHS="$(lipo -archs "$HELPER_BINARY")"
|
||||
echo "App binary architectures: $APP_ARCHS"
|
||||
echo "CLI binary architectures: $CLI_ARCHS"
|
||||
echo "Ghostty helper architectures: $HELPER_ARCHS"
|
||||
[[ "$APP_ARCHS" == *arm64* && "$APP_ARCHS" == *x86_64* ]]
|
||||
[[ "$CLI_ARCHS" == *arm64* && "$CLI_ARCHS" == *x86_64* ]]
|
||||
[[ "$HELPER_ARCHS" == *arm64* && "$HELPER_ARCHS" == *x86_64* ]]
|
||||
|
||||
- name: Run CLI version memory guard regression
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
|
||||
|
|
@ -350,9 +357,13 @@ jobs:
|
|||
"build-universal/Build/Products/Release/cmux NIGHTLY.app"
|
||||
do
|
||||
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
|
||||
HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty"
|
||||
if [ -f "$CLI_PATH" ]; then
|
||||
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH"
|
||||
fi
|
||||
if [ -f "$HELPER_PATH" ]; then
|
||||
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$HELPER_PATH"
|
||||
fi
|
||||
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH"
|
||||
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
|
||||
done
|
||||
|
|
|
|||
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
|
|
@ -101,6 +101,9 @@ jobs:
|
|||
- name: Install build deps
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
run: |
|
||||
if ! command -v zig >/dev/null 2>&1; then
|
||||
brew install zig
|
||||
fi
|
||||
npm install --global "create-dmg@${CREATE_DMG_VERSION}"
|
||||
|
||||
- name: Download pre-built GhosttyKit.xcframework
|
||||
|
|
@ -165,6 +168,13 @@ jobs:
|
|||
[ -x "$CLI_BINARY" ] || { echo "cmux CLI binary not found at $CLI_BINARY" >&2; exit 1; }
|
||||
CMUX_CLI_BIN="$CLI_BINARY" python3 tests/test_cli_version_memory_guard.py
|
||||
|
||||
- name: Verify bundled Ghostty theme picker helper
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HELPER_BINARY="build/Build/Products/Release/cmux.app/Contents/Resources/bin/ghostty"
|
||||
[ -x "$HELPER_BINARY" ] || { echo "Ghostty theme picker helper not found at $HELPER_BINARY" >&2; exit 1; }
|
||||
|
||||
- name: Inject Sparkle keys into Info.plist
|
||||
if: steps.guard_release_assets.outputs.skip_all != 'true'
|
||||
run: |
|
||||
|
|
@ -215,9 +225,13 @@ jobs:
|
|||
APP_PATH="build/Build/Products/Release/cmux.app"
|
||||
ENTITLEMENTS="cmux.entitlements"
|
||||
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
|
||||
HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty"
|
||||
if [ -f "$CLI_PATH" ]; then
|
||||
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH"
|
||||
fi
|
||||
if [ -f "$HELPER_PATH" ]; then
|
||||
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$HELPER_PATH"
|
||||
fi
|
||||
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH"
|
||||
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
|
||||
|
||||
|
|
|
|||
6
.github/workflows/test-depot.yml
vendored
6
.github/workflows/test-depot.yml
vendored
|
|
@ -82,6 +82,12 @@ jobs:
|
|||
rm GhosttyKit.xcframework.tar.gz
|
||||
test -d GhosttyKit.xcframework
|
||||
|
||||
- name: Install zig
|
||||
run: |
|
||||
if ! command -v zig >/dev/null 2>&1; then
|
||||
brew install zig
|
||||
fi
|
||||
|
||||
- name: Create virtual display
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
|
|
|||
6
.github/workflows/test-e2e.yml
vendored
6
.github/workflows/test-e2e.yml
vendored
|
|
@ -90,6 +90,12 @@ jobs:
|
|||
rm GhosttyKit.xcframework.tar.gz
|
||||
test -d GhosttyKit.xcframework
|
||||
|
||||
- name: Install zig
|
||||
run: |
|
||||
if ! command -v zig >/dev/null 2>&1; then
|
||||
brew install zig
|
||||
fi
|
||||
|
||||
- name: Create virtual display
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
|
|
|||
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -2,6 +2,28 @@
|
|||
|
||||
All notable changes to cmux are documented here.
|
||||
|
||||
## [0.62.2] - 2026-03-14
|
||||
|
||||
### Added
|
||||
- Configurable sidebar tint color with separate light/dark mode support via Settings and config file (`sidebar-background`, `sidebar-tint-opacity`) ([#1465](https://github.com/manaflow-ai/cmux/pull/1465))
|
||||
- Cmd+P all-surfaces search option ([#1382](https://github.com/manaflow-ai/cmux/pull/1382))
|
||||
- `cmux themes` command with bundled Ghostty themes ([#1334](https://github.com/manaflow-ai/cmux/pull/1334), [#1314](https://github.com/manaflow-ai/cmux/pull/1314))
|
||||
- Sidebar can now shrink to smaller widths ([#1420](https://github.com/manaflow-ai/cmux/pull/1420))
|
||||
- Menu bar visibility setting ([#1330](https://github.com/manaflow-ai/cmux/pull/1330))
|
||||
|
||||
### Changed
|
||||
- CLI Sentry events are now tagged with the app release ([#1408](https://github.com/manaflow-ai/cmux/pull/1408))
|
||||
- Stable socket listener now falls back to a user-scoped path, and repeated startup failures are throttled ([#1351](https://github.com/manaflow-ai/cmux/pull/1351), [#1415](https://github.com/manaflow-ai/cmux/pull/1415))
|
||||
|
||||
### Fixed
|
||||
- Command palette command-mode shortcut, navigation, and omnibar backspace or arrow-key regressions ([#1417](https://github.com/manaflow-ai/cmux/pull/1417), [#1413](https://github.com/manaflow-ai/cmux/pull/1413))
|
||||
- Stale Claude sidebar status from missing hooks, OSC suppression, and PID cleanup ([#1306](https://github.com/manaflow-ai/cmux/pull/1306))
|
||||
- Split cwd inheritance when the shell cwd is stale ([#1403](https://github.com/manaflow-ai/cmux/pull/1403))
|
||||
- Crashes when creating a new workspace and when inserting a workspace into an orphaned window context ([#1391](https://github.com/manaflow-ai/cmux/pull/1391), [#1380](https://github.com/manaflow-ai/cmux/pull/1380))
|
||||
- Cmd+W close behavior and close-confirmation shell-state regressions ([#1395](https://github.com/manaflow-ai/cmux/pull/1395), [#1386](https://github.com/manaflow-ai/cmux/pull/1386))
|
||||
- macOS dictation NSTextInputClient conformance and terminal image-paste fallbacks ([#1410](https://github.com/manaflow-ai/cmux/pull/1410), [#1305](https://github.com/manaflow-ai/cmux/pull/1305), [#1361](https://github.com/manaflow-ai/cmux/pull/1361), [#1358](https://github.com/manaflow-ai/cmux/pull/1358))
|
||||
- VS Code command palette target resolution, Ghostty Pure prompt redraws, and internal drag regressions ([#1389](https://github.com/manaflow-ai/cmux/pull/1389), [#1363](https://github.com/manaflow-ai/cmux/pull/1363), [#1316](https://github.com/manaflow-ai/cmux/pull/1316), [#1379](https://github.com/manaflow-ai/cmux/pull/1379))
|
||||
|
||||
## [0.62.1] - 2026-03-13
|
||||
|
||||
### Added
|
||||
|
|
|
|||
398
CLI/cmux.swift
398
CLI/cmux.swift
|
|
@ -30,6 +30,102 @@ private final class CLISocketSentryTelemetry {
|
|||
private static let startupLock = NSLock()
|
||||
private static var started = false
|
||||
private static let dsn = "https://ecba1ec90ecaee02a102fba931b6d2b3@o4507547940749312.ingest.us.sentry.io/4510796264636416"
|
||||
|
||||
private static func currentSentryReleaseName() -> String? {
|
||||
guard let bundleIdentifier = currentSentryBundleIdentifier(),
|
||||
let version = currentBundleVersionValue(forKey: "CFBundleShortVersionString"),
|
||||
let build = currentBundleVersionValue(forKey: "CFBundleVersion")
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return "\(bundleIdentifier)@\(version)+\(build)"
|
||||
}
|
||||
|
||||
private static func currentSentryBundleIdentifier() -> String? {
|
||||
if let bundleIdentifier = ProcessInfo.processInfo.environment["CMUX_BUNDLE_ID"]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!bundleIdentifier.isEmpty {
|
||||
return bundleIdentifier
|
||||
}
|
||||
|
||||
if let bundleIdentifier = currentSentryBundle()?.bundleIdentifier?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!bundleIdentifier.isEmpty {
|
||||
return bundleIdentifier
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func currentBundleVersionValue(forKey key: String) -> String? {
|
||||
guard let value = currentSentryBundle()?.infoDictionary?[key] as? String else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, !trimmed.contains("$(") else {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private static func currentSentryBundle() -> Bundle? {
|
||||
if Bundle.main.bundleIdentifier?.isEmpty == false {
|
||||
return Bundle.main
|
||||
}
|
||||
|
||||
guard let executableURL = currentExecutableURL() else {
|
||||
return Bundle.main
|
||||
}
|
||||
|
||||
var current = executableURL.deletingLastPathComponent().standardizedFileURL
|
||||
while true {
|
||||
if current.pathExtension == "app", let bundle = Bundle(url: current) {
|
||||
return bundle
|
||||
}
|
||||
|
||||
if current.lastPathComponent == "Contents" {
|
||||
let appURL = current.deletingLastPathComponent().standardizedFileURL
|
||||
if appURL.pathExtension == "app", let bundle = Bundle(url: appURL) {
|
||||
return bundle
|
||||
}
|
||||
}
|
||||
|
||||
guard let parent = parentSearchURL(for: current) else {
|
||||
break
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
|
||||
return Bundle.main
|
||||
}
|
||||
|
||||
private static func currentExecutableURL() -> URL? {
|
||||
var size: UInt32 = 0
|
||||
_ = _NSGetExecutablePath(nil, &size)
|
||||
if size > 0 {
|
||||
var buffer = Array<CChar>(repeating: 0, count: Int(size))
|
||||
if _NSGetExecutablePath(&buffer, &size) == 0 {
|
||||
return URL(fileURLWithPath: String(cString: buffer)).standardizedFileURL
|
||||
}
|
||||
}
|
||||
|
||||
return Bundle.main.executableURL?.standardizedFileURL
|
||||
}
|
||||
|
||||
private static func parentSearchURL(for url: URL) -> URL? {
|
||||
let standardized = url.standardizedFileURL
|
||||
let path = standardized.path
|
||||
guard !path.isEmpty, path != "/" else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let parent = standardized.deletingLastPathComponent().standardizedFileURL
|
||||
guard parent.path != path else {
|
||||
return nil
|
||||
}
|
||||
return parent
|
||||
}
|
||||
#endif
|
||||
|
||||
init(command: String, commandArgs: [String], socketPath: String, processEnv: [String: String]) {
|
||||
|
|
@ -180,6 +276,7 @@ private final class CLISocketSentryTelemetry {
|
|||
guard !started else { return }
|
||||
SentrySDK.start { options in
|
||||
options.dsn = dsn
|
||||
options.releaseName = currentSentryReleaseName()
|
||||
#if DEBUG
|
||||
options.environment = "development-cli"
|
||||
#else
|
||||
|
|
@ -226,6 +323,7 @@ private struct ClaudeHookSessionRecord: Codable {
|
|||
var workspaceId: String
|
||||
var surfaceId: String
|
||||
var cwd: String?
|
||||
var pid: Int?
|
||||
var lastSubtitle: String?
|
||||
var lastBody: String?
|
||||
var startedAt: TimeInterval
|
||||
|
|
@ -273,6 +371,7 @@ private final class ClaudeHookSessionStore {
|
|||
workspaceId: String,
|
||||
surfaceId: String,
|
||||
cwd: String?,
|
||||
pid: Int? = nil,
|
||||
lastSubtitle: String? = nil,
|
||||
lastBody: String? = nil
|
||||
) throws {
|
||||
|
|
@ -285,16 +384,22 @@ private final class ClaudeHookSessionStore {
|
|||
workspaceId: workspaceId,
|
||||
surfaceId: surfaceId,
|
||||
cwd: nil,
|
||||
pid: nil,
|
||||
lastSubtitle: nil,
|
||||
lastBody: nil,
|
||||
startedAt: now,
|
||||
updatedAt: now
|
||||
)
|
||||
record.workspaceId = workspaceId
|
||||
record.surfaceId = surfaceId
|
||||
if !surfaceId.isEmpty {
|
||||
record.surfaceId = surfaceId
|
||||
}
|
||||
if let cwd = normalizeOptional(cwd) {
|
||||
record.cwd = cwd
|
||||
}
|
||||
if let pid {
|
||||
record.pid = pid
|
||||
}
|
||||
if let subtitle = normalizeOptional(lastSubtitle) {
|
||||
record.lastSubtitle = subtitle
|
||||
}
|
||||
|
|
@ -9566,39 +9671,68 @@ struct CMUXCLI {
|
|||
workspaceId: workspaceId,
|
||||
client: client
|
||||
)
|
||||
let claudePid: Int? = {
|
||||
guard let raw = ProcessInfo.processInfo.environment["CMUX_CLAUDE_PID"]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
let pid = Int(raw),
|
||||
pid > 0 else {
|
||||
return nil
|
||||
}
|
||||
return pid
|
||||
}()
|
||||
if let sessionId = parsedInput.sessionId {
|
||||
try? sessionStore.upsert(
|
||||
sessionId: sessionId,
|
||||
workspaceId: workspaceId,
|
||||
surfaceId: surfaceId,
|
||||
cwd: parsedInput.cwd
|
||||
cwd: parsedInput.cwd,
|
||||
pid: claudePid
|
||||
)
|
||||
}
|
||||
// Register PID for stale-session detection and OSC suppression,
|
||||
// but don't set a visible status. "Running" only appears when the
|
||||
// user submits a prompt (UserPromptSubmit) or Claude starts working
|
||||
// (PreToolUse).
|
||||
if let claudePid {
|
||||
_ = try? sendV1Command(
|
||||
"set_agent_pid claude_code \(claudePid) --tab=\(workspaceId)",
|
||||
client: client
|
||||
)
|
||||
}
|
||||
try setClaudeStatus(
|
||||
client: client,
|
||||
workspaceId: workspaceId,
|
||||
value: "Running",
|
||||
icon: "bolt.fill",
|
||||
color: "#4C8DFF"
|
||||
)
|
||||
print("OK")
|
||||
|
||||
case "stop", "idle":
|
||||
telemetry.breadcrumb("claude-hook.stop")
|
||||
let consumedSession = try? sessionStore.consume(
|
||||
sessionId: parsedInput.sessionId,
|
||||
workspaceId: fallbackWorkspaceId,
|
||||
surfaceId: fallbackSurfaceId
|
||||
)
|
||||
let workspaceId = consumedSession?.workspaceId ?? fallbackWorkspaceId
|
||||
try clearClaudeStatus(client: client, workspaceId: workspaceId)
|
||||
// Turn ended. Don't consume session or clear PID — Claude is still alive.
|
||||
// Notification hook handles user-facing notifications; SessionEnd handles cleanup.
|
||||
var workspaceId = fallbackWorkspaceId
|
||||
var surfaceId = surfaceArg
|
||||
if let sessionId = parsedInput.sessionId,
|
||||
let mapped = try? sessionStore.lookup(sessionId: sessionId),
|
||||
let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) {
|
||||
workspaceId = mappedWorkspace
|
||||
surfaceId = mapped.surfaceId
|
||||
}
|
||||
|
||||
if let completion = summarizeClaudeHookStop(
|
||||
// Update session with transcript summary and send completion notification.
|
||||
let completion = summarizeClaudeHookStop(
|
||||
parsedInput: parsedInput,
|
||||
sessionRecord: consumedSession
|
||||
) {
|
||||
let surfaceId = try resolveSurfaceIdForClaudeHook(
|
||||
consumedSession?.surfaceId ?? surfaceArg,
|
||||
sessionRecord: (try? sessionStore.lookup(sessionId: parsedInput.sessionId ?? ""))
|
||||
)
|
||||
if let sessionId = parsedInput.sessionId, let completion {
|
||||
try? sessionStore.upsert(
|
||||
sessionId: sessionId,
|
||||
workspaceId: workspaceId,
|
||||
surfaceId: surfaceId ?? "",
|
||||
cwd: parsedInput.cwd,
|
||||
lastSubtitle: completion.subtitle,
|
||||
lastBody: completion.body
|
||||
)
|
||||
}
|
||||
|
||||
if let completion {
|
||||
let resolvedSurface = try resolveSurfaceIdForClaudeHook(
|
||||
surfaceId,
|
||||
workspaceId: workspaceId,
|
||||
client: client
|
||||
)
|
||||
|
|
@ -9606,12 +9740,18 @@ struct CMUXCLI {
|
|||
let subtitle = sanitizeNotificationField(completion.subtitle)
|
||||
let body = sanitizeNotificationField(completion.body)
|
||||
let payload = "\(title)|\(subtitle)|\(body)"
|
||||
let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)")
|
||||
print(response)
|
||||
} else {
|
||||
print("OK")
|
||||
_ = try? sendV1Command("notify_target \(workspaceId) \(resolvedSurface) \(payload)", client: client)
|
||||
}
|
||||
|
||||
try setClaudeStatus(
|
||||
client: client,
|
||||
workspaceId: workspaceId,
|
||||
value: "Idle",
|
||||
icon: "pause.circle.fill",
|
||||
color: "#8E8E93"
|
||||
)
|
||||
print("OK")
|
||||
|
||||
case "prompt-submit":
|
||||
telemetry.breadcrumb("claude-hook.prompt-submit")
|
||||
var workspaceId = fallbackWorkspaceId
|
||||
|
|
@ -9632,7 +9772,7 @@ struct CMUXCLI {
|
|||
|
||||
case "notification", "notify":
|
||||
telemetry.breadcrumb("claude-hook.notification")
|
||||
let summary = summarizeClaudeHookNotification(rawInput: rawInput)
|
||||
var summary = summarizeClaudeHookNotification(rawInput: rawInput)
|
||||
|
||||
var workspaceId = fallbackWorkspaceId
|
||||
var preferredSurface = surfaceArg
|
||||
|
|
@ -9641,6 +9781,12 @@ struct CMUXCLI {
|
|||
let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) {
|
||||
workspaceId = mappedWorkspace
|
||||
preferredSurface = mapped.surfaceId
|
||||
// If PreToolUse saved a richer message (e.g. from AskUserQuestion),
|
||||
// use it instead of the generic notification text.
|
||||
if let savedBody = mapped.lastBody, !savedBody.isEmpty,
|
||||
summary.body.contains("needs your attention") || summary.body.contains("needs your input") {
|
||||
summary = (subtitle: mapped.lastSubtitle ?? summary.subtitle, body: savedBody)
|
||||
}
|
||||
}
|
||||
|
||||
let surfaceId = try resolveSurfaceIdForClaudeHook(
|
||||
|
|
@ -9675,11 +9821,86 @@ struct CMUXCLI {
|
|||
)
|
||||
print(response)
|
||||
|
||||
case "session-end":
|
||||
telemetry.breadcrumb("claude-hook.session-end")
|
||||
// Final cleanup when Claude process exits.
|
||||
// Only clear when we are the primary cleanup path (Stop didn't fire first).
|
||||
// If Stop already consumed the session, consumedSession is nil and we skip
|
||||
// to avoid wiping the completion notification that Stop just delivered.
|
||||
let consumedSession = try? sessionStore.consume(
|
||||
sessionId: parsedInput.sessionId,
|
||||
workspaceId: fallbackWorkspaceId,
|
||||
surfaceId: fallbackSurfaceId
|
||||
)
|
||||
if let consumedSession {
|
||||
let workspaceId = consumedSession.workspaceId
|
||||
_ = try? clearClaudeStatus(client: client, workspaceId: workspaceId)
|
||||
_ = try? sendV1Command("clear_agent_pid claude_code --tab=\(workspaceId)", client: client)
|
||||
_ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client)
|
||||
}
|
||||
print("OK")
|
||||
|
||||
case "pre-tool-use":
|
||||
telemetry.breadcrumb("claude-hook.pre-tool-use")
|
||||
// Clears "Needs input" status and notification when Claude resumes work
|
||||
// (e.g. after permission grant). Runs async so it doesn't block tool execution.
|
||||
var workspaceId = fallbackWorkspaceId
|
||||
var claudePid: Int? = nil
|
||||
if let sessionId = parsedInput.sessionId,
|
||||
let mapped = try? sessionStore.lookup(sessionId: sessionId),
|
||||
let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) {
|
||||
workspaceId = mappedWorkspace
|
||||
claudePid = mapped.pid
|
||||
}
|
||||
|
||||
// AskUserQuestion means Claude is about to ask the user something.
|
||||
// Save question text in session so the Notification handler can use it
|
||||
// instead of the generic "Claude Code needs your attention".
|
||||
if let toolName = parsedInput.object?["tool_name"] as? String,
|
||||
toolName == "AskUserQuestion",
|
||||
let question = describeAskUserQuestion(parsedInput.object),
|
||||
let sessionId = parsedInput.sessionId {
|
||||
// Preserve the existing surfaceId from SessionStart; passing ""
|
||||
// would overwrite it and cause notifications to target the wrong workspace.
|
||||
let existingSurfaceId = (try? sessionStore.lookup(sessionId: sessionId))?.surfaceId ?? ""
|
||||
try? sessionStore.upsert(
|
||||
sessionId: sessionId,
|
||||
workspaceId: workspaceId,
|
||||
surfaceId: existingSurfaceId,
|
||||
cwd: parsedInput.cwd,
|
||||
lastSubtitle: "Waiting",
|
||||
lastBody: question
|
||||
)
|
||||
// Don't clear notifications or set status here.
|
||||
// The Notification hook fires right after and will use the saved question.
|
||||
print("OK")
|
||||
return
|
||||
}
|
||||
|
||||
_ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client)
|
||||
|
||||
let statusValue: String
|
||||
if UserDefaults.standard.bool(forKey: "claudeCodeVerboseStatus"),
|
||||
let toolStatus = describeToolUse(parsedInput.object) {
|
||||
statusValue = toolStatus
|
||||
} else {
|
||||
statusValue = "Running"
|
||||
}
|
||||
try setClaudeStatus(
|
||||
client: client,
|
||||
workspaceId: workspaceId,
|
||||
value: statusValue,
|
||||
icon: "bolt.fill",
|
||||
color: "#4C8DFF",
|
||||
pid: claudePid
|
||||
)
|
||||
print("OK")
|
||||
|
||||
case "help", "--help", "-h":
|
||||
telemetry.breadcrumb("claude-hook.help")
|
||||
print(
|
||||
"""
|
||||
cmux claude-hook <session-start|stop|notification> [--workspace <id|index>] [--surface <id|index>]
|
||||
cmux claude-hook <session-start|stop|session-end|notification|prompt-submit|pre-tool-use> [--workspace <id|index>] [--surface <id|index>]
|
||||
"""
|
||||
)
|
||||
|
||||
|
|
@ -9693,17 +9914,105 @@ struct CMUXCLI {
|
|||
workspaceId: String,
|
||||
value: String,
|
||||
icon: String,
|
||||
color: String
|
||||
color: String,
|
||||
pid: Int? = nil
|
||||
) throws {
|
||||
_ = try client.send(
|
||||
command: "set_status claude_code \(value) --icon=\(icon) --color=\(color) --tab=\(workspaceId)"
|
||||
)
|
||||
var cmd = "set_status claude_code \(value) --icon=\(icon) --color=\(color) --tab=\(workspaceId)"
|
||||
if let pid {
|
||||
cmd += " --pid=\(pid)"
|
||||
}
|
||||
_ = try client.send(command: cmd)
|
||||
}
|
||||
|
||||
private func clearClaudeStatus(client: SocketClient, workspaceId: String) throws {
|
||||
_ = try client.send(command: "clear_status claude_code --tab=\(workspaceId)")
|
||||
}
|
||||
|
||||
private func describeAskUserQuestion(_ object: [String: Any]?) -> String? {
|
||||
guard let object,
|
||||
let input = object["tool_input"] as? [String: Any],
|
||||
let questions = input["questions"] as? [[String: Any]],
|
||||
let first = questions.first else { return nil }
|
||||
|
||||
var parts: [String] = []
|
||||
|
||||
if let question = first["question"] as? String, !question.isEmpty {
|
||||
parts.append(question)
|
||||
} else if let header = first["header"] as? String, !header.isEmpty {
|
||||
parts.append(header)
|
||||
}
|
||||
|
||||
if let options = first["options"] as? [[String: Any]] {
|
||||
let labels = options.compactMap { $0["label"] as? String }
|
||||
if !labels.isEmpty {
|
||||
parts.append(labels.map { "[\($0)]" }.joined(separator: " "))
|
||||
}
|
||||
}
|
||||
|
||||
if parts.isEmpty { return "Asking a question" }
|
||||
return parts.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func describeToolUse(_ object: [String: Any]?) -> String? {
|
||||
guard let object, let toolName = object["tool_name"] as? String else { return nil }
|
||||
let input = object["tool_input"] as? [String: Any]
|
||||
|
||||
switch toolName {
|
||||
case "Read":
|
||||
if let path = input?["file_path"] as? String {
|
||||
return "Reading \(shortenPath(path))"
|
||||
}
|
||||
return "Reading file"
|
||||
case "Edit":
|
||||
if let path = input?["file_path"] as? String {
|
||||
return "Editing \(shortenPath(path))"
|
||||
}
|
||||
return "Editing file"
|
||||
case "Write":
|
||||
if let path = input?["file_path"] as? String {
|
||||
return "Writing \(shortenPath(path))"
|
||||
}
|
||||
return "Writing file"
|
||||
case "Bash":
|
||||
if let cmd = input?["command"] as? String {
|
||||
let first = cmd.components(separatedBy: .whitespacesAndNewlines).first ?? cmd
|
||||
let short = String(first.prefix(30))
|
||||
return "Running \(short)"
|
||||
}
|
||||
return "Running command"
|
||||
case "Glob":
|
||||
if let pattern = input?["pattern"] as? String {
|
||||
return "Searching \(String(pattern.prefix(30)))"
|
||||
}
|
||||
return "Searching files"
|
||||
case "Grep":
|
||||
if let pattern = input?["pattern"] as? String {
|
||||
return "Grep \(String(pattern.prefix(30)))"
|
||||
}
|
||||
return "Searching code"
|
||||
case "Agent":
|
||||
if let desc = input?["description"] as? String {
|
||||
return String(desc.prefix(40))
|
||||
}
|
||||
return "Subagent"
|
||||
case "WebFetch":
|
||||
return "Fetching URL"
|
||||
case "WebSearch":
|
||||
if let query = input?["query"] as? String {
|
||||
return "Search: \(String(query.prefix(30)))"
|
||||
}
|
||||
return "Web search"
|
||||
default:
|
||||
return toolName
|
||||
}
|
||||
}
|
||||
|
||||
private func shortenPath(_ path: String) -> String {
|
||||
let url = URL(fileURLWithPath: path)
|
||||
let name = url.lastPathComponent
|
||||
return name.isEmpty ? String(path.suffix(30)) : name
|
||||
}
|
||||
|
||||
private func resolveWorkspaceIdForClaudeHook(_ raw: String?, client: SocketClient) throws -> String {
|
||||
if let raw, !raw.isEmpty, let candidate = try? resolveWorkspaceId(raw, client: client) {
|
||||
let probe = try? client.sendV2(method: "surface.list", params: ["workspace_id": candidate])
|
||||
|
|
@ -9904,20 +10213,13 @@ struct CMUXCLI {
|
|||
let signal = signalParts.compactMap { $0 }.joined(separator: " ")
|
||||
var classified = classifyClaudeNotification(signal: signal, message: normalizedMessage)
|
||||
|
||||
if let session, !session.isEmpty {
|
||||
let shortSession = String(session.prefix(8))
|
||||
if !classified.body.contains(shortSession) {
|
||||
classified.body = "\(classified.body) [\(shortSession)]"
|
||||
}
|
||||
}
|
||||
|
||||
classified.body = truncate(classified.body, maxLength: 180)
|
||||
return classified
|
||||
}
|
||||
|
||||
private func classifyClaudeNotification(signal: String, message: String) -> (subtitle: String, body: String) {
|
||||
let lower = "\(signal) \(message)".lowercased()
|
||||
if lower.contains("permission") || lower.contains("approve") || lower.contains("approval") {
|
||||
if lower.contains("permission") || lower.contains("approve") || lower.contains("approval") || lower.contains("permission_prompt") {
|
||||
let body = message.isEmpty ? "Approval needed" : message
|
||||
return ("Permission", body)
|
||||
}
|
||||
|
|
@ -9925,12 +10227,19 @@ struct CMUXCLI {
|
|||
let body = message.isEmpty ? "Claude reported an error" : message
|
||||
return ("Error", body)
|
||||
}
|
||||
if lower.contains("idle") || lower.contains("wait") || lower.contains("input") || lower.contains("prompt") {
|
||||
let body = message.isEmpty ? "Claude is waiting for your input" : message
|
||||
if lower.contains("complet") || lower.contains("finish") || lower.contains("done") || lower.contains("success") {
|
||||
let body = message.isEmpty ? "Task completed" : message
|
||||
return ("Completed", body)
|
||||
}
|
||||
if lower.contains("idle") || lower.contains("wait") || lower.contains("input") || lower.contains("idle_prompt") {
|
||||
let body = message.isEmpty ? "Waiting for input" : message
|
||||
return ("Waiting", body)
|
||||
}
|
||||
let body = message.isEmpty ? "Claude needs your input" : message
|
||||
return ("Attention", body)
|
||||
// Use the message directly if it's meaningful (not a generic placeholder).
|
||||
if !message.isEmpty, message != "Claude needs your input" {
|
||||
return ("Attention", message)
|
||||
}
|
||||
return ("Attention", "Claude needs your attention")
|
||||
}
|
||||
|
||||
private func firstString(in object: [String: Any], keys: [String]) -> String? {
|
||||
|
|
@ -9958,9 +10267,8 @@ struct CMUXCLI {
|
|||
}
|
||||
|
||||
private func sanitizeNotificationField(_ value: String) -> String {
|
||||
let normalized = normalizedSingleLine(value)
|
||||
return normalizedSingleLine(value)
|
||||
.replacingOccurrences(of: "|", with: "¦")
|
||||
return truncate(normalized, maxLength: 180)
|
||||
}
|
||||
|
||||
private func versionSummary() -> String {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; };
|
||||
A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; };
|
||||
A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; };
|
||||
A5007420 /* BrowserPopupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007421 /* BrowserPopupWindowController.swift */; };
|
||||
A5001420 /* MarkdownPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001418 /* MarkdownPanel.swift */; };
|
||||
A5001421 /* MarkdownPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001419 /* MarkdownPanelView.swift */; };
|
||||
A5001290 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A5001291 /* MarkdownUI */; };
|
||||
|
|
@ -83,12 +84,14 @@
|
|||
B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */; };
|
||||
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; };
|
||||
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; };
|
||||
FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */; };
|
||||
E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; };
|
||||
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; };
|
||||
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; };
|
||||
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; };
|
||||
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; };
|
||||
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; };
|
||||
FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; };
|
||||
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; };
|
||||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
|
||||
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; };
|
||||
|
|
@ -180,6 +183,7 @@
|
|||
A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = "<group>"; };
|
||||
A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = "<group>"; };
|
||||
A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = "<group>"; };
|
||||
A5007421 /* BrowserPopupWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPopupWindowController.swift; sourceTree = "<group>"; };
|
||||
A5001415 /* PanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelContentView.swift; sourceTree = "<group>"; };
|
||||
A5001418 /* MarkdownPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanel.swift; sourceTree = "<group>"; };
|
||||
A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -229,12 +233,14 @@
|
|||
B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.swift; sourceTree = "<group>"; };
|
||||
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = "<group>"; };
|
||||
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = "<group>"; };
|
||||
FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = "<group>"; };
|
||||
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = "<group>"; };
|
||||
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = "<group>"; };
|
||||
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
|
||||
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
|
||||
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = "<group>"; };
|
||||
FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = "<group>"; };
|
||||
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; };
|
||||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -330,7 +336,7 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "set -euo pipefail\nDEST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\nGHOSTTY_DEST=\"${DEST}/ghostty\"\nTERMINFO_DEST=\"${DEST}/terminfo\"\nCMUX_SHELL_DEST=\"${DEST}/shell-integration\"\nSRC_SHARE=\"${SRCROOT}/ghostty/zig-out/share\"\nGHOSTTY_SRC=\"${SRC_SHARE}/ghostty\"\nTERMINFO_SRC=\"${SRC_SHARE}/terminfo\"\nFALLBACK_GHOSTTY=\"${SRCROOT}/Resources/ghostty\"\nFALLBACK_TERMINFO=\"${SRCROOT}/Resources/ghostty/terminfo\"\nTERMINFO_OVERLAY=\"${SRCROOT}/Resources/terminfo-overlay\"\nCMUX_SHELL_SRC=\"${SRCROOT}/Resources/shell-integration\"\nCMUX_GHOSTTY_ZSH_SRC=\"${SRCROOT}/ghostty/src/shell-integration/zsh/ghostty-integration\"\nif [ -d \"$GHOSTTY_SRC\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$GHOSTTY_SRC/\" \"$GHOSTTY_DEST/\"\nelif [ -d \"$FALLBACK_GHOSTTY\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$FALLBACK_GHOSTTY/\" \"$GHOSTTY_DEST/\"\nfi\nif [ -d \"$TERMINFO_SRC\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$TERMINFO_SRC/\" \"$TERMINFO_DEST/\"\nelif [ -d \"$FALLBACK_TERMINFO\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$FALLBACK_TERMINFO/\" \"$TERMINFO_DEST/\"\nfi\n# Overlay any cmux-specific terminfo adjustments.\n# This intentionally does not use --delete so we only patch specific entries.\nif [ -d \"$TERMINFO_OVERLAY\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a \"$TERMINFO_OVERLAY/\" \"$TERMINFO_DEST/\"\nfi\nif [ -d \"$CMUX_SHELL_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n # Use '/.' so dotfiles like .zshenv/.zprofile are copied too.\n rsync -a \"$CMUX_SHELL_SRC/.\" \"$CMUX_SHELL_DEST/\"\nfi\nif [ -f \"$CMUX_GHOSTTY_ZSH_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n rsync -a \"$CMUX_GHOSTTY_ZSH_SRC\" \"$CMUX_SHELL_DEST/ghostty-integration.zsh\"\nfi\nINFO_PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\nCOMMIT=\"$(git -C \"${SRCROOT}\" rev-parse --short=9 HEAD 2>/dev/null || true)\"\nif [ -n \"$COMMIT\" ] && [ -f \"$INFO_PLIST\" ]; then\n /usr/libexec/PlistBuddy -c \"Set :CMUXCommit $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || /usr/libexec/PlistBuddy -c \"Add :CMUXCommit string $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || true\nfi\n";
|
||||
shellScript = "set -euo pipefail\nDEST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\nGHOSTTY_DEST=\"${DEST}/ghostty\"\nTERMINFO_DEST=\"${DEST}/terminfo\"\nCMUX_SHELL_DEST=\"${DEST}/shell-integration\"\nBIN_DEST=\"${DEST}/bin\"\nSRC_SHARE=\"${SRCROOT}/ghostty/zig-out/share\"\nGHOSTTY_SRC=\"${SRC_SHARE}/ghostty\"\nTERMINFO_SRC=\"${SRC_SHARE}/terminfo\"\nFALLBACK_GHOSTTY=\"${SRCROOT}/Resources/ghostty\"\nFALLBACK_TERMINFO=\"${SRCROOT}/Resources/ghostty/terminfo\"\nTERMINFO_OVERLAY=\"${SRCROOT}/Resources/terminfo-overlay\"\nCMUX_SHELL_SRC=\"${SRCROOT}/Resources/shell-integration\"\nCMUX_GHOSTTY_ZSH_SRC=\"${SRCROOT}/ghostty/src/shell-integration/zsh/ghostty-integration\"\nBUILD_GHOSTTY_HELPER=\"${SRCROOT}/scripts/build-ghostty-cli-helper.sh\"\nGHOSTTY_HELPER_DEST=\"${BIN_DEST}/ghostty\"\nif [ -d \"$GHOSTTY_SRC\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$GHOSTTY_SRC/\" \"$GHOSTTY_DEST/\"\nelif [ -d \"$FALLBACK_GHOSTTY\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$FALLBACK_GHOSTTY/\" \"$GHOSTTY_DEST/\"\nfi\nif [ -d \"$TERMINFO_SRC\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$TERMINFO_SRC/\" \"$TERMINFO_DEST/\"\nelif [ -d \"$FALLBACK_TERMINFO\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$FALLBACK_TERMINFO/\" \"$TERMINFO_DEST/\"\nfi\n# Overlay any cmux-specific terminfo adjustments.\n# This intentionally does not use --delete so we only patch specific entries.\nif [ -d \"$TERMINFO_OVERLAY\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a \"$TERMINFO_OVERLAY/\" \"$TERMINFO_DEST/\"\nfi\nif [ -d \"$CMUX_SHELL_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n # Use '/.' so dotfiles like .zshenv/.zprofile are copied too.\n rsync -a \"$CMUX_SHELL_SRC/.\" \"$CMUX_SHELL_DEST/\"\nfi\nif [ -f \"$CMUX_GHOSTTY_ZSH_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n rsync -a \"$CMUX_GHOSTTY_ZSH_SRC\" \"$CMUX_SHELL_DEST/ghostty-integration.zsh\"\nfi\nif [ ! -x \"$BUILD_GHOSTTY_HELPER\" ]; then\n echo \"error: missing Ghostty CLI helper build script at $BUILD_GHOSTTY_HELPER\" >&2\n exit 1\nfi\nARCHS_LIST=\" ${ARCHS:-} \"\nHAS_ARM64=0\nHAS_X86_64=0\nGHOSTTY_HELPER_TARGET=\"\"\ncase \"$ARCHS_LIST\" in\n *\" arm64 \"*) HAS_ARM64=1 ;;\nesac\ncase \"$ARCHS_LIST\" in\n *\" x86_64 \"*) HAS_X86_64=1 ;;\nesac\nif [ \"$HAS_ARM64\" -eq 1 ] && [ \"$HAS_X86_64\" -eq 1 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --universal --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"aarch64-macos\"\nelif [ \"$HAS_X86_64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"x86_64-macos\"\nfi\nif [ -n \"$GHOSTTY_HELPER_TARGET\" ]; then\n \"$BUILD_GHOSTTY_HELPER\" --target \"$GHOSTTY_HELPER_TARGET\" --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 0 ] || [ \"$HAS_X86_64\" -eq 0 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --output \"$GHOSTTY_HELPER_DEST\"\nfi\nif [ ! -x \"$GHOSTTY_HELPER_DEST\" ]; then\n echo \"error: Ghostty CLI helper was not created at $GHOSTTY_HELPER_DEST\" >&2\n exit 1\nfi\nINFO_PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\nCOMMIT=\"$(git -C \"${SRCROOT}\" rev-parse --short=9 HEAD 2>/dev/null || true)\"\nif [ -n \"$COMMIT\" ] && [ -f \"$INFO_PLIST\" ]; then\n /usr/libexec/PlistBuddy -c \"Set :CMUXCommit $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || /usr/libexec/PlistBuddy -c \"Add :CMUXCommit string $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || true\nfi\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
|
|
@ -387,6 +393,7 @@
|
|||
A5001412 /* BrowserPanel.swift */,
|
||||
A5001413 /* TerminalPanelView.swift */,
|
||||
A5001414 /* BrowserPanelView.swift */,
|
||||
A5007421 /* BrowserPopupWindowController.swift */,
|
||||
A5001418 /* MarkdownPanel.swift */,
|
||||
A5001419 /* MarkdownPanelView.swift */,
|
||||
A5001510 /* CmuxWebView.swift */,
|
||||
|
|
@ -456,6 +463,7 @@
|
|||
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */,
|
||||
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */,
|
||||
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */,
|
||||
FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */,
|
||||
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */,
|
||||
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */,
|
||||
);
|
||||
|
|
@ -470,6 +478,7 @@
|
|||
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
|
||||
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
|
||||
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */,
|
||||
FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */,
|
||||
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */,
|
||||
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
|
||||
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
|
||||
|
|
@ -659,6 +668,7 @@
|
|||
A5001402 /* BrowserPanel.swift in Sources */,
|
||||
A5001403 /* TerminalPanelView.swift in Sources */,
|
||||
A5001404 /* BrowserPanelView.swift in Sources */,
|
||||
A5007420 /* BrowserPopupWindowController.swift in Sources */,
|
||||
A5001420 /* MarkdownPanel.swift in Sources */,
|
||||
A5001421 /* MarkdownPanelView.swift in Sources */,
|
||||
A5001500 /* CmuxWebView.swift in Sources */,
|
||||
|
|
@ -696,6 +706,7 @@
|
|||
B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */,
|
||||
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */,
|
||||
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */,
|
||||
FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */,
|
||||
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */,
|
||||
E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */,
|
||||
);
|
||||
|
|
@ -710,6 +721,7 @@
|
|||
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
|
||||
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
|
||||
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */,
|
||||
FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */,
|
||||
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */,
|
||||
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
|
||||
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */,
|
||||
|
|
@ -818,7 +830,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 76;
|
||||
CURRENT_PROJECT_VERSION = 77;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_HARDENED_RUNTIME = NO;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
|
|
@ -827,7 +839,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.62.1;
|
||||
MARKETING_VERSION = 0.62.2;
|
||||
OTHER_LDFLAGS = (
|
||||
"-lc++",
|
||||
"-framework",
|
||||
|
|
@ -857,7 +869,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 76;
|
||||
CURRENT_PROJECT_VERSION = 77;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_HARDENED_RUNTIME = NO;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
|
|
@ -866,7 +878,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.62.1;
|
||||
MARKETING_VERSION = 0.62.2;
|
||||
OTHER_LDFLAGS = (
|
||||
"-lc++",
|
||||
"-framework",
|
||||
|
|
@ -933,10 +945,10 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 76;
|
||||
CURRENT_PROJECT_VERSION = 77;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.62.1;
|
||||
MARKETING_VERSION = 0.62.2;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -950,10 +962,10 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 76;
|
||||
CURRENT_PROJECT_VERSION = 77;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.62.1;
|
||||
MARKETING_VERSION = 0.62.2;
|
||||
ONLY_ACTIVE_ARCH = NO;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -967,10 +979,10 @@
|
|||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 76;
|
||||
CURRENT_PROJECT_VERSION = 77;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.62.1;
|
||||
MARKETING_VERSION = 0.62.2;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -986,10 +998,10 @@
|
|||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 76;
|
||||
CURRENT_PROJECT_VERSION = 77;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
MARKETING_VERSION = 0.62.1;
|
||||
MARKETING_VERSION = 0.62.2;
|
||||
ONLY_ACTIVE_ARCH = NO;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
|
|||
|
|
@ -12,6 +12,21 @@
|
|||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>Folder</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Alternate</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>public.folder</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -76,9 +76,17 @@ for arg in "$@"; do
|
|||
esac
|
||||
done
|
||||
|
||||
# Export the wrapper's PID. Because we exec claude below, this PID becomes
|
||||
# the actual claude process PID, which hooks use for stale-session detection.
|
||||
export CMUX_CLAUDE_PID=$$
|
||||
|
||||
# Build hooks settings JSON.
|
||||
# Claude Code merges --settings additively with the user's own settings.json.
|
||||
HOOKS_JSON='{"hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook stop","timeout":10}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook notification","timeout":10}]}]}}'
|
||||
# - SessionStart/Stop/Notification: existing lifecycle hooks
|
||||
# - SessionEnd: cleanup when Claude exits (covers Ctrl+C where Stop doesn't fire)
|
||||
# - UserPromptSubmit: clears "Needs input" and sets "Running" on new prompt
|
||||
# - PreToolUse: clears "Needs input" when Claude resumes after permission grant (async to avoid latency)
|
||||
HOOKS_JSON='{"hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook stop","timeout":10}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook pre-tool-use","timeout":5,"async":true}]}]}}'
|
||||
|
||||
if [[ "$SKIP_SESSION_ID" == true ]]; then
|
||||
exec "$REAL_CLAUDE" --settings "$HOOKS_JSON" "$@"
|
||||
|
|
|
|||
|
|
@ -145,6 +145,40 @@ _cmux_pr_output_indicates_no_pull_request() {
|
|||
|| "$output" == *"no pull request associated"* ]]
|
||||
}
|
||||
|
||||
_cmux_github_repo_slug_for_path() {
|
||||
local repo_path="$1"
|
||||
local remote_url="" path_part=""
|
||||
[[ -n "$repo_path" ]] || return 0
|
||||
|
||||
remote_url="$(git -C "$repo_path" remote get-url origin 2>/dev/null)"
|
||||
[[ -n "$remote_url" ]] || return 0
|
||||
|
||||
case "$remote_url" in
|
||||
git@github.com:*)
|
||||
path_part="${remote_url#git@github.com:}"
|
||||
;;
|
||||
ssh://git@github.com/*)
|
||||
path_part="${remote_url#ssh://git@github.com/}"
|
||||
;;
|
||||
https://github.com/*)
|
||||
path_part="${remote_url#https://github.com/}"
|
||||
;;
|
||||
http://github.com/*)
|
||||
path_part="${remote_url#http://github.com/}"
|
||||
;;
|
||||
git://github.com/*)
|
||||
path_part="${remote_url#git://github.com/}"
|
||||
;;
|
||||
*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
path_part="${path_part%.git}"
|
||||
[[ "$path_part" == */* ]] || return 0
|
||||
printf '%s\n' "$path_part"
|
||||
}
|
||||
|
||||
_cmux_report_pr_for_path() {
|
||||
local repo_path="$1"
|
||||
[[ -n "$repo_path" ]] || {
|
||||
|
|
@ -159,18 +193,26 @@ _cmux_report_pr_for_path() {
|
|||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
|
||||
local branch gh_output gh_error="" err_file="" gh_status number state url status_opt=""
|
||||
local branch repo_slug="" gh_output="" gh_error="" err_file="" gh_status number state url status_opt=""
|
||||
local explicit_branch_output="" explicit_branch_error="" explicit_branch_status=0
|
||||
local implicit_probe_indicates_no_pr=0 explicit_probe_indicates_no_pr=0
|
||||
local -a gh_repo_args=()
|
||||
branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)"
|
||||
if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
|
||||
_cmux_clear_pr_for_panel
|
||||
return 0
|
||||
fi
|
||||
repo_slug="$(_cmux_github_repo_slug_for_path "$repo_path")"
|
||||
if [[ -n "$repo_slug" ]]; then
|
||||
gh_repo_args=(--repo "$repo_slug")
|
||||
fi
|
||||
|
||||
err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)"
|
||||
[[ -n "$err_file" ]] || return 1
|
||||
gh_output="$(
|
||||
builtin cd "$repo_path" 2>/dev/null \
|
||||
&& gh pr view \
|
||||
"${gh_repo_args[@]}" \
|
||||
--json number,state,url \
|
||||
--jq '[.number, .state, .url] | @tsv' \
|
||||
2>"$err_file"
|
||||
|
|
@ -180,18 +222,54 @@ _cmux_report_pr_for_path() {
|
|||
gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)"
|
||||
/bin/rm -f -- "$err_file" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if (( gh_status != 0 )); then
|
||||
if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then
|
||||
_cmux_clear_pr_for_panel
|
||||
return 0
|
||||
|
||||
if (( gh_status == 0 )) && [[ -n "$gh_output" ]]; then
|
||||
:
|
||||
else
|
||||
if (( gh_status == 0 )) && [[ -z "$gh_output" ]]; then
|
||||
implicit_probe_indicates_no_pr=1
|
||||
elif _cmux_pr_output_indicates_no_pull_request "$gh_error"; then
|
||||
implicit_probe_indicates_no_pr=1
|
||||
fi
|
||||
|
||||
# `gh pr view` without an explicit branch can fail to resolve the
|
||||
# current worktree branch even when the branch has a PR. Fall back to
|
||||
# the explicit branch name before concluding there is no PR.
|
||||
err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)"
|
||||
[[ -n "$err_file" ]] || return 1
|
||||
explicit_branch_output="$(
|
||||
builtin cd "$repo_path" 2>/dev/null \
|
||||
&& gh pr view "$branch" \
|
||||
"${gh_repo_args[@]}" \
|
||||
--json number,state,url \
|
||||
--jq '[.number, .state, .url] | @tsv' \
|
||||
2>"$err_file"
|
||||
)"
|
||||
explicit_branch_status=$?
|
||||
if [[ -f "$err_file" ]]; then
|
||||
explicit_branch_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)"
|
||||
/bin/rm -f -- "$err_file" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if (( explicit_branch_status == 0 )) && [[ -n "$explicit_branch_output" ]]; then
|
||||
gh_output="$explicit_branch_output"
|
||||
gh_status=0
|
||||
else
|
||||
if (( explicit_branch_status == 0 )) && [[ -z "$explicit_branch_output" ]]; then
|
||||
explicit_probe_indicates_no_pr=1
|
||||
elif _cmux_pr_output_indicates_no_pull_request "$explicit_branch_error"; then
|
||||
explicit_probe_indicates_no_pr=1
|
||||
fi
|
||||
|
||||
if (( implicit_probe_indicates_no_pr )) && (( explicit_probe_indicates_no_pr )); then
|
||||
_cmux_clear_pr_for_panel
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Preserve the last-known PR badge when gh fails transiently, then retry
|
||||
# on the next background poll instead of clearing visible state.
|
||||
return 1
|
||||
fi
|
||||
# Preserve the last-known PR badge when gh fails transiently, then retry
|
||||
# on the next background poll instead of clearing visible state.
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$gh_output" ]]; then
|
||||
_cmux_clear_pr_for_panel
|
||||
return 0
|
||||
fi
|
||||
|
||||
IFS=$'\t' read -r number state url <<< "$gh_output"
|
||||
|
|
@ -263,11 +341,9 @@ _cmux_run_pr_probe_with_timeout() {
|
|||
|
||||
_cmux_stop_pr_poll_loop() {
|
||||
if [[ -n "$_CMUX_PR_POLL_PID" ]]; then
|
||||
_cmux_kill_process_tree "$_CMUX_PR_POLL_PID" TERM
|
||||
sleep 0.1
|
||||
if kill -0 "$_CMUX_PR_POLL_PID" >/dev/null 2>&1; then
|
||||
_cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL
|
||||
fi
|
||||
# Use SIGKILL directly to avoid blocking sleep in preexec.
|
||||
# The poll loop is lightweight and safe to kill abruptly.
|
||||
_cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL
|
||||
_CMUX_PR_POLL_PID=""
|
||||
fi
|
||||
}
|
||||
|
|
@ -378,11 +454,18 @@ _cmux_prompt_command() {
|
|||
if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then
|
||||
local head_signature
|
||||
head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)"
|
||||
if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
|
||||
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
|
||||
git_head_changed=1
|
||||
# Also invalidate the PR poller so it refreshes with the new branch.
|
||||
_CMUX_PR_FORCE=1
|
||||
if [[ -n "$head_signature" ]]; then
|
||||
if [[ -z "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
|
||||
# The first observed HEAD value is just the session baseline.
|
||||
# Treating it as a branch change clears restore-seeded PR badges
|
||||
# before the first background probe can confirm the current PR.
|
||||
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
|
||||
elif [[ "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
|
||||
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
|
||||
git_head_changed=1
|
||||
# Also invalidate the PR poller so it refreshes with the new branch.
|
||||
_CMUX_PR_FORCE=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -263,6 +263,40 @@ _cmux_pr_output_indicates_no_pull_request() {
|
|||
|| "$output" == *"no pull request associated"* ]]
|
||||
}
|
||||
|
||||
_cmux_github_repo_slug_for_path() {
|
||||
local repo_path="$1"
|
||||
local remote_url="" path_part=""
|
||||
[[ -n "$repo_path" ]] || return 0
|
||||
|
||||
remote_url="$(git -C "$repo_path" remote get-url origin 2>/dev/null)"
|
||||
[[ -n "$remote_url" ]] || return 0
|
||||
|
||||
case "$remote_url" in
|
||||
git@github.com:*)
|
||||
path_part="${remote_url#git@github.com:}"
|
||||
;;
|
||||
ssh://git@github.com/*)
|
||||
path_part="${remote_url#ssh://git@github.com/}"
|
||||
;;
|
||||
https://github.com/*)
|
||||
path_part="${remote_url#https://github.com/}"
|
||||
;;
|
||||
http://github.com/*)
|
||||
path_part="${remote_url#http://github.com/}"
|
||||
;;
|
||||
git://github.com/*)
|
||||
path_part="${remote_url#git://github.com/}"
|
||||
;;
|
||||
*)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
path_part="${path_part%.git}"
|
||||
[[ "$path_part" == */* ]] || return 0
|
||||
print -r -- "$path_part"
|
||||
}
|
||||
|
||||
_cmux_report_pr_for_path() {
|
||||
local repo_path="$1"
|
||||
[[ -n "$repo_path" ]] || {
|
||||
|
|
@ -277,18 +311,27 @@ _cmux_report_pr_for_path() {
|
|||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
|
||||
local branch gh_output gh_error="" err_file="" number state url status_opt="" gh_status
|
||||
local branch repo_slug="" gh_output="" gh_error="" err_file="" number state url status_opt="" gh_status
|
||||
local explicit_branch_output="" explicit_branch_error="" explicit_branch_status=0
|
||||
local implicit_probe_indicates_no_pr=0 explicit_probe_indicates_no_pr=0
|
||||
local -a gh_repo_args
|
||||
gh_repo_args=()
|
||||
branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)"
|
||||
if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
|
||||
_cmux_clear_pr_for_panel
|
||||
return 0
|
||||
fi
|
||||
repo_slug="$(_cmux_github_repo_slug_for_path "$repo_path")"
|
||||
if [[ -n "$repo_slug" ]]; then
|
||||
gh_repo_args=(--repo "$repo_slug")
|
||||
fi
|
||||
|
||||
err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)"
|
||||
[[ -n "$err_file" ]] || return 1
|
||||
gh_output="$(
|
||||
builtin cd "$repo_path" 2>/dev/null \
|
||||
&& gh pr view \
|
||||
"${gh_repo_args[@]}" \
|
||||
--json number,state,url \
|
||||
--jq '[.number, .state, .url] | @tsv' \
|
||||
2>"$err_file"
|
||||
|
|
@ -298,18 +341,54 @@ _cmux_report_pr_for_path() {
|
|||
gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)"
|
||||
/bin/rm -f -- "$err_file" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if (( gh_status != 0 )); then
|
||||
if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then
|
||||
_cmux_clear_pr_for_panel
|
||||
return 0
|
||||
|
||||
if (( gh_status == 0 )) && [[ -n "$gh_output" ]]; then
|
||||
:
|
||||
else
|
||||
if (( gh_status == 0 )) && [[ -z "$gh_output" ]]; then
|
||||
implicit_probe_indicates_no_pr=1
|
||||
elif _cmux_pr_output_indicates_no_pull_request "$gh_error"; then
|
||||
implicit_probe_indicates_no_pr=1
|
||||
fi
|
||||
|
||||
# `gh pr view` without an explicit branch can fail to resolve the
|
||||
# current worktree branch even when the branch has a PR. Fall back to
|
||||
# the explicit branch name before concluding there is no PR.
|
||||
err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)"
|
||||
[[ -n "$err_file" ]] || return 1
|
||||
explicit_branch_output="$(
|
||||
builtin cd "$repo_path" 2>/dev/null \
|
||||
&& gh pr view "$branch" \
|
||||
"${gh_repo_args[@]}" \
|
||||
--json number,state,url \
|
||||
--jq '[.number, .state, .url] | @tsv' \
|
||||
2>"$err_file"
|
||||
)"
|
||||
explicit_branch_status=$?
|
||||
if [[ -f "$err_file" ]]; then
|
||||
explicit_branch_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)"
|
||||
/bin/rm -f -- "$err_file" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if (( explicit_branch_status == 0 )) && [[ -n "$explicit_branch_output" ]]; then
|
||||
gh_output="$explicit_branch_output"
|
||||
gh_status=0
|
||||
else
|
||||
if (( explicit_branch_status == 0 )) && [[ -z "$explicit_branch_output" ]]; then
|
||||
explicit_probe_indicates_no_pr=1
|
||||
elif _cmux_pr_output_indicates_no_pull_request "$explicit_branch_error"; then
|
||||
explicit_probe_indicates_no_pr=1
|
||||
fi
|
||||
|
||||
if (( implicit_probe_indicates_no_pr )) && (( explicit_probe_indicates_no_pr )); then
|
||||
_cmux_clear_pr_for_panel
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Keep the last-known PR badge on transient gh failures (auth hiccups,
|
||||
# API lag after creation, or rate limiting) and retry on the next poll.
|
||||
return 1
|
||||
fi
|
||||
# Keep the last-known PR badge on transient gh failures (auth hiccups,
|
||||
# API lag after creation, or rate limiting) and retry on the next poll.
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "$gh_output" ]]; then
|
||||
_cmux_clear_pr_for_panel
|
||||
return 0
|
||||
fi
|
||||
|
||||
local IFS=$'\t'
|
||||
|
|
@ -382,11 +461,9 @@ _cmux_run_pr_probe_with_timeout() {
|
|||
|
||||
_cmux_stop_pr_poll_loop() {
|
||||
if [[ -n "$_CMUX_PR_POLL_PID" ]]; then
|
||||
_cmux_kill_process_tree "$_CMUX_PR_POLL_PID" TERM
|
||||
sleep 0.1
|
||||
if kill -0 "$_CMUX_PR_POLL_PID" >/dev/null 2>&1; then
|
||||
_cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL
|
||||
fi
|
||||
# Use SIGKILL directly to avoid blocking sleep in preexec.
|
||||
# The poll loop is lightweight and safe to kill abruptly.
|
||||
_cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL
|
||||
_CMUX_PR_POLL_PID=""
|
||||
fi
|
||||
}
|
||||
|
|
@ -554,14 +631,21 @@ _cmux_precmd() {
|
|||
if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then
|
||||
local head_signature
|
||||
head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)"
|
||||
if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
|
||||
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
|
||||
git_head_changed=1
|
||||
# Treat HEAD file change like a git command — force-replace any
|
||||
# running probe so the sidebar picks up the new branch immediately.
|
||||
_CMUX_GIT_FORCE=1
|
||||
_CMUX_PR_FORCE=1
|
||||
should_git=1
|
||||
if [[ -n "$head_signature" ]]; then
|
||||
if [[ -z "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
|
||||
# The first observed HEAD value establishes the baseline for this
|
||||
# shell session. Don't treat it as a branch change or we'll clear
|
||||
# restore-seeded PR badges before the first background probe runs.
|
||||
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
|
||||
elif [[ "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
|
||||
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
|
||||
git_head_changed=1
|
||||
# Treat HEAD file change like a git command — force-replace any
|
||||
# running probe so the sidebar picks up the new branch immediately.
|
||||
_CMUX_GIT_FORCE=1
|
||||
_CMUX_PR_FORCE=1
|
||||
should_git=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -368,13 +368,24 @@ enum FinderServicePathResolver {
|
|||
return canonical
|
||||
}
|
||||
|
||||
private static func resolvedDirectoryURL(from url: URL) -> URL {
|
||||
let standardized = url.standardizedFileURL
|
||||
if standardized.hasDirectoryPath {
|
||||
return standardized
|
||||
}
|
||||
if let resourceValues = try? standardized.resourceValues(forKeys: [.isDirectoryKey]),
|
||||
resourceValues.isDirectory == true {
|
||||
return standardized
|
||||
}
|
||||
return standardized.deletingLastPathComponent()
|
||||
}
|
||||
|
||||
static func orderedUniqueDirectories(from pathURLs: [URL]) -> [String] {
|
||||
var seen: Set<String> = []
|
||||
var directories: [String] = []
|
||||
|
||||
for url in pathURLs {
|
||||
let standardized = url.standardizedFileURL
|
||||
let directoryURL = standardized.hasDirectoryPath ? standardized : standardized.deletingLastPathComponent()
|
||||
let directoryURL = resolvedDirectoryURL(from: url)
|
||||
let path = canonicalDirectoryPath(directoryURL.path(percentEncoded: false))
|
||||
guard !path.isEmpty else { continue }
|
||||
if seen.insert(path).inserted {
|
||||
|
|
@ -1696,6 +1707,32 @@ func shouldRouteTerminalFontZoomShortcutToGhostty(
|
|||
) != nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func startOrFocusTerminalSearch(
|
||||
_ terminalSurface: TerminalSurface,
|
||||
searchFocusNotifier: @escaping (TerminalSurface) -> Void = {
|
||||
NotificationCenter.default.post(name: .ghosttySearchFocus, object: $0)
|
||||
}
|
||||
) -> Bool {
|
||||
if terminalSurface.searchState != nil {
|
||||
searchFocusNotifier(terminalSurface)
|
||||
return true
|
||||
}
|
||||
|
||||
if terminalSurface.performBindingAction("start_search") {
|
||||
DispatchQueue.main.async { [weak terminalSurface] in
|
||||
guard let terminalSurface, terminalSurface.searchState == nil else { return }
|
||||
terminalSurface.searchState = TerminalSurface.SearchState()
|
||||
searchFocusNotifier(terminalSurface)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
terminalSurface.searchState = TerminalSurface.SearchState()
|
||||
searchFocusNotifier(terminalSurface)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Let AppKit own native Cmd+` window cycling so key-window changes do not
|
||||
/// re-enter our direct-to-menu shortcut path.
|
||||
func shouldRouteCommandEquivalentDirectlyToMainMenu(_ event: NSEvent) -> Bool {
|
||||
|
|
@ -2128,6 +2165,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
Self.shared = self
|
||||
}
|
||||
|
||||
func application(_ application: NSApplication, open urls: [URL]) {
|
||||
let directories = externalOpenDirectories(from: urls)
|
||||
guard !directories.isEmpty else { return }
|
||||
|
||||
prepareForExplicitOpenIntentAtStartup()
|
||||
for directory in directories {
|
||||
openWorkspaceForExternalDirectory(
|
||||
workingDirectory: directory,
|
||||
debugSource: "application.openURLs"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
let isRunningUnderXCTest = isRunningUnderXCTest(env)
|
||||
|
|
@ -2221,8 +2271,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
configureUserNotifications()
|
||||
installMenuBarVisibilityObserver()
|
||||
syncMenuBarExtraVisibility()
|
||||
// Sparkle updater is started lazily on first manual check. This avoids any
|
||||
// first-launch permission prompts and keeps cmux aligned with the update pill UI.
|
||||
updateController.startUpdaterIfNeeded()
|
||||
}
|
||||
titlebarAccessoryController.start()
|
||||
windowDecorationsController.start()
|
||||
|
|
@ -2337,7 +2386,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
stopSocketListenerHealthMonitor()
|
||||
TerminalController.shared.stop()
|
||||
VSCodeServeWebController.shared.stop()
|
||||
BrowserHistoryStore.shared.flushPendingSaves()
|
||||
BrowserProfileStore.shared.flushPendingSaves()
|
||||
if TelemetrySettings.enabledForCurrentLaunch {
|
||||
PostHogAnalytics.shared.flush()
|
||||
}
|
||||
|
|
@ -5052,11 +5101,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
target: ServiceOpenTarget,
|
||||
error: AutoreleasingUnsafeMutablePointer<NSString>
|
||||
) {
|
||||
didHandleExplicitOpenIntentAtStartup = true
|
||||
if !didAttemptStartupSessionRestore {
|
||||
startupSessionSnapshot = nil
|
||||
didAttemptStartupSessionRestore = true
|
||||
}
|
||||
prepareForExplicitOpenIntentAtStartup()
|
||||
|
||||
let pathURLs = servicePathURLs(from: pasteboard)
|
||||
guard !pathURLs.isEmpty else {
|
||||
|
|
@ -5064,7 +5109,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return
|
||||
}
|
||||
|
||||
let directories = FinderServicePathResolver.orderedUniqueDirectories(from: pathURLs)
|
||||
let directories = externalOpenDirectories(from: pathURLs)
|
||||
guard !directories.isEmpty else {
|
||||
error.pointee = Self.serviceErrorNoPath
|
||||
return
|
||||
|
|
@ -5109,10 +5154,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
|
||||
private func openWorkspaceFromService(workingDirectory: String) {
|
||||
openWorkspaceForExternalDirectory(
|
||||
workingDirectory: workingDirectory,
|
||||
debugSource: "service.openTab"
|
||||
)
|
||||
}
|
||||
|
||||
private func prepareForExplicitOpenIntentAtStartup() {
|
||||
didHandleExplicitOpenIntentAtStartup = true
|
||||
if !didAttemptStartupSessionRestore {
|
||||
startupSessionSnapshot = nil
|
||||
didAttemptStartupSessionRestore = true
|
||||
}
|
||||
}
|
||||
|
||||
private func externalOpenDirectories(from urls: [URL]) -> [String] {
|
||||
FinderServicePathResolver.orderedUniqueDirectories(from: urls.filter { $0.isFileURL })
|
||||
}
|
||||
|
||||
private func openWorkspaceForExternalDirectory(
|
||||
workingDirectory: String,
|
||||
debugSource: String
|
||||
) {
|
||||
if addWorkspaceInPreferredMainWindow(
|
||||
workingDirectory: workingDirectory,
|
||||
shouldBringToFront: true,
|
||||
debugSource: "service.openTab"
|
||||
debugSource: debugSource
|
||||
) != nil {
|
||||
return
|
||||
}
|
||||
|
|
@ -7902,11 +7969,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
let commandPaletteResponderActiveInTargetWindow = commandPaletteTargetWindow.map {
|
||||
isCommandPaletteResponderActive(in: $0)
|
||||
} ?? false
|
||||
let commandPaletteEffectiveInTargetWindow =
|
||||
let commandPaletteInteractiveInTargetWindow =
|
||||
commandPaletteVisibleInTargetWindow
|
||||
|| commandPalettePendingOpenInTargetWindow
|
||||
|| commandPaletteOverlayVisibleInTargetWindow
|
||||
|| commandPaletteResponderActiveInTargetWindow
|
||||
let commandPaletteEffectiveInTargetWindow =
|
||||
commandPaletteInteractiveInTargetWindow
|
||||
|| commandPalettePendingOpenInTargetWindow
|
||||
|
||||
if normalizedFlags.isEmpty, event.keyCode == 53 {
|
||||
let activePaletteWindow = activeCommandPaletteWindow()
|
||||
|
|
@ -7995,7 +8064,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
chars: chars,
|
||||
keyCode: event.keyCode
|
||||
),
|
||||
commandPaletteVisibleInTargetWindow,
|
||||
commandPaletteInteractiveInTargetWindow,
|
||||
let paletteWindow = commandPaletteShortcutWindow {
|
||||
NotificationCenter.default.post(
|
||||
name: .commandPaletteMoveSelection,
|
||||
|
|
@ -8005,7 +8074,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return true
|
||||
}
|
||||
|
||||
if commandPaletteVisibleInTargetWindow,
|
||||
if commandPaletteInteractiveInTargetWindow,
|
||||
let paletteWindow = commandPaletteShortcutWindow {
|
||||
let paletteFieldEditorHasMarkedText = commandPaletteFieldEditorHasMarkedText(in: paletteWindow)
|
||||
if normalizedFlags.isEmpty, event.keyCode == 53 {
|
||||
|
|
@ -8050,6 +8119,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
// Scope the omnibar check to the shortcut's routed window context so a
|
||||
// focused omnibar in another window does not suppress Cmd+P here.
|
||||
let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil
|
||||
let isCommandShiftP = matchShortcut(
|
||||
event: event,
|
||||
shortcut: StoredShortcut(key: "p", command: true, shift: true, option: false, control: false)
|
||||
)
|
||||
if isCommandShiftP {
|
||||
let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
||||
requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.cmdShiftP")
|
||||
return true
|
||||
}
|
||||
|
||||
let isCommandP = !hasFocusedAddressBarInShortcutContext
|
||||
&& matchShortcut(
|
||||
event: event,
|
||||
|
|
@ -8061,16 +8140,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return true
|
||||
}
|
||||
|
||||
let isCommandShiftP = matchShortcut(
|
||||
event: event,
|
||||
shortcut: StoredShortcut(key: "p", command: true, shift: true, option: false, control: false)
|
||||
)
|
||||
if isCommandShiftP {
|
||||
let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
||||
requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.cmdShiftP")
|
||||
return true
|
||||
}
|
||||
|
||||
if shouldConsumeShortcutWhileCommandPaletteVisible(
|
||||
isCommandPaletteVisible: commandPaletteEffectiveInTargetWindow,
|
||||
normalizedFlags: normalizedFlags,
|
||||
|
|
@ -8348,8 +8417,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
event: event,
|
||||
shortcut: StoredShortcut(key: "w", command: true, shift: false, option: false, control: false)
|
||||
) {
|
||||
if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow,
|
||||
targetWindow.identifier?.rawValue == "cmux.settings" {
|
||||
// Browser popup windows primarily intercept Cmd+W in BrowserPopupPanel.
|
||||
// This AppDelegate path is a fallback for cases where AppKit routes the
|
||||
// event through the global shortcut handler first.
|
||||
if let targetWindow = [NSApp.keyWindow, event.window]
|
||||
.compactMap({ $0 })
|
||||
.first(where: { $0.identifier?.rawValue == "cmux.browser-popup" }) {
|
||||
#if DEBUG
|
||||
dlog("shortcut.cmdW route=browserPopup")
|
||||
#endif
|
||||
targetWindow.performClose(nil)
|
||||
return true
|
||||
} else if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow,
|
||||
cmuxWindowShouldOwnCloseShortcut(targetWindow) {
|
||||
targetWindow.performClose(nil)
|
||||
} else {
|
||||
let responder = event.window?.firstResponder
|
||||
|
|
@ -8781,7 +8861,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
@discardableResult
|
||||
func openBrowserAndFocusAddressBar(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? {
|
||||
guard let panelId = tabManager?.openBrowser(url: url, insertAtEnd: insertAtEnd) else {
|
||||
let preferredProfileID =
|
||||
tabManager?.focusedBrowserPanel?.profileID
|
||||
?? tabManager?.selectedWorkspace?.preferredBrowserProfileID
|
||||
guard let panelId = tabManager?.openBrowser(
|
||||
url: url,
|
||||
preferredProfileID: preferredProfileID,
|
||||
insertAtEnd: insertAtEnd
|
||||
) else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.openAndFocus result=open_failed insertAtEnd=\(insertAtEnd ? 1 : 0) " +
|
||||
|
|
@ -9528,6 +9615,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
case ".": return 47 // kVK_ANSI_Period
|
||||
case "`": return 50 // kVK_ANSI_Grave
|
||||
case "\r": return 36 // kVK_Return
|
||||
case "←": return 123 // kVK_LeftArrow
|
||||
case "→": return 124 // kVK_RightArrow
|
||||
case "↓": return 125 // kVK_DownArrow
|
||||
case "↑": return 126 // kVK_UpArrow
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -29,6 +29,13 @@ struct GhosttyConfig {
|
|||
var selectionBackground: NSColor = NSColor(hex: "#57584f")!
|
||||
var selectionForeground: NSColor = NSColor(hex: "#fdfff1")!
|
||||
|
||||
// Sidebar appearance
|
||||
var rawSidebarBackground: String?
|
||||
var sidebarBackground: NSColor?
|
||||
var sidebarBackgroundLight: NSColor?
|
||||
var sidebarBackgroundDark: NSColor?
|
||||
var sidebarTintOpacity: Double?
|
||||
|
||||
// Palette colors (0-15)
|
||||
var palette: [Int: NSColor] = [:]
|
||||
|
||||
|
|
@ -134,6 +141,54 @@ struct GhosttyConfig {
|
|||
return []
|
||||
}
|
||||
|
||||
mutating func resolveSidebarBackground(preferredColorScheme: ColorSchemePreference) {
|
||||
guard let raw = rawSidebarBackground else { return }
|
||||
|
||||
let lightResolved = Self.resolveThemeName(from: raw, preferredColorScheme: .light)
|
||||
let darkResolved = Self.resolveThemeName(from: raw, preferredColorScheme: .dark)
|
||||
let hasDualMode = lightResolved != darkResolved
|
||||
|
||||
if hasDualMode {
|
||||
sidebarBackgroundLight = NSColor(hex: lightResolved)
|
||||
sidebarBackgroundDark = NSColor(hex: darkResolved)
|
||||
}
|
||||
|
||||
let resolved = Self.resolveThemeName(from: raw, preferredColorScheme: preferredColorScheme)
|
||||
if let color = NSColor(hex: resolved) {
|
||||
sidebarBackground = color
|
||||
}
|
||||
}
|
||||
|
||||
func applySidebarAppearanceToUserDefaults() {
|
||||
guard rawSidebarBackground != nil else {
|
||||
if let opacity = sidebarTintOpacity {
|
||||
UserDefaults.standard.set(opacity, forKey: "sidebarTintOpacity")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
if let light = sidebarBackgroundLight {
|
||||
defaults.set(light.hexString(), forKey: "sidebarTintHexLight")
|
||||
} else {
|
||||
defaults.removeObject(forKey: "sidebarTintHexLight")
|
||||
}
|
||||
if let dark = sidebarBackgroundDark {
|
||||
defaults.set(dark.hexString(), forKey: "sidebarTintHexDark")
|
||||
} else {
|
||||
defaults.removeObject(forKey: "sidebarTintHexDark")
|
||||
}
|
||||
if let color = sidebarBackground {
|
||||
defaults.set(color.hexString(), forKey: "sidebarTintHex")
|
||||
} else {
|
||||
defaults.removeObject(forKey: "sidebarTintHex")
|
||||
}
|
||||
if let opacity = sidebarTintOpacity {
|
||||
defaults.set(opacity, forKey: "sidebarTintOpacity")
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadFromDisk(preferredColorScheme: ColorSchemePreference) -> GhosttyConfig {
|
||||
var config = GhosttyConfig()
|
||||
|
||||
|
|
@ -161,6 +216,9 @@ struct GhosttyConfig {
|
|||
)
|
||||
}
|
||||
|
||||
config.resolveSidebarBackground(preferredColorScheme: preferredColorScheme)
|
||||
config.applySidebarAppearanceToUserDefaults()
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
|
|
@ -240,6 +298,12 @@ struct GhosttyConfig {
|
|||
if let color = NSColor(hex: value) {
|
||||
splitDividerColor = color
|
||||
}
|
||||
case "sidebar-background":
|
||||
rawSidebarBackground = value
|
||||
case "sidebar-tint-opacity":
|
||||
if let opacity = Double(value) {
|
||||
sidebarTintOpacity = min(max(opacity, 0), 1)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1923,7 +1923,15 @@ class GhosttyApp {
|
|||
let tabId = tabManager.selectedTabId else {
|
||||
return false
|
||||
}
|
||||
let tabTitle = tabManager.titleForTab(tabId) ?? "Terminal"
|
||||
// Suppress OSC notifications for workspaces with active Claude hook sessions.
|
||||
// The hook system manages notifications with proper lifecycle tracking;
|
||||
// raw OSC notifications would duplicate or outlive the structured hooks.
|
||||
let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? tabManager
|
||||
if let workspace = owningManager.tabs.first(where: { $0.id == tabId }),
|
||||
workspace.agentPIDs["claude_code"] != nil {
|
||||
return true
|
||||
}
|
||||
let tabTitle = owningManager.titleForTab(tabId) ?? "Terminal"
|
||||
let command = actionTitle.isEmpty ? tabTitle : actionTitle
|
||||
let body = actionBody
|
||||
let surfaceId = tabManager.focusedSurfaceId(for: tabId)
|
||||
|
|
@ -2195,7 +2203,13 @@ class GhosttyApp {
|
|||
let actionBody = action.action.desktop_notification.body
|
||||
.flatMap { String(cString: $0) } ?? ""
|
||||
performOnMain {
|
||||
let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal"
|
||||
// Suppress OSC notifications for workspaces with active Claude hook sessions.
|
||||
let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? AppDelegate.shared?.tabManager
|
||||
if let workspace = owningManager?.tabs.first(where: { $0.id == tabId }),
|
||||
workspace.agentPIDs["claude_code"] != nil {
|
||||
return
|
||||
}
|
||||
let tabTitle = owningManager?.titleForTab(tabId) ?? "Terminal"
|
||||
let command = actionTitle.isEmpty ? tabTitle : actionTitle
|
||||
let body = actionBody
|
||||
TerminalNotificationStore.shared.addNotification(
|
||||
|
|
@ -3872,6 +3886,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
source: "surface.viewDidMoveToWindow"
|
||||
)
|
||||
applyWindowBackgroundIfActive()
|
||||
invalidateTextInputCoordinates()
|
||||
}
|
||||
|
||||
override func viewDidChangeEffectiveAppearance() {
|
||||
|
|
@ -3903,11 +3918,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
CATransaction.commit()
|
||||
}
|
||||
updateSurfaceSize()
|
||||
invalidateTextInputCoordinates()
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
updateSurfaceSize()
|
||||
invalidateTextInputCoordinates()
|
||||
}
|
||||
|
||||
override var isOpaque: Bool { false }
|
||||
|
|
@ -4456,16 +4473,49 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
}
|
||||
|
||||
override func accessibilitySelectedText() -> String? {
|
||||
guard let surface = surface else { return nil }
|
||||
guard let snapshot = readSelectionSnapshot() else { return nil }
|
||||
return snapshot.string.isEmpty ? nil : snapshot.string
|
||||
}
|
||||
|
||||
private func readSelectionSnapshot() -> SelectionSnapshot? {
|
||||
guard let surface else { return nil }
|
||||
|
||||
var text = ghostty_text_s()
|
||||
guard ghostty_surface_read_selection(surface, &text) else { return nil }
|
||||
defer { ghostty_surface_free_text(surface, &text) }
|
||||
|
||||
guard let ptr = text.text, text.text_len > 0 else { return nil }
|
||||
let selectedData = Data(bytes: ptr, count: Int(text.text_len))
|
||||
let selected = String(decoding: selectedData, as: UTF8.self)
|
||||
return selected.isEmpty ? nil : selected
|
||||
let selected: String
|
||||
if let ptr = text.text, text.text_len > 0 {
|
||||
let selectedData = Data(bytes: ptr, count: Int(text.text_len))
|
||||
selected = String(decoding: selectedData, as: UTF8.self)
|
||||
} else {
|
||||
selected = ""
|
||||
}
|
||||
|
||||
return SelectionSnapshot(
|
||||
range: NSRange(location: Int(text.offset_start), length: Int(text.offset_len)),
|
||||
string: selected,
|
||||
topLeft: CGPoint(x: text.tl_px_x, y: text.tl_px_y)
|
||||
)
|
||||
}
|
||||
|
||||
private func visibleDocumentRectInScreenCoordinates() -> NSRect {
|
||||
let localRect = visibleRect
|
||||
let windowRect = convert(localRect, to: nil)
|
||||
guard let window else { return windowRect }
|
||||
return window.convertToScreen(windowRect)
|
||||
}
|
||||
|
||||
private func invalidateTextInputCoordinates(selectionChanged: Bool = false) {
|
||||
guard let inputContext else { return }
|
||||
inputContext.invalidateCharacterCoordinates()
|
||||
guard selectionChanged else { return }
|
||||
|
||||
// `textInputClientDidUpdateSelection` is absent from the Xcode 16.2 AppKit SDK
|
||||
// used by the macOS 14 compatibility lane, so call it dynamically when present.
|
||||
let updateSelectionSelector = NSSelectorFromString("textInputClientDidUpdateSelection")
|
||||
guard inputContext.responds(to: updateSelectionSelector) else { return }
|
||||
_ = inputContext.perform(updateSelectionSelector)
|
||||
}
|
||||
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
|
|
@ -4560,6 +4610,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
private var keyTextAccumulator: [String]? = nil
|
||||
private var markedText = NSMutableAttributedString()
|
||||
private var lastPerformKeyEvent: TimeInterval?
|
||||
private struct SelectionSnapshot {
|
||||
let range: NSRange
|
||||
let string: String
|
||||
let topLeft: CGPoint
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// Test-only accessors for keyTextAccumulator to verify CJK IME composition behavior.
|
||||
|
|
@ -5913,7 +5968,9 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
private let keyboardCopyModeBadgeIconView: NSImageView
|
||||
private let keyboardCopyModeBadgeLabel: NSTextField
|
||||
private var searchOverlayHostingView: NSHostingView<SurfaceSearchOverlay>?
|
||||
private var deferredSearchOverlayMutationWorkItem: DispatchWorkItem?
|
||||
private var lastSearchOverlayStateID: ObjectIdentifier?
|
||||
private var searchOverlayMutationGeneration: UInt64 = 0
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
private var windowObservers: [NSObjectProtocol] = []
|
||||
private var isLiveScrolling = false
|
||||
|
|
@ -6300,6 +6357,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
#endif
|
||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
windowObservers.forEach { NotificationCenter.default.removeObserver($0) }
|
||||
deferredSearchOverlayMutationWorkItem?.cancel()
|
||||
dropZoneOverlayView.removeFromSuperview()
|
||||
cancelFocusRequest()
|
||||
}
|
||||
|
|
@ -6386,6 +6444,9 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
}
|
||||
_ = setFrameIfNeeded(notificationRingOverlayView, to: bounds)
|
||||
_ = setFrameIfNeeded(flashOverlayView, to: bounds)
|
||||
if let overlay = searchOverlayHostingView {
|
||||
_ = setFrameIfNeeded(overlay, to: bounds)
|
||||
}
|
||||
updateNotificationRingPath()
|
||||
updateFlashPath(style: .standardFocus)
|
||||
synchronizeScrollView()
|
||||
|
|
@ -6625,50 +6686,42 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
CATransaction.commit()
|
||||
}
|
||||
|
||||
func setSearchOverlay(searchState: TerminalSurface.SearchState?) {
|
||||
if !Thread.isMainThread {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.setSearchOverlay(searchState: searchState)
|
||||
}
|
||||
return
|
||||
private func cancelDeferredSearchOverlayMutation() {
|
||||
deferredSearchOverlayMutationWorkItem?.cancel()
|
||||
deferredSearchOverlayMutationWorkItem = nil
|
||||
}
|
||||
|
||||
private func scheduleDeferredSearchOverlayMutation(
|
||||
generation: UInt64,
|
||||
_ mutation: @escaping () -> Void
|
||||
) {
|
||||
cancelDeferredSearchOverlayMutation()
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
guard self.searchOverlayMutationGeneration == generation else { return }
|
||||
self.deferredSearchOverlayMutationWorkItem = nil
|
||||
mutation()
|
||||
}
|
||||
deferredSearchOverlayMutationWorkItem = work
|
||||
DispatchQueue.main.async(execute: work)
|
||||
}
|
||||
|
||||
// Layering contract: keep terminal Cmd+F UI inside this portal-hosted AppKit view.
|
||||
// SwiftUI panel-level overlays can fall behind portal-hosted terminal surfaces.
|
||||
guard let terminalSurface = surfaceView.terminalSurface,
|
||||
let searchState else {
|
||||
let hadOverlay = searchOverlayHostingView != nil
|
||||
lastSearchOverlayStateID = nil
|
||||
guard hadOverlay else { return }
|
||||
#if DEBUG
|
||||
dlog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)")
|
||||
#endif
|
||||
searchOverlayHostingView?.removeFromSuperview()
|
||||
searchOverlayHostingView = nil
|
||||
searchFocusTarget = .searchField
|
||||
return
|
||||
private func updateKeyboardCopyModeBadgeZOrder(relativeTo overlay: NSView?) {
|
||||
guard !keyboardCopyModeBadgeContainerView.isHidden else { return }
|
||||
if let overlay, overlay.superview === self {
|
||||
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay)
|
||||
} else {
|
||||
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: nil)
|
||||
}
|
||||
}
|
||||
|
||||
let searchStateID = ObjectIdentifier(searchState)
|
||||
if let overlay = searchOverlayHostingView,
|
||||
lastSearchOverlayStateID == searchStateID,
|
||||
overlay.superview === self {
|
||||
if !keyboardCopyModeBadgeContainerView.isHidden {
|
||||
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let hadOverlay = searchOverlayHostingView != nil
|
||||
#if DEBUG
|
||||
dlog("find.setSearchOverlay MOUNT surface=\(terminalSurface.id.uuidString.prefix(5)) existingOverlay=\(hadOverlay ? "yes(update)" : "no(create)")")
|
||||
#endif
|
||||
|
||||
let tabId = terminalSurface.tabId
|
||||
let surfaceId = terminalSurface.id
|
||||
let rootView = SurfaceSearchOverlay(
|
||||
tabId: tabId,
|
||||
surfaceId: surfaceId,
|
||||
private func makeSearchOverlayRootView(
|
||||
terminalSurface: TerminalSurface,
|
||||
searchState: TerminalSurface.SearchState
|
||||
) -> SurfaceSearchOverlay {
|
||||
SurfaceSearchOverlay(
|
||||
tabId: terminalSurface.tabId,
|
||||
surfaceId: terminalSurface.id,
|
||||
searchState: searchState,
|
||||
onMoveFocusToTerminal: { [weak self] in
|
||||
self?.searchFocusTarget = .terminal
|
||||
|
|
@ -6686,41 +6739,165 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
self?.moveFocus()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func findEditableSearchField(in view: NSView?) -> NSTextField? {
|
||||
guard let view else { return nil }
|
||||
if let field = view as? NSTextField, field.isEditable {
|
||||
return field
|
||||
}
|
||||
for subview in view.subviews {
|
||||
if let field = findEditableSearchField(in: subview) {
|
||||
return field
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func requestMountedSearchFieldFocus(
|
||||
generation: UInt64,
|
||||
force: Bool,
|
||||
attemptsRemaining: Int = 4
|
||||
) {
|
||||
guard searchOverlayMutationGeneration == generation else { return }
|
||||
guard force || searchFocusTarget == .searchField else { return }
|
||||
guard let overlay = searchOverlayHostingView,
|
||||
overlay.superview === self,
|
||||
let window,
|
||||
window.isKeyWindow else { return }
|
||||
|
||||
guard let field = findEditableSearchField(in: overlay) else {
|
||||
guard attemptsRemaining > 0 else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in
|
||||
self?.requestMountedSearchFieldFocus(
|
||||
generation: generation,
|
||||
force: force,
|
||||
attemptsRemaining: attemptsRemaining - 1
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let firstResponder = window.firstResponder
|
||||
let alreadyFocused = firstResponder === field ||
|
||||
field.currentEditor() != nil ||
|
||||
((firstResponder as? NSTextView)?.delegate as? NSTextField) === field
|
||||
guard !alreadyFocused else { return }
|
||||
|
||||
surfaceView.terminalSurface?.setFocus(false)
|
||||
let result = window.makeFirstResponder(field)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"find.mountedFieldFocus surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"result=\(result ? 1 : 0) attemptsRemaining=\(attemptsRemaining) " +
|
||||
"firstResponder=\(String(describing: window.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
guard !result, attemptsRemaining > 0 else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in
|
||||
self?.requestMountedSearchFieldFocus(
|
||||
generation: generation,
|
||||
force: force,
|
||||
attemptsRemaining: attemptsRemaining - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func setSearchOverlay(searchState: TerminalSurface.SearchState?) {
|
||||
if !Thread.isMainThread {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.setSearchOverlay(searchState: searchState)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
searchOverlayMutationGeneration &+= 1
|
||||
let mutationGeneration = searchOverlayMutationGeneration
|
||||
|
||||
// Layering contract: keep terminal Cmd+F UI inside this portal-hosted AppKit view.
|
||||
// SwiftUI panel-level overlays can fall behind portal-hosted terminal surfaces.
|
||||
guard let terminalSurface = surfaceView.terminalSurface,
|
||||
let searchState else {
|
||||
let hadOverlay = searchOverlayHostingView != nil
|
||||
lastSearchOverlayStateID = nil
|
||||
searchFocusTarget = .searchField
|
||||
guard hadOverlay else {
|
||||
cancelDeferredSearchOverlayMutation()
|
||||
return
|
||||
}
|
||||
#if DEBUG
|
||||
dlog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)")
|
||||
#endif
|
||||
scheduleDeferredSearchOverlayMutation(generation: mutationGeneration) { [weak self] in
|
||||
self?.searchOverlayHostingView?.removeFromSuperview()
|
||||
self?.searchOverlayHostingView = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let searchStateID = ObjectIdentifier(searchState)
|
||||
if let overlay = searchOverlayHostingView,
|
||||
lastSearchOverlayStateID == searchStateID,
|
||||
overlay.superview === self {
|
||||
cancelDeferredSearchOverlayMutation()
|
||||
_ = setFrameIfNeeded(overlay, to: bounds)
|
||||
updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay)
|
||||
return
|
||||
}
|
||||
|
||||
let hadOverlay = searchOverlayHostingView != nil
|
||||
#if DEBUG
|
||||
dlog("find.setSearchOverlay MOUNT surface=\(terminalSurface.id.uuidString.prefix(5)) existingOverlay=\(hadOverlay ? "yes(update)" : "no(create)")")
|
||||
#endif
|
||||
|
||||
let rootView = makeSearchOverlayRootView(
|
||||
terminalSurface: terminalSurface,
|
||||
searchState: searchState
|
||||
)
|
||||
|
||||
if let overlay = searchOverlayHostingView {
|
||||
overlay.rootView = rootView
|
||||
if overlay.superview !== self {
|
||||
overlay.removeFromSuperview()
|
||||
addSubview(overlay)
|
||||
NSLayoutConstraint.activate([
|
||||
overlay.topAnchor.constraint(equalTo: topAnchor),
|
||||
overlay.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
overlay.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
}
|
||||
if !keyboardCopyModeBadgeContainerView.isHidden {
|
||||
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay)
|
||||
}
|
||||
lastSearchOverlayStateID = searchStateID
|
||||
if overlay.superview !== self {
|
||||
scheduleDeferredSearchOverlayMutation(generation: mutationGeneration) { [weak self, weak overlay] in
|
||||
guard let self, let overlay else { return }
|
||||
overlay.removeFromSuperview()
|
||||
overlay.frame = self.bounds
|
||||
overlay.autoresizingMask = [.width, .height]
|
||||
self.addSubview(overlay)
|
||||
self.updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay)
|
||||
self.requestMountedSearchFieldFocus(
|
||||
generation: mutationGeneration,
|
||||
force: false
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
cancelDeferredSearchOverlayMutation()
|
||||
_ = setFrameIfNeeded(overlay, to: bounds)
|
||||
updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay)
|
||||
return
|
||||
}
|
||||
|
||||
searchFocusTarget = .searchField
|
||||
let overlay = NSHostingView(rootView: rootView)
|
||||
overlay.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(overlay)
|
||||
NSLayoutConstraint.activate([
|
||||
overlay.topAnchor.constraint(equalTo: topAnchor),
|
||||
overlay.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
overlay.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
if !keyboardCopyModeBadgeContainerView.isHidden {
|
||||
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay)
|
||||
}
|
||||
overlay.frame = bounds
|
||||
overlay.autoresizingMask = [.width, .height]
|
||||
searchOverlayHostingView = overlay
|
||||
lastSearchOverlayStateID = searchStateID
|
||||
scheduleDeferredSearchOverlayMutation(generation: mutationGeneration) { [weak self, weak overlay] in
|
||||
guard let self, let overlay else { return }
|
||||
guard self.searchOverlayHostingView === overlay else { return }
|
||||
overlay.removeFromSuperview()
|
||||
overlay.frame = self.bounds
|
||||
overlay.autoresizingMask = [.width, .height]
|
||||
self.addSubview(overlay)
|
||||
self.updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay)
|
||||
self.requestMountedSearchFieldFocus(
|
||||
generation: mutationGeneration,
|
||||
force: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func syncKeyStateIndicator(text: String?) {
|
||||
|
|
@ -6739,11 +6916,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
|| subviews.last !== keyboardCopyModeBadgeContainerView
|
||||
keyboardCopyModeBadgeContainerView.isHidden = false
|
||||
if needsReorder {
|
||||
if let overlay = searchOverlayHostingView {
|
||||
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay)
|
||||
} else {
|
||||
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: nil)
|
||||
}
|
||||
updateKeyboardCopyModeBadgeZOrder(relativeTo: searchOverlayHostingView)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -7463,6 +7636,17 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
|
||||
switch searchFocusTarget {
|
||||
case .searchField:
|
||||
if let firstResponder = window.firstResponder,
|
||||
isCurrentSurfaceSearchFieldResponder(firstResponder) {
|
||||
surfaceView.terminalSurface?.setFocus(false)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " +
|
||||
"reason=alreadyFocused firstResponder=\(String(describing: firstResponder))"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
if let firstResponder = window.firstResponder,
|
||||
isSearchOverlayOrDescendant(firstResponder),
|
||||
!isCurrentSurfaceSearchResponder(firstResponder) {
|
||||
|
|
@ -7666,6 +7850,17 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
return view.isDescendant(of: self)
|
||||
}
|
||||
|
||||
private func isCurrentSurfaceSearchFieldResponder(_ responder: NSResponder) -> Bool {
|
||||
if let editor = responder as? NSTextView,
|
||||
editor.isFieldEditor,
|
||||
let editedView = editor.delegate as? NSTextField {
|
||||
return editedView.isDescendant(of: self) && isSearchOverlayOrDescendant(editedView)
|
||||
}
|
||||
|
||||
guard let textField = responder as? NSTextField else { return false }
|
||||
return textField.isDescendant(of: self) && isSearchOverlayOrDescendant(textField)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct DebugRenderStats {
|
||||
let drawCount: Int
|
||||
|
|
@ -8121,7 +8316,7 @@ extension GhosttyNSView: NSTextInputClient {
|
|||
}
|
||||
|
||||
func selectedRange() -> NSRange {
|
||||
return NSRange(location: NSNotFound, length: 0)
|
||||
readSelectionSnapshot()?.range ?? NSRange(location: 0, length: 0)
|
||||
}
|
||||
|
||||
func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
|
||||
|
|
@ -8149,6 +8344,7 @@ extension GhosttyNSView: NSTextInputClient {
|
|||
// while composing.
|
||||
if keyTextAccumulator == nil {
|
||||
syncPreedit()
|
||||
invalidateTextInputCoordinates(selectionChanged: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -8167,6 +8363,7 @@ extension GhosttyNSView: NSTextInputClient {
|
|||
if markedText.length > 0 {
|
||||
markedText.mutableString.setString("")
|
||||
syncPreedit()
|
||||
invalidateTextInputCoordinates(selectionChanged: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -8207,11 +8404,14 @@ extension GhosttyNSView: NSTextInputClient {
|
|||
}
|
||||
|
||||
func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
|
||||
return nil
|
||||
guard range.length > 0,
|
||||
let snapshot = readSelectionSnapshot() else { return nil }
|
||||
actualRange?.pointee = snapshot.range
|
||||
return NSAttributedString(string: snapshot.string)
|
||||
}
|
||||
|
||||
func characterIndex(for point: NSPoint) -> Int {
|
||||
return 0
|
||||
return selectedRange().location
|
||||
}
|
||||
|
||||
func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
|
||||
|
|
@ -8225,7 +8425,12 @@ extension GhosttyNSView: NSTextInputClient {
|
|||
var w: Double = cellSize.width
|
||||
var h: Double = cellSize.height
|
||||
#if DEBUG
|
||||
if let override = imePointOverrideForTesting {
|
||||
if range.length > 0,
|
||||
range != selectedRange(),
|
||||
let snapshot = readSelectionSnapshot() {
|
||||
x = snapshot.topLeft.x - 2
|
||||
y = snapshot.topLeft.y + 2
|
||||
} else if let override = imePointOverrideForTesting {
|
||||
x = override.x
|
||||
y = override.y
|
||||
w = override.width
|
||||
|
|
@ -8234,11 +8439,21 @@ extension GhosttyNSView: NSTextInputClient {
|
|||
ghostty_surface_ime_point(surface, &x, &y, &w, &h)
|
||||
}
|
||||
#else
|
||||
if let surface = surface {
|
||||
if range.length > 0,
|
||||
range != selectedRange(),
|
||||
let snapshot = readSelectionSnapshot() {
|
||||
x = snapshot.topLeft.x - 2
|
||||
y = snapshot.topLeft.y + 2
|
||||
} else if let surface = surface {
|
||||
ghostty_surface_ime_point(surface, &x, &y, &w, &h)
|
||||
}
|
||||
#endif
|
||||
|
||||
if range.length == 0, w > 0 {
|
||||
// Dictation expects a caret rect for insertion points rather than a box.
|
||||
w = 0
|
||||
}
|
||||
|
||||
// Ghostty coordinates are top-left origin; AppKit expects bottom-left.
|
||||
let viewRect = NSRect(
|
||||
x: x,
|
||||
|
|
@ -8250,6 +8465,30 @@ extension GhosttyNSView: NSTextInputClient {
|
|||
return window.convertToScreen(winRect)
|
||||
}
|
||||
|
||||
func attributedString() -> NSAttributedString {
|
||||
if markedText.length > 0 {
|
||||
return NSAttributedString(attributedString: markedText)
|
||||
}
|
||||
if let snapshot = readSelectionSnapshot(), !snapshot.string.isEmpty {
|
||||
return NSAttributedString(string: snapshot.string)
|
||||
}
|
||||
return NSAttributedString(string: "")
|
||||
}
|
||||
|
||||
func windowLevel() -> Int {
|
||||
Int(window?.level.rawValue ?? NSWindow.Level.normal.rawValue)
|
||||
}
|
||||
|
||||
@available(macOS 14.0, *)
|
||||
var unionRectInVisibleSelectedRange: NSRect {
|
||||
firstRect(forCharacterRange: selectedRange(), actualRange: nil)
|
||||
}
|
||||
|
||||
@available(macOS 14.0, *)
|
||||
var documentVisibleRect: NSRect {
|
||||
visibleDocumentRectInScreenCoordinates()
|
||||
}
|
||||
|
||||
func insertText(_ string: Any, replacementRange: NSRange) {
|
||||
#if DEBUG
|
||||
let typingTimingStart = CmuxTypingTiming.start()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -203,9 +203,39 @@ func resolvedBrowserOmnibarPillBackgroundColor(
|
|||
return themeBackgroundColor.blended(withFraction: darkenMix, of: .black) ?? themeBackgroundColor
|
||||
}
|
||||
|
||||
private struct BrowserChromeStyle {
|
||||
let backgroundColor: NSColor
|
||||
let colorScheme: ColorScheme
|
||||
let omnibarPillBackgroundColor: NSColor
|
||||
|
||||
static func resolve(
|
||||
for colorScheme: ColorScheme,
|
||||
themeBackgroundColor: NSColor
|
||||
) -> BrowserChromeStyle {
|
||||
let backgroundColor = resolvedBrowserChromeBackgroundColor(
|
||||
for: colorScheme,
|
||||
themeBackgroundColor: themeBackgroundColor
|
||||
)
|
||||
let chromeColorScheme = resolvedBrowserChromeColorScheme(
|
||||
for: colorScheme,
|
||||
themeBackgroundColor: backgroundColor
|
||||
)
|
||||
let omnibarPillBackgroundColor = resolvedBrowserOmnibarPillBackgroundColor(
|
||||
for: chromeColorScheme,
|
||||
themeBackgroundColor: backgroundColor
|
||||
)
|
||||
return BrowserChromeStyle(
|
||||
backgroundColor: backgroundColor,
|
||||
colorScheme: chromeColorScheme,
|
||||
omnibarPillBackgroundColor: omnibarPillBackgroundColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// View for rendering a browser panel with address bar
|
||||
struct BrowserPanelView: View {
|
||||
@ObservedObject var panel: BrowserPanel
|
||||
@ObservedObject private var browserProfileStore = BrowserProfileStore.shared
|
||||
let paneId: PaneID
|
||||
let isFocused: Bool
|
||||
let isVisibleInUI: Bool
|
||||
|
|
@ -220,10 +250,15 @@ struct BrowserPanelView: View {
|
|||
@AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
|
||||
@AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
|
||||
@AppStorage(BrowserThemeSettings.modeKey) private var browserThemeModeRaw = BrowserThemeSettings.defaultMode.rawValue
|
||||
@AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey)
|
||||
private var toggleBrowserDeveloperToolsShortcutData = Data()
|
||||
@State private var suggestionTask: Task<Void, Never>?
|
||||
@State private var isLoadingRemoteSuggestions: Bool = false
|
||||
@State private var latestRemoteSuggestionQuery: String = ""
|
||||
@State private var latestRemoteSuggestions: [String] = []
|
||||
@State private var emptyStateImportBrowsers: [InstalledBrowserCandidate] = []
|
||||
@State private var emptyStateImportBrowserRefreshTask: Task<Void, Never>?
|
||||
@State private var emptyStateImportBrowserRefreshGeneration: UInt64 = 0
|
||||
@State private var inlineCompletion: OmnibarInlineCompletion?
|
||||
@State private var omnibarSelectionRange: NSRange = NSRange(location: NSNotFound, length: 0)
|
||||
@State private var omnibarHasMarkedText: Bool = false
|
||||
|
|
@ -235,8 +270,13 @@ struct BrowserPanelView: View {
|
|||
@State private var lastHandledAddressBarFocusRequestId: UUID?
|
||||
@State private var pendingAddressBarFocusRetryRequestId: UUID?
|
||||
@State private var pendingAddressBarFocusRetryGeneration: UInt64 = 0
|
||||
@State private var isBrowserProfileMenuPresented = false
|
||||
@State private var isBrowserThemeMenuPresented = false
|
||||
@State private var ghosttyBackgroundGeneration: Int = 0
|
||||
@State private var browserChromeStyle = BrowserChromeStyle.resolve(
|
||||
for: .light,
|
||||
themeBackgroundColor: GhosttyBackgroundTheme.currentColor()
|
||||
)
|
||||
@State private var toggleBrowserDeveloperToolsShortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut
|
||||
// Keep this below half of the compact omnibar height so it reads as a squircle,
|
||||
// not a capsule.
|
||||
private let omnibarPillCornerRadius: CGFloat = 10
|
||||
|
|
@ -282,24 +322,15 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
|
||||
private var browserChromeBackground: Color {
|
||||
_ = ghosttyBackgroundGeneration
|
||||
return Color(nsColor: GhosttyBackgroundTheme.currentColor())
|
||||
Color(nsColor: browserChromeStyle.backgroundColor)
|
||||
}
|
||||
|
||||
private var browserChromeBackgroundColor: NSColor {
|
||||
_ = ghosttyBackgroundGeneration
|
||||
return resolvedBrowserChromeBackgroundColor(
|
||||
for: colorScheme,
|
||||
themeBackgroundColor: GhosttyBackgroundTheme.currentColor()
|
||||
)
|
||||
browserChromeStyle.backgroundColor
|
||||
}
|
||||
|
||||
private var browserChromeColorScheme: ColorScheme {
|
||||
_ = ghosttyBackgroundGeneration
|
||||
return resolvedBrowserChromeColorScheme(
|
||||
for: colorScheme,
|
||||
themeBackgroundColor: GhosttyBackgroundTheme.currentColor()
|
||||
)
|
||||
browserChromeStyle.colorScheme
|
||||
}
|
||||
|
||||
private var browserContentAccessibilityIdentifier: String {
|
||||
|
|
@ -307,10 +338,12 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
|
||||
private var omnibarPillBackgroundColor: NSColor {
|
||||
resolvedBrowserOmnibarPillBackgroundColor(
|
||||
for: browserChromeColorScheme,
|
||||
themeBackgroundColor: browserChromeBackgroundColor
|
||||
)
|
||||
browserChromeStyle.omnibarPillBackgroundColor
|
||||
}
|
||||
|
||||
private var developerToolsButtonHelp: String {
|
||||
let base = String(localized: "browser.toggleDevTools", defaultValue: "Toggle Developer Tools")
|
||||
return "\(base) (\(toggleBrowserDeveloperToolsShortcut.displayString))"
|
||||
}
|
||||
|
||||
private var owningWorkspace: Workspace? {
|
||||
|
|
@ -420,6 +453,8 @@ struct BrowserPanelView: View {
|
|||
BrowserSearchSettings.searchSuggestionsEnabledKey: BrowserSearchSettings.defaultSearchSuggestionsEnabled,
|
||||
BrowserThemeSettings.modeKey: BrowserThemeSettings.defaultMode.rawValue,
|
||||
])
|
||||
refreshBrowserChromeStyle()
|
||||
refreshToggleBrowserDeveloperToolsShortcut()
|
||||
let resolvedThemeMode = BrowserThemeSettings.mode(defaults: .standard)
|
||||
if browserThemeModeRaw != resolvedThemeMode.rawValue {
|
||||
browserThemeModeRaw = resolvedThemeMode.rawValue
|
||||
|
|
@ -431,7 +466,8 @@ struct BrowserPanelView: View {
|
|||
// If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar.
|
||||
autoFocusOmnibarIfBlank()
|
||||
syncWebViewResponderPolicyWithViewState(reason: "onAppear")
|
||||
BrowserHistoryStore.shared.loadIfNeeded()
|
||||
refreshEmptyStateImportBrowsers()
|
||||
panel.historyStore.loadIfNeeded()
|
||||
#if DEBUG
|
||||
logBrowserFocusState(event: "view.onAppear")
|
||||
#endif
|
||||
|
|
@ -450,6 +486,9 @@ struct BrowserPanelView: View {
|
|||
!isWebViewBlank() {
|
||||
setAddressBarFocused(false, reason: "panel.currentURL.loaded")
|
||||
}
|
||||
if isWebViewBlank() {
|
||||
refreshEmptyStateImportBrowsers()
|
||||
}
|
||||
}
|
||||
.onChange(of: browserThemeModeRaw) { _ in
|
||||
let normalizedMode = BrowserThemeSettings.mode(for: browserThemeModeRaw)
|
||||
|
|
@ -459,11 +498,21 @@ struct BrowserPanelView: View {
|
|||
panel.setBrowserThemeMode(normalizedMode)
|
||||
}
|
||||
.onChange(of: colorScheme) { _ in
|
||||
refreshBrowserChromeStyle()
|
||||
panel.refreshAppearanceDrivenColors()
|
||||
}
|
||||
.onChange(of: toggleBrowserDeveloperToolsShortcutData) { _ in
|
||||
refreshToggleBrowserDeveloperToolsShortcut()
|
||||
}
|
||||
.onChange(of: panel.pendingAddressBarFocusRequestId) { _ in
|
||||
applyPendingAddressBarFocusRequestIfNeeded()
|
||||
}
|
||||
.onChange(of: panel.profileID) { _ in
|
||||
panel.historyStore.loadIfNeeded()
|
||||
if addressBarFocused {
|
||||
refreshSuggestions()
|
||||
}
|
||||
}
|
||||
.onChange(of: isFocused) { focused in
|
||||
#if DEBUG
|
||||
logBrowserFocusState(
|
||||
|
|
@ -536,7 +585,7 @@ struct BrowserPanelView: View {
|
|||
applyOmnibarEffects(effects)
|
||||
refreshInlineCompletion()
|
||||
}
|
||||
.onReceive(BrowserHistoryStore.shared.$entries) { _ in
|
||||
.onReceive(panel.historyStore.$entries) { _ in
|
||||
guard addressBarFocused else { return }
|
||||
refreshSuggestions()
|
||||
}
|
||||
|
|
@ -552,7 +601,7 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in
|
||||
ghosttyBackgroundGeneration &+= 1
|
||||
refreshBrowserChromeStyle()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -564,10 +613,9 @@ struct BrowserPanelView: View {
|
|||
.accessibilityIdentifier("BrowserOmnibarPill")
|
||||
.accessibilityLabel("Browser omnibar")
|
||||
|
||||
if !panel.isShowingNewTabPage {
|
||||
browserThemeModeButton
|
||||
developerToolsButton
|
||||
}
|
||||
browserProfileButton
|
||||
browserThemeModeButton
|
||||
developerToolsButton
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, addressBarVerticalPadding)
|
||||
|
|
@ -668,10 +716,38 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
.buttonStyle(OmnibarAddressButtonStyle())
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
.safeHelp(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.tooltip(String(localized: "browser.toggleDevTools", defaultValue: "Toggle Developer Tools")))
|
||||
.safeHelp(developerToolsButtonHelp)
|
||||
.accessibilityIdentifier("BrowserToggleDevToolsButton")
|
||||
}
|
||||
|
||||
private var browserProfileButton: some View {
|
||||
Button(action: {
|
||||
isBrowserProfileMenuPresented.toggle()
|
||||
}) {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.cmuxFlatSymbolColorRendering()
|
||||
.font(.system(size: devToolsButtonIconSize, weight: .medium))
|
||||
.foregroundStyle(devToolsColorOption.color)
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
}
|
||||
.buttonStyle(OmnibarAddressButtonStyle())
|
||||
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||
.popover(isPresented: $isBrowserProfileMenuPresented, arrowEdge: .bottom) {
|
||||
browserProfilePopover
|
||||
}
|
||||
.safeHelp(
|
||||
String(
|
||||
format: String(
|
||||
localized: "browser.profile.buttonHelp",
|
||||
defaultValue: "Browser Profile: %@"
|
||||
),
|
||||
panel.profileDisplayName
|
||||
)
|
||||
)
|
||||
.accessibilityIdentifier("BrowserProfileButton")
|
||||
}
|
||||
|
||||
private var browserThemeModeButton: some View {
|
||||
Button(action: {
|
||||
isBrowserThemeMenuPresented.toggle()
|
||||
|
|
@ -688,10 +764,76 @@ struct BrowserPanelView: View {
|
|||
.popover(isPresented: $isBrowserThemeMenuPresented, arrowEdge: .bottom) {
|
||||
browserThemeModePopover
|
||||
}
|
||||
.safeHelp("Browser Theme: \(browserThemeMode.displayName)")
|
||||
.safeHelp(
|
||||
String(
|
||||
format: String(
|
||||
localized: "browser.theme.buttonHelp",
|
||||
defaultValue: "Browser Theme: %@"
|
||||
),
|
||||
browserThemeMode.displayName
|
||||
)
|
||||
)
|
||||
.accessibilityIdentifier("BrowserThemeModeButton")
|
||||
}
|
||||
|
||||
private var browserProfilePopover: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(String(localized: "browser.profile.menu.title", defaultValue: "Profiles"))
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(browserProfileStore.profiles) { profile in
|
||||
Button {
|
||||
applyBrowserProfileSelection(profile.id)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: profile.id == panel.profileID ? "checkmark" : "circle")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.opacity(profile.id == panel.profileID ? 1.0 : 0.0)
|
||||
.frame(width: 12, alignment: .center)
|
||||
Text(profile.displayName)
|
||||
.font(.system(size: 12))
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: 24)
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(profile.id == panel.profileID ? Color.primary.opacity(0.12) : Color.clear)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button {
|
||||
isBrowserProfileMenuPresented = false
|
||||
presentCreateBrowserProfilePrompt()
|
||||
} label: {
|
||||
Text(String(localized: "browser.profile.new", defaultValue: "New Profile..."))
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if browserProfileStore.canRenameProfile(id: panel.profileID) {
|
||||
Button {
|
||||
isBrowserProfileMenuPresented = false
|
||||
presentRenameBrowserProfilePrompt()
|
||||
} label: {
|
||||
Text(String(localized: "browser.profile.rename", defaultValue: "Rename Current Profile..."))
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.frame(minWidth: 208)
|
||||
}
|
||||
|
||||
private var browserThemeModePopover: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(BrowserThemeMode.allCases) { mode in
|
||||
|
|
@ -876,6 +1018,11 @@ struct BrowserPanelView: View {
|
|||
setAddressBarFocused(false, reason: "placeholderContent.tapBlur")
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if shouldShowEmptyStateImportOverlay {
|
||||
emptyBrowserStateOverlay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
|
|
@ -907,6 +1054,28 @@ struct BrowserPanelView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func refreshBrowserChromeStyle() {
|
||||
browserChromeStyle = BrowserChromeStyle.resolve(
|
||||
for: colorScheme,
|
||||
themeBackgroundColor: GhosttyBackgroundTheme.currentColor()
|
||||
)
|
||||
}
|
||||
|
||||
private func refreshToggleBrowserDeveloperToolsShortcut() {
|
||||
toggleBrowserDeveloperToolsShortcut = decodeShortcut(
|
||||
from: toggleBrowserDeveloperToolsShortcutData,
|
||||
fallback: KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut
|
||||
)
|
||||
}
|
||||
|
||||
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
|
||||
guard !data.isEmpty,
|
||||
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
|
||||
return fallback
|
||||
}
|
||||
return shortcut
|
||||
}
|
||||
|
||||
private func syncWebViewResponderPolicyWithViewState(
|
||||
reason: String,
|
||||
isPanelFocusedOverride: Bool? = nil
|
||||
|
|
@ -1119,6 +1288,51 @@ struct BrowserPanelView: View {
|
|||
#endif
|
||||
}
|
||||
|
||||
private var emptyBrowserStateOverlay: some View {
|
||||
VStack {
|
||||
Spacer(minLength: 22)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(String(localized: "settings.browser.emptyImport.title", defaultValue: "Import browser data"))
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(InstalledBrowserDetector.summaryText(for: emptyStateImportBrowsers))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Button(String(localized: "settings.browser.emptyImport.choose", defaultValue: "Choose What to Import…")) {
|
||||
BrowserDataImportCoordinator.shared.presentImportDialog(
|
||||
defaultDestinationProfileID: panel.profileID
|
||||
)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: 360, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(nsColor: .windowBackgroundColor).opacity(0.9))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(
|
||||
Color(nsColor: .separatorColor).opacity(0.45),
|
||||
lineWidth: 1
|
||||
)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.08), radius: 8, y: 3)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
}
|
||||
|
||||
private var shouldShowEmptyStateImportOverlay: Bool {
|
||||
!panel.shouldRenderWebView && isWebViewBlank()
|
||||
}
|
||||
|
||||
/// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes.
|
||||
private func isWebViewBlank() -> Bool {
|
||||
guard let url = panel.webView.url else { return true }
|
||||
|
|
@ -1170,6 +1384,31 @@ struct BrowserPanelView: View {
|
|||
#endif
|
||||
}
|
||||
|
||||
private func refreshEmptyStateImportBrowsers() {
|
||||
emptyStateImportBrowserRefreshTask?.cancel()
|
||||
emptyStateImportBrowserRefreshGeneration &+= 1
|
||||
let generation = emptyStateImportBrowserRefreshGeneration
|
||||
|
||||
guard shouldShowEmptyStateImportOverlay else {
|
||||
emptyStateImportBrowsers = []
|
||||
emptyStateImportBrowserRefreshTask = nil
|
||||
return
|
||||
}
|
||||
|
||||
emptyStateImportBrowserRefreshTask = Task {
|
||||
let browsers = await Task.detached(priority: .utility) {
|
||||
InstalledBrowserDetector.detectInstalledBrowsers()
|
||||
}.value
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
guard emptyStateImportBrowserRefreshGeneration == generation,
|
||||
shouldShowEmptyStateImportOverlay else { return }
|
||||
emptyStateImportBrowsers = browsers
|
||||
emptyStateImportBrowserRefreshTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openDevTools() {
|
||||
#if DEBUG
|
||||
dlog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))")
|
||||
|
|
@ -1273,10 +1512,73 @@ struct BrowserPanelView: View {
|
|||
|
||||
let target = omnibarState.suggestions[idx]
|
||||
guard case .history(let url, _) = target.kind else { return }
|
||||
guard BrowserHistoryStore.shared.removeHistoryEntry(urlString: url) else { return }
|
||||
guard panel.historyStore.removeHistoryEntry(urlString: url) else { return }
|
||||
refreshSuggestions()
|
||||
}
|
||||
|
||||
private func applyBrowserProfileSelection(_ profileID: UUID) {
|
||||
isBrowserProfileMenuPresented = false
|
||||
owningWorkspace?.setPreferredBrowserProfileID(profileID)
|
||||
_ = panel.switchToProfile(profileID)
|
||||
}
|
||||
|
||||
private func presentCreateBrowserProfilePrompt() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = String(localized: "browser.profile.new.title", defaultValue: "New Browser Profile")
|
||||
alert.informativeText = String(localized: "browser.profile.new.message", defaultValue: "Create a separate browser profile for cookies, history, and local storage.")
|
||||
|
||||
let input = NSTextField(string: "")
|
||||
input.placeholderString = String(localized: "browser.profile.new.placeholder", defaultValue: "Profile name")
|
||||
input.frame = NSRect(x: 0, y: 0, width: 260, height: 22)
|
||||
alert.accessoryView = input
|
||||
|
||||
alert.addButton(withTitle: String(localized: "common.create", defaultValue: "Create"))
|
||||
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
|
||||
|
||||
let alertWindow = alert.window
|
||||
alertWindow.initialFirstResponder = input
|
||||
DispatchQueue.main.async {
|
||||
alertWindow.makeFirstResponder(input)
|
||||
input.selectText(nil)
|
||||
}
|
||||
|
||||
guard alert.runModal() == .alertFirstButtonReturn,
|
||||
let profile = browserProfileStore.createProfile(named: input.stringValue) else {
|
||||
return
|
||||
}
|
||||
|
||||
applyBrowserProfileSelection(profile.id)
|
||||
}
|
||||
|
||||
private func presentRenameBrowserProfilePrompt() {
|
||||
guard let profile = browserProfileStore.profileDefinition(id: panel.profileID),
|
||||
browserProfileStore.canRenameProfile(id: profile.id) else {
|
||||
return
|
||||
}
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = String(localized: "browser.profile.rename.title", defaultValue: "Rename Browser Profile")
|
||||
alert.informativeText = String(localized: "browser.profile.rename.message", defaultValue: "Choose a new name for this browser profile.")
|
||||
|
||||
let input = NSTextField(string: profile.displayName)
|
||||
input.placeholderString = String(localized: "browser.profile.new.placeholder", defaultValue: "Profile name")
|
||||
input.frame = NSRect(x: 0, y: 0, width: 260, height: 22)
|
||||
alert.accessoryView = input
|
||||
|
||||
alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename"))
|
||||
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
|
||||
|
||||
let alertWindow = alert.window
|
||||
alertWindow.initialFirstResponder = input
|
||||
DispatchQueue.main.async {
|
||||
alertWindow.makeFirstResponder(input)
|
||||
input.selectText(nil)
|
||||
}
|
||||
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
_ = browserProfileStore.renameProfile(id: profile.id, to: input.stringValue)
|
||||
}
|
||||
|
||||
private func refreshInlineCompletion() {
|
||||
inlineCompletion = omnibarInlineCompletionForDisplay(
|
||||
typedText: omnibarState.buffer,
|
||||
|
|
@ -1312,9 +1614,9 @@ struct BrowserPanelView: View {
|
|||
let query = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let historyEntries: [BrowserHistoryStore.Entry] = {
|
||||
if query.isEmpty {
|
||||
return BrowserHistoryStore.shared.recentSuggestions(limit: 12)
|
||||
return panel.historyStore.recentSuggestions(limit: 12)
|
||||
}
|
||||
return BrowserHistoryStore.shared.suggestions(for: query, limit: 12)
|
||||
return panel.historyStore.suggestions(for: query, limit: 12)
|
||||
}()
|
||||
let openTabMatches = query.isEmpty ? [] : matchingOpenTabSuggestions(for: query, limit: 12)
|
||||
let isSingleCharacterQuery = omnibarSingleCharacterQuery(for: query) != nil
|
||||
|
|
@ -1378,7 +1680,7 @@ struct BrowserPanelView: View {
|
|||
let merged = buildOmnibarSuggestions(
|
||||
query: query,
|
||||
engineName: searchEngine.displayName,
|
||||
historyEntries: BrowserHistoryStore.shared.suggestions(for: query, limit: 12),
|
||||
historyEntries: panel.historyStore.suggestions(for: query, limit: 12),
|
||||
openTabMatches: matchingOpenTabSuggestions(for: query, limit: 12),
|
||||
remoteQueries: remote,
|
||||
resolvedURL: panel.resolveNavigableURL(from: query),
|
||||
|
|
|
|||
619
Sources/Panels/BrowserPopupWindowController.swift
Normal file
619
Sources/Panels/BrowserPopupWindowController.swift
Normal file
|
|
@ -0,0 +1,619 @@
|
|||
import AppKit
|
||||
import Bonsplit
|
||||
import ObjectiveC
|
||||
import WebKit
|
||||
|
||||
func browserPopupContentRect(
|
||||
requestedWidth: CGFloat?,
|
||||
requestedHeight: CGFloat?,
|
||||
requestedX: CGFloat?,
|
||||
requestedTopY: CGFloat?,
|
||||
visibleFrame: NSRect,
|
||||
defaultWidth: CGFloat = 800,
|
||||
defaultHeight: CGFloat = 600,
|
||||
minWidth: CGFloat = 200,
|
||||
minHeight: CGFloat = 150
|
||||
) -> NSRect {
|
||||
let clampedWidth = min(max(requestedWidth ?? defaultWidth, minWidth), visibleFrame.width)
|
||||
let clampedHeight = min(max(requestedHeight ?? defaultHeight, minHeight), visibleFrame.height)
|
||||
|
||||
let x: CGFloat
|
||||
let y: CGFloat
|
||||
if let requestedX, let requestedTopY {
|
||||
x = max(visibleFrame.minX, min(requestedX, visibleFrame.maxX - clampedWidth))
|
||||
|
||||
// Web content expresses popup Y as distance from the screen's top edge,
|
||||
// while AppKit window origins are bottom-up.
|
||||
let appKitY = visibleFrame.maxY - requestedTopY - clampedHeight
|
||||
y = max(visibleFrame.minY, min(appKitY, visibleFrame.maxY - clampedHeight))
|
||||
} else {
|
||||
x = visibleFrame.midX - clampedWidth / 2
|
||||
y = visibleFrame.midY - clampedHeight / 2
|
||||
}
|
||||
|
||||
return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight)
|
||||
}
|
||||
|
||||
/// Hosts a popup `CmuxWebView` in a standalone `NSPanel`, created when a page
|
||||
/// calls `window.open()` (scripted new-window requests).
|
||||
///
|
||||
/// Lifecycle:
|
||||
/// - The controller self-retains via `objc_setAssociatedObject` on its panel.
|
||||
/// - Released in `windowWillClose(_:)` when the panel closes.
|
||||
/// - The opener `BrowserPanel` also keeps a strong reference for deterministic
|
||||
/// cleanup when the opener tab or workspace is closed.
|
||||
/// NSPanel subclass that intercepts Cmd+W before the swizzled
|
||||
/// `cmux_performKeyEquivalent` can dispatch it to the main menu's
|
||||
/// "Close Tab" action (which would close the parent browser tab).
|
||||
private class BrowserPopupPanel: NSPanel {
|
||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
// Cmd+W: close this popup panel only
|
||||
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
if flags == .command,
|
||||
event.charactersIgnoringModifiers == "w" {
|
||||
#if DEBUG
|
||||
dlog("popup.panel.cmdW close")
|
||||
#endif
|
||||
performClose(nil)
|
||||
return true
|
||||
}
|
||||
return super.performKeyEquivalent(with: event)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class BrowserPopupWindowController: NSObject, NSWindowDelegate {
|
||||
|
||||
static let maxNestingDepth = 3
|
||||
|
||||
let webView: CmuxWebView
|
||||
private let panel: NSPanel
|
||||
private let urlLabel: NSTextField
|
||||
private weak var openerPanel: BrowserPanel?
|
||||
private weak var parentPopupController: BrowserPopupWindowController?
|
||||
private let nestingDepth: Int
|
||||
private var titleObservation: NSKeyValueObservation?
|
||||
private var urlObservation: NSKeyValueObservation?
|
||||
private var childPopups: [BrowserPopupWindowController] = []
|
||||
private let popupUIDelegate: PopupUIDelegate
|
||||
private let popupNavigationDelegate: PopupNavigationDelegate
|
||||
private let downloadDelegate: BrowserDownloadDelegate
|
||||
|
||||
private static var associatedObjectKey: UInt8 = 0
|
||||
|
||||
init(
|
||||
configuration: WKWebViewConfiguration,
|
||||
windowFeatures: WKWindowFeatures,
|
||||
openerPanel: BrowserPanel?,
|
||||
parentPopupController: BrowserPopupWindowController? = nil,
|
||||
nestingDepth: Int = 0
|
||||
) {
|
||||
self.openerPanel = openerPanel
|
||||
self.parentPopupController = parentPopupController
|
||||
self.nestingDepth = nestingDepth
|
||||
|
||||
// Create popup web view with WebKit's supplied configuration (preserves
|
||||
// internal browsing-context state for opener linkage / postMessage).
|
||||
let webView = CmuxWebView(frame: .zero, configuration: configuration)
|
||||
webView.allowsBackForwardNavigationGestures = true
|
||||
if #available(macOS 13.3, *) {
|
||||
webView.isInspectable = true
|
||||
}
|
||||
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
|
||||
self.webView = webView
|
||||
|
||||
// --- Window sizing from WKWindowFeatures ---
|
||||
let defaultWidth: CGFloat = 800
|
||||
let defaultHeight: CGFloat = 600
|
||||
let minWidth: CGFloat = 200
|
||||
let minHeight: CGFloat = 150
|
||||
|
||||
let w = max(windowFeatures.width?.doubleValue ?? defaultWidth, minWidth)
|
||||
let h = max(windowFeatures.height?.doubleValue ?? defaultHeight, minHeight)
|
||||
|
||||
// Screen-clamping: use opener's screen or main screen
|
||||
let screen = openerPanel?.webView.window?.screen ?? NSScreen.main ?? NSScreen.screens.first
|
||||
let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
|
||||
let contentRect = browserPopupContentRect(
|
||||
requestedWidth: w,
|
||||
requestedHeight: h,
|
||||
requestedX: windowFeatures.x.map { CGFloat($0.doubleValue) },
|
||||
requestedTopY: windowFeatures.y.map { CGFloat($0.doubleValue) },
|
||||
visibleFrame: visibleFrame,
|
||||
defaultWidth: defaultWidth,
|
||||
defaultHeight: defaultHeight,
|
||||
minWidth: minWidth,
|
||||
minHeight: minHeight
|
||||
)
|
||||
|
||||
// Style mask: titled + closable + resizable by default.
|
||||
// allowsResizing is a separate property from chrome-visibility flags
|
||||
// (toolbarsVisibility, menuBarVisibility, statusBarVisibility).
|
||||
var styleMask: NSWindow.StyleMask = [.titled, .closable, .miniaturizable]
|
||||
if windowFeatures.allowsResizing?.boolValue != false {
|
||||
styleMask.insert(.resizable)
|
||||
}
|
||||
|
||||
let panel = BrowserPopupPanel(
|
||||
contentRect: contentRect,
|
||||
styleMask: styleMask,
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
panel.identifier = NSUserInterfaceItemIdentifier("cmux.browser-popup")
|
||||
panel.level = NSWindow.Level.normal
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.isReleasedWhenClosed = false
|
||||
panel.minSize = NSSize(width: minWidth, height: minHeight)
|
||||
panel.title = String(localized: "browser.popup.loadingTitle", defaultValue: "Loading\u{2026}")
|
||||
self.panel = panel
|
||||
|
||||
let urlLabel = NSTextField(labelWithString: "")
|
||||
self.urlLabel = urlLabel
|
||||
|
||||
// Build delegate objects before super.init so they can be assigned
|
||||
let uiDel = PopupUIDelegate()
|
||||
let navDel = PopupNavigationDelegate()
|
||||
let dlDel = BrowserDownloadDelegate()
|
||||
self.popupUIDelegate = uiDel
|
||||
self.popupNavigationDelegate = navDel
|
||||
self.downloadDelegate = dlDel
|
||||
|
||||
super.init()
|
||||
|
||||
// --- URL label for phishing protection ---
|
||||
urlLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
urlLabel.font = .systemFont(ofSize: 11)
|
||||
urlLabel.textColor = .secondaryLabelColor
|
||||
urlLabel.lineBreakMode = .byTruncatingMiddle
|
||||
urlLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
|
||||
let containerView = NSView()
|
||||
containerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerView.addSubview(urlLabel)
|
||||
containerView.addSubview(webView)
|
||||
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
panel.contentView = containerView
|
||||
NSLayoutConstraint.activate([
|
||||
urlLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 4),
|
||||
urlLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8),
|
||||
urlLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8),
|
||||
urlLabel.heightAnchor.constraint(equalToConstant: 16),
|
||||
|
||||
webView.topAnchor.constraint(equalTo: urlLabel.bottomAnchor, constant: 2),
|
||||
webView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
webView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
webView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
|
||||
])
|
||||
|
||||
// --- Delegates ---
|
||||
uiDel.controller = self
|
||||
navDel.controller = self
|
||||
navDel.downloadDelegate = dlDel
|
||||
webView.uiDelegate = uiDel
|
||||
webView.navigationDelegate = navDel
|
||||
|
||||
// Context menu "Open Link in New Tab" → open in opener's workspace,
|
||||
// not as a nested popup. Falls back to system browser if opener is gone.
|
||||
webView.onContextMenuOpenLinkInNewTab = { [weak self] url in
|
||||
if let opener = self?.openerPanel {
|
||||
opener.openLinkInNewTab(url: url)
|
||||
} else {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
// --- KVO for title and URL ---
|
||||
titleObservation = webView.observe(\.title, options: [.new]) { [weak self] _, change in
|
||||
guard let newTitle = change.newValue ?? nil, !newTitle.isEmpty else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
self?.panel.title = newTitle
|
||||
}
|
||||
}
|
||||
urlObservation = webView.observe(\.url, options: [.new]) { [weak self] _, change in
|
||||
let displayURL = change.newValue??.absoluteString ?? ""
|
||||
Task { @MainActor [weak self] in
|
||||
self?.urlLabel.stringValue = displayURL
|
||||
}
|
||||
}
|
||||
|
||||
// --- Self-retention via associated object on panel ---
|
||||
objc_setAssociatedObject(panel, &Self.associatedObjectKey, self, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
|
||||
panel.delegate = self
|
||||
|
||||
#if DEBUG
|
||||
dlog("popup.init depth=\(nestingDepth) size=\(Int(contentRect.width))x\(Int(contentRect.height)) opener=\(openerPanel?.id.uuidString.prefix(5) ?? "nil")")
|
||||
#endif
|
||||
|
||||
panel.makeKeyAndOrderFront(self)
|
||||
}
|
||||
|
||||
// MARK: - Child popup tracking
|
||||
|
||||
func addChildPopup(_ child: BrowserPopupWindowController) {
|
||||
childPopups.append(child)
|
||||
}
|
||||
|
||||
func removeChildPopup(_ child: BrowserPopupWindowController) {
|
||||
childPopups.removeAll { $0 === child }
|
||||
}
|
||||
|
||||
// MARK: - Popup lifecycle
|
||||
|
||||
func closePopup() {
|
||||
panel.close() // triggers windowWillClose
|
||||
}
|
||||
|
||||
func closeAllChildPopups() {
|
||||
let children = childPopups
|
||||
childPopups.removeAll()
|
||||
for child in children {
|
||||
child.closeAllChildPopups()
|
||||
child.closePopup()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSWindowDelegate
|
||||
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
#if DEBUG
|
||||
dlog("popup.close depth=\(nestingDepth)")
|
||||
#endif
|
||||
|
||||
closeAllChildPopups()
|
||||
|
||||
// Invalidate observations
|
||||
titleObservation?.invalidate()
|
||||
titleObservation = nil
|
||||
urlObservation?.invalidate()
|
||||
urlObservation = nil
|
||||
|
||||
// Tear down web view
|
||||
webView.stopLoading()
|
||||
webView.navigationDelegate = nil
|
||||
webView.uiDelegate = nil
|
||||
|
||||
// Unregister from parent (opener panel or parent popup)
|
||||
openerPanel?.removePopupController(self)
|
||||
parentPopupController?.removeChildPopup(self)
|
||||
|
||||
// Release self-retention
|
||||
objc_setAssociatedObject(panel, &Self.associatedObjectKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
}
|
||||
|
||||
// MARK: - Nested popup creation
|
||||
|
||||
func createNestedPopup(
|
||||
configuration: WKWebViewConfiguration,
|
||||
windowFeatures: WKWindowFeatures
|
||||
) -> WKWebView? {
|
||||
let nextDepth = nestingDepth + 1
|
||||
if nextDepth > Self.maxNestingDepth {
|
||||
#if DEBUG
|
||||
dlog("popup.nested.blocked depth=\(nextDepth) max=\(Self.maxNestingDepth)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
let child = BrowserPopupWindowController(
|
||||
configuration: configuration,
|
||||
windowFeatures: windowFeatures,
|
||||
openerPanel: openerPanel,
|
||||
parentPopupController: self,
|
||||
nestingDepth: nextDepth
|
||||
)
|
||||
addChildPopup(child)
|
||||
return child.webView
|
||||
}
|
||||
|
||||
func openInOpenerTab(_ url: URL) {
|
||||
if let openerPanel {
|
||||
openerPanel.openLinkInNewTab(url: url)
|
||||
} else {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Insecure HTTP prompt (parity with main browser)
|
||||
|
||||
/// Shows the same 3-button insecure HTTP alert as the main browser.
|
||||
/// Reuses the global helpers from BrowserPanel.swift.
|
||||
fileprivate func presentInsecureHTTPAlert(
|
||||
for url: URL,
|
||||
in webView: WKWebView,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||||
) {
|
||||
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else {
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = String(localized: "browser.error.insecure.title", defaultValue: "Connection isn\u{2019}t secure")
|
||||
alert.informativeText = String(localized: "browser.error.insecure.message", defaultValue: "\(host) uses plain HTTP, so traffic can be read or modified on the network.\n\nOpen this URL in your default browser, or proceed in cmux.")
|
||||
alert.addButton(withTitle: String(localized: "browser.openInDefaultBrowser", defaultValue: "Open in Default Browser"))
|
||||
alert.addButton(withTitle: String(localized: "browser.proceedInCmux", defaultValue: "Proceed in cmux"))
|
||||
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
|
||||
alert.showsSuppressionButton = true
|
||||
alert.suppressionButton?.title = String(localized: "browser.alwaysAllowHost", defaultValue: "Always allow this host in cmux")
|
||||
|
||||
let handleResponse: (NSApplication.ModalResponse) -> Void = { [weak alert] response in
|
||||
if browserShouldPersistInsecureHTTPAllowlistSelection(
|
||||
response: response,
|
||||
suppressionEnabled: alert?.suppressionButton?.state == .on
|
||||
) {
|
||||
BrowserInsecureHTTPSettings.addAllowedHost(host)
|
||||
}
|
||||
switch response {
|
||||
case .alertFirstButtonReturn:
|
||||
// Open in default browser, cancel popup navigation
|
||||
NSWorkspace.shared.open(url)
|
||||
decisionHandler(.cancel)
|
||||
case .alertSecondButtonReturn:
|
||||
// Proceed in popup
|
||||
decisionHandler(.allow)
|
||||
default:
|
||||
decisionHandler(.cancel)
|
||||
}
|
||||
}
|
||||
|
||||
if let window = webView.window {
|
||||
alert.beginSheetModal(for: window, completionHandler: handleResponse)
|
||||
return
|
||||
}
|
||||
handleResponse(alert.runModal())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PopupUIDelegate
|
||||
|
||||
private class PopupUIDelegate: NSObject, WKUIDelegate {
|
||||
weak var controller: BrowserPopupWindowController?
|
||||
|
||||
func webViewDidClose(_ webView: WKWebView) {
|
||||
#if DEBUG
|
||||
dlog("popup.webViewDidClose")
|
||||
#endif
|
||||
controller?.closePopup()
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
createWebViewWith configuration: WKWebViewConfiguration,
|
||||
for navigationAction: WKNavigationAction,
|
||||
windowFeatures: WKWindowFeatures
|
||||
) -> WKWebView? {
|
||||
// External URL check
|
||||
if let url = navigationAction.request.url,
|
||||
browserShouldOpenURLExternally(url) {
|
||||
NSWorkspace.shared.open(url)
|
||||
return nil
|
||||
}
|
||||
|
||||
let isScriptedPopup = browserNavigationShouldCreatePopup(
|
||||
navigationType: navigationAction.navigationType,
|
||||
modifierFlags: navigationAction.modifierFlags,
|
||||
buttonNumber: navigationAction.buttonNumber,
|
||||
hasRecentMiddleClickIntent: CmuxWebView.hasRecentMiddleClickIntent(for: webView)
|
||||
)
|
||||
|
||||
if isScriptedPopup {
|
||||
return controller?.createNestedPopup(
|
||||
configuration: configuration,
|
||||
windowFeatures: windowFeatures
|
||||
)
|
||||
}
|
||||
|
||||
if let url = navigationAction.request.url {
|
||||
controller?.openInOpenerTab(url)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - JS Dialogs (parity with main browser)
|
||||
|
||||
private func javaScriptDialogTitle(for webView: WKWebView) -> String {
|
||||
if let absolute = webView.url?.absoluteString, !absolute.isEmpty {
|
||||
return String(localized: "browser.dialog.pageSaysAt", defaultValue: "The page at \(absolute) says:")
|
||||
}
|
||||
return String(localized: "browser.dialog.pageSays", defaultValue: "This page says:")
|
||||
}
|
||||
|
||||
private func presentDialog(
|
||||
_ alert: NSAlert,
|
||||
for webView: WKWebView,
|
||||
completion: @escaping (NSApplication.ModalResponse) -> Void
|
||||
) {
|
||||
if let window = webView.window {
|
||||
alert.beginSheetModal(for: window, completionHandler: completion)
|
||||
return
|
||||
}
|
||||
completion(alert.runModal())
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
runJavaScriptAlertPanelWithMessage message: String,
|
||||
initiatedByFrame frame: WKFrameInfo,
|
||||
completionHandler: @escaping () -> Void
|
||||
) {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .informational
|
||||
alert.messageText = javaScriptDialogTitle(for: webView)
|
||||
alert.informativeText = message
|
||||
alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK"))
|
||||
presentDialog(alert, for: webView) { _ in completionHandler() }
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
runJavaScriptConfirmPanelWithMessage message: String,
|
||||
initiatedByFrame frame: WKFrameInfo,
|
||||
completionHandler: @escaping (Bool) -> Void
|
||||
) {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .informational
|
||||
alert.messageText = javaScriptDialogTitle(for: webView)
|
||||
alert.informativeText = message
|
||||
alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK"))
|
||||
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
|
||||
presentDialog(alert, for: webView) { response in
|
||||
completionHandler(response == .alertFirstButtonReturn)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
runJavaScriptTextInputPanelWithPrompt prompt: String,
|
||||
defaultText: String?,
|
||||
initiatedByFrame frame: WKFrameInfo,
|
||||
completionHandler: @escaping (String?) -> Void
|
||||
) {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .informational
|
||||
alert.messageText = javaScriptDialogTitle(for: webView)
|
||||
alert.informativeText = prompt
|
||||
alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK"))
|
||||
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
|
||||
|
||||
let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24))
|
||||
field.stringValue = defaultText ?? ""
|
||||
alert.accessoryView = field
|
||||
|
||||
presentDialog(alert, for: webView) { response in
|
||||
if response == .alertFirstButtonReturn {
|
||||
completionHandler(field.stringValue)
|
||||
} else {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
runOpenPanelWith parameters: WKOpenPanelParameters,
|
||||
initiatedByFrame frame: WKFrameInfo,
|
||||
completionHandler: @escaping ([URL]?) -> Void
|
||||
) {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
|
||||
panel.canChooseDirectories = parameters.allowsDirectories
|
||||
panel.canChooseFiles = true
|
||||
panel.begin { result in
|
||||
completionHandler(result == .OK ? panel.urls : nil)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
requestMediaCapturePermissionFor origin: WKSecurityOrigin,
|
||||
initiatedByFrame frame: WKFrameInfo,
|
||||
type: WKMediaCaptureType,
|
||||
decisionHandler: @escaping (WKPermissionDecision) -> Void
|
||||
) {
|
||||
decisionHandler(.prompt)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PopupNavigationDelegate
|
||||
|
||||
private class PopupNavigationDelegate: NSObject, WKNavigationDelegate {
|
||||
weak var controller: BrowserPopupWindowController?
|
||||
var downloadDelegate: WKDownloadDelegate?
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
decidePolicyFor navigationAction: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||||
) {
|
||||
// Only guard main-frame navigations
|
||||
guard navigationAction.targetFrame?.isMainFrame != false else {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = navigationAction.request.url else {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
|
||||
// External URL schemes → hand off to macOS
|
||||
if browserShouldOpenURLExternally(url) {
|
||||
NSWorkspace.shared.open(url)
|
||||
#if DEBUG
|
||||
dlog("popup.nav.external url=\(url.absoluteString)")
|
||||
#endif
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
// Insecure HTTP → show same prompt as main browser
|
||||
if browserShouldBlockInsecureHTTPURL(url) {
|
||||
#if DEBUG
|
||||
dlog("popup.nav.insecureHTTP url=\(url.absoluteString)")
|
||||
#endif
|
||||
controller?.presentInsecureHTTPAlert(for: url, in: webView, decisionHandler: decisionHandler)
|
||||
return
|
||||
}
|
||||
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
decidePolicyFor navigationResponse: WKNavigationResponse,
|
||||
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
|
||||
) {
|
||||
if !navigationResponse.isForMainFrame {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
|
||||
if let scheme = navigationResponse.response.url?.scheme?.lowercased(),
|
||||
scheme != "http", scheme != "https" {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
|
||||
if let response = navigationResponse.response as? HTTPURLResponse {
|
||||
let contentDisposition = response.value(forHTTPHeaderField: "Content-Disposition") ?? ""
|
||||
if contentDisposition.lowercased().hasPrefix("attachment") {
|
||||
decisionHandler(.download)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !navigationResponse.canShowMIMEType {
|
||||
decisionHandler(.download)
|
||||
return
|
||||
}
|
||||
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
// Parity with main browser: performDefaultHandling enables system keychain
|
||||
// lookups, MDM client certs, and SSO extensions (e.g. Microsoft Entra ID).
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
|
||||
#if DEBUG
|
||||
dlog("popup.download.didBecome source=navigationAction")
|
||||
#endif
|
||||
download.delegate = downloadDelegate
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
|
||||
#if DEBUG
|
||||
dlog("popup.download.didBecome source=navigationResponse")
|
||||
#endif
|
||||
download.delegate = downloadDelegate
|
||||
}
|
||||
}
|
||||
|
|
@ -53,6 +53,9 @@ final class CmuxWebView: WKWebView {
|
|||
private static var contextMenuFallbackKey: UInt8 = 0
|
||||
|
||||
var onContextMenuDownloadStateChanged: ((Bool) -> Void)?
|
||||
/// Called when "Open Link in New Tab" context menu is selected.
|
||||
/// Bypasses createWebViewWith so the link opens as a tab, not a popup.
|
||||
var onContextMenuOpenLinkInNewTab: ((URL) -> Void)?
|
||||
var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)?
|
||||
var contextMenuDefaultBrowserOpener: ((URL) -> Bool)?
|
||||
/// Guard against background panes stealing first responder (e.g. page autofocus).
|
||||
|
|
@ -1212,12 +1215,15 @@ final class CmuxWebView: WKWebView {
|
|||
openLinkInsertionIndex = index + 1
|
||||
}
|
||||
|
||||
// Rename "Open Link in New Window" to "Open Link in New Tab".
|
||||
// The UIDelegate's createWebViewWith already handles the action
|
||||
// by opening the link as a new surface in the same pane.
|
||||
// Retarget "Open Link in New Window" to open as a tab, not a popup.
|
||||
// Without this, WebKit's default action calls createWebViewWith with
|
||||
// navigationType .other, which our classifier would treat as a scripted
|
||||
// popup request.
|
||||
if item.identifier?.rawValue == "WKMenuItemIdentifierOpenLinkInNewWindow"
|
||||
|| item.title.contains("Open Link in New Window") {
|
||||
item.title = String(localized: "browser.contextMenu.openLinkInNewTab", defaultValue: "Open Link in New Tab")
|
||||
item.target = self
|
||||
item.action = #selector(contextMenuOpenLinkInNewTab(_:))
|
||||
}
|
||||
|
||||
if isDownloadImageMenuItem(item) {
|
||||
|
|
@ -1275,6 +1281,14 @@ final class CmuxWebView: WKWebView {
|
|||
}
|
||||
}
|
||||
|
||||
@objc private func contextMenuOpenLinkInNewTab(_ sender: Any?) {
|
||||
let point = lastContextMenuPoint
|
||||
resolveContextMenuLinkURL(at: point) { [weak self] url in
|
||||
guard let self, let url else { return }
|
||||
self.onContextMenuOpenLinkInNewTab?(url)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func contextMenuDownloadImage(_ sender: Any?) {
|
||||
let traceID = Self.makeContextDownloadTraceID(prefix: "img")
|
||||
let point = lastContextMenuPoint
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ enum SessionSnapshotSchema {
|
|||
|
||||
enum SessionPersistencePolicy {
|
||||
static let defaultSidebarWidth: Double = 200
|
||||
static let minimumSidebarWidth: Double = 186
|
||||
static let minimumSidebarWidth: Double = 180
|
||||
static let maximumSidebarWidth: Double = 600
|
||||
static let minimumWindowWidth: Double = 300
|
||||
static let minimumWindowHeight: Double = 200
|
||||
|
|
@ -228,6 +228,7 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable {
|
|||
|
||||
struct SessionBrowserPanelSnapshot: Codable, Sendable {
|
||||
var urlString: String?
|
||||
var profileID: UUID?
|
||||
var shouldRenderWebView: Bool
|
||||
var pageZoom: Double
|
||||
var developerToolsVisible: Bool
|
||||
|
|
|
|||
|
|
@ -636,6 +636,15 @@ class TabManager: ObservableObject {
|
|||
private struct InitialWorkspaceGitMetadataSnapshot: Equatable {
|
||||
let branch: String?
|
||||
let isDirty: Bool
|
||||
let pullRequest: SidebarPullRequestState?
|
||||
}
|
||||
|
||||
private struct CommandResult {
|
||||
let stdout: String?
|
||||
let stderr: String?
|
||||
let exitStatus: Int32?
|
||||
let timedOut: Bool
|
||||
let executionError: String?
|
||||
}
|
||||
|
||||
/// The window that owns this TabManager. Set by AppDelegate.registerMainWindow().
|
||||
|
|
@ -651,6 +660,7 @@ class TabManager: ObservableObject {
|
|||
/// 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]
|
||||
private nonisolated static let initialWorkspacePullRequestProbeTimeout: TimeInterval = 5.0
|
||||
@Published var selectedTabId: UUID? {
|
||||
willSet {
|
||||
#if DEBUG
|
||||
|
|
@ -760,6 +770,7 @@ class TabManager: ObservableObject {
|
|||
return tabs.first(where: { $0.id == selectedTabId })
|
||||
}
|
||||
}
|
||||
private var agentPIDSweepTimer: DispatchSourceTimer?
|
||||
#if DEBUG
|
||||
private var debugWorkspaceSwitchCounter: UInt64 = 0
|
||||
private var debugWorkspaceSwitchId: UInt64 = 0
|
||||
|
|
@ -805,6 +816,8 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
})
|
||||
|
||||
startAgentPIDSweepTimer()
|
||||
|
||||
#if DEBUG
|
||||
setupUITestFocusShortcutsIfNeeded()
|
||||
setupSplitCloseRightUITestIfNeeded()
|
||||
|
|
@ -815,6 +828,54 @@ class TabManager: ObservableObject {
|
|||
|
||||
deinit {
|
||||
workspaceCycleCooldownTask?.cancel()
|
||||
agentPIDSweepTimer?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Agent PID Sweep
|
||||
|
||||
/// Periodically checks agent PIDs associated with status entries.
|
||||
/// If a process has exited (SIGKILL, crash, etc.), clears the stale status entry.
|
||||
/// This is the safety net for cases where no hook fires (e.g. SIGKILL).
|
||||
private func startAgentPIDSweepTimer() {
|
||||
let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility))
|
||||
timer.schedule(deadline: .now() + 30, repeating: 30)
|
||||
timer.setEventHandler { [weak self] in
|
||||
guard let self else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.sweepStaleAgentPIDs()
|
||||
}
|
||||
}
|
||||
timer.resume()
|
||||
agentPIDSweepTimer = timer
|
||||
}
|
||||
|
||||
private func sweepStaleAgentPIDs() {
|
||||
for tab in tabs {
|
||||
var keysToRemove: [String] = []
|
||||
for (key, pid) in tab.agentPIDs {
|
||||
guard pid > 0 else {
|
||||
keysToRemove.append(key)
|
||||
continue
|
||||
}
|
||||
// kill(pid, 0) probes process liveness without sending a signal.
|
||||
// ESRCH = process doesn't exist (stale). EPERM = process exists
|
||||
// but we lack permission (not stale, keep tracking).
|
||||
errno = 0
|
||||
if kill(pid, 0) == -1, POSIXErrorCode(rawValue: errno) == .ESRCH {
|
||||
keysToRemove.append(key)
|
||||
}
|
||||
}
|
||||
if !keysToRemove.isEmpty {
|
||||
for key in keysToRemove {
|
||||
tab.statusEntries.removeValue(forKey: key)
|
||||
tab.agentPIDs.removeValue(forKey: key)
|
||||
}
|
||||
// Also clear stale notifications (e.g. "Doing well, thanks!")
|
||||
// left behind when Claude was killed without SessionEnd firing.
|
||||
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func wireClosedBrowserTracking(for workspace: Workspace) {
|
||||
|
|
@ -865,6 +926,20 @@ class TabManager: ObservableObject {
|
|||
_ = panel.performBindingAction("start_search")
|
||||
return
|
||||
}
|
||||
if let panel = selectedTerminalPanel {
|
||||
let hadExistingSearch = panel.searchState != nil
|
||||
let handled = startOrFocusTerminalSearch(panel.surface)
|
||||
NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) " +
|
||||
"panel=\(panel.id.uuidString.prefix(5)) existing=\(hadExistingSearch ? "yes" : "no") " +
|
||||
"handled=\(handled ? 1 : 0) " +
|
||||
"firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
focusedBrowserPanel?.startFind()
|
||||
}
|
||||
|
|
@ -1118,15 +1193,25 @@ class TabManager: ObservableObject {
|
|||
workspace.clearPanelGitBranch(panelId: panelId)
|
||||
}
|
||||
|
||||
if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) {
|
||||
if let pullRequest = snapshot.pullRequest {
|
||||
workspace.updatePanelPullRequest(
|
||||
panelId: panelId,
|
||||
number: pullRequest.number,
|
||||
label: pullRequest.label,
|
||||
url: pullRequest.url,
|
||||
status: pullRequest.status
|
||||
)
|
||||
} else if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) {
|
||||
workspace.clearPanelPullRequest(panelId: panelId)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
let branchLabel = snapshot.branch ?? "none"
|
||||
let prLabel = snapshot.pullRequest.map { "#\($0.number):\($0.status.rawValue)" } ?? "none"
|
||||
dlog(
|
||||
"workspace.gitProbe.apply workspace=\(workspaceId.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0)"
|
||||
"panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0) " +
|
||||
"pr=\(prLabel)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
|
@ -1136,36 +1221,233 @@ class TabManager: ObservableObject {
|
|||
) -> InitialWorkspaceGitMetadataSnapshot {
|
||||
let branch = normalizedBranchName(runGitCommand(directory: directory, arguments: ["branch", "--show-current"]))
|
||||
guard let branch else {
|
||||
return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false)
|
||||
return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false, pullRequest: nil)
|
||||
}
|
||||
|
||||
let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"])
|
||||
let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
|
||||
return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty)
|
||||
let pullRequest = initialWorkspacePullRequestSnapshot(directory: directory, branch: branch)
|
||||
return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty, pullRequest: pullRequest)
|
||||
}
|
||||
|
||||
private nonisolated static func runGitCommand(directory: String, arguments: [String]) -> String? {
|
||||
runCommand(
|
||||
directory: directory,
|
||||
executable: "git",
|
||||
arguments: arguments
|
||||
)
|
||||
}
|
||||
|
||||
private nonisolated static func initialWorkspacePullRequestSnapshot(
|
||||
directory: String,
|
||||
branch: String
|
||||
) -> SidebarPullRequestState? {
|
||||
let repoSlug = githubRepositorySlug(directory: directory)
|
||||
let repoArguments = repoSlug.map { ["--repo", $0] } ?? []
|
||||
let result = runCommandResult(
|
||||
directory: directory,
|
||||
executable: "gh",
|
||||
arguments: [
|
||||
"pr", "view", branch,
|
||||
] + repoArguments + [
|
||||
"--json", "number,state,url",
|
||||
"--jq", "[.number, .state, .url] | @tsv",
|
||||
],
|
||||
timeout: initialWorkspacePullRequestProbeTimeout
|
||||
)
|
||||
|
||||
guard let result else { return nil }
|
||||
guard let output = result.stdout,
|
||||
result.exitStatus == 0,
|
||||
!output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
#if DEBUG
|
||||
let statusText: String
|
||||
if result.timedOut {
|
||||
statusText = "timeout"
|
||||
} else if let exitStatus = result.exitStatus {
|
||||
statusText = "exit=\(exitStatus)"
|
||||
} else if let executionError = result.executionError {
|
||||
statusText = "error=\(executionError)"
|
||||
} else {
|
||||
statusText = "unknown"
|
||||
}
|
||||
let stderr = debugLogSnippet(result.stderr) ?? "none"
|
||||
dlog(
|
||||
"workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " +
|
||||
"repo=\(repoSlug ?? "none") status=\(statusText) stderr=\(stderr)"
|
||||
)
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fields = trimmedOutput
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.split(separator: "\t", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
guard fields.count == 3,
|
||||
let number = Int(fields[0]),
|
||||
let url = URL(string: String(fields[2])) else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"workspace.gitProbe.pr.parseFail dir=\(directory) branch=\(branch) " +
|
||||
"repo=\(repoSlug ?? "none") output=\(debugLogSnippet(trimmedOutput) ?? "none")"
|
||||
)
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
let status: SidebarPullRequestStatus
|
||||
switch fields[1].uppercased() {
|
||||
case "OPEN":
|
||||
status = .open
|
||||
case "MERGED":
|
||||
status = .merged
|
||||
case "CLOSED":
|
||||
status = .closed
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"workspace.gitProbe.pr.success dir=\(directory) branch=\(branch) " +
|
||||
"repo=\(repoSlug ?? "none") number=\(number) state=\(status.rawValue)"
|
||||
)
|
||||
#endif
|
||||
return SidebarPullRequestState(number: number, label: "PR", url: url, status: status)
|
||||
}
|
||||
|
||||
private nonisolated static func runCommand(
|
||||
directory: String,
|
||||
executable: String,
|
||||
arguments: [String],
|
||||
timeout: TimeInterval? = nil
|
||||
) -> String? {
|
||||
let result = runCommandResult(
|
||||
directory: directory,
|
||||
executable: executable,
|
||||
arguments: arguments,
|
||||
timeout: timeout
|
||||
)
|
||||
guard let result,
|
||||
result.exitStatus == 0,
|
||||
!result.timedOut else {
|
||||
return nil
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
private nonisolated static func runCommandResult(
|
||||
directory: String,
|
||||
executable: String,
|
||||
arguments: [String],
|
||||
timeout: TimeInterval? = nil
|
||||
) -> CommandResult? {
|
||||
let process = Process()
|
||||
let stdout = Pipe()
|
||||
let stderr = Pipe()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
process.arguments = ["git", "-C", directory] + arguments
|
||||
process.arguments = [executable] + arguments
|
||||
process.currentDirectoryURL = URL(fileURLWithPath: directory)
|
||||
process.standardOutput = stdout
|
||||
process.standardError = FileHandle.nullDevice
|
||||
process.standardError = stderr
|
||||
|
||||
let completion = DispatchSemaphore(value: 0)
|
||||
process.terminationHandler = { _ in
|
||||
completion.signal()
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return CommandResult(
|
||||
stdout: nil,
|
||||
stderr: nil,
|
||||
exitStatus: nil,
|
||||
timedOut: false,
|
||||
executionError: String(describing: error)
|
||||
)
|
||||
}
|
||||
|
||||
if let timeout,
|
||||
completion.wait(timeout: .now() + timeout) == .timedOut {
|
||||
process.terminate()
|
||||
if completion.wait(timeout: .now() + 0.2) == .timedOut {
|
||||
kill(process.processIdentifier, SIGKILL)
|
||||
_ = completion.wait(timeout: .now() + 0.2)
|
||||
}
|
||||
return CommandResult(
|
||||
stdout: nil,
|
||||
stderr: nil,
|
||||
exitStatus: nil,
|
||||
timedOut: true,
|
||||
executionError: nil
|
||||
)
|
||||
} else if timeout == nil {
|
||||
completion.wait()
|
||||
}
|
||||
|
||||
let stdoutData = stdout.fileHandleForReading.readDataToEndOfFile()
|
||||
let stderrData = stderr.fileHandleForReading.readDataToEndOfFile()
|
||||
return CommandResult(
|
||||
stdout: String(data: stdoutData, encoding: .utf8),
|
||||
stderr: String(data: stderrData, encoding: .utf8),
|
||||
exitStatus: process.terminationStatus,
|
||||
timedOut: false,
|
||||
executionError: nil
|
||||
)
|
||||
}
|
||||
|
||||
private nonisolated static func githubRepositorySlug(directory: String) -> String? {
|
||||
guard let remoteURL = runGitCommand(
|
||||
directory: directory,
|
||||
arguments: ["remote", "get-url", "origin"]
|
||||
) else {
|
||||
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 {
|
||||
let trimmed = remoteURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
let githubPrefixes = [
|
||||
"git@github.com:",
|
||||
"ssh://git@github.com/",
|
||||
"https://github.com/",
|
||||
"http://github.com/",
|
||||
"git://github.com/",
|
||||
]
|
||||
for prefix in githubPrefixes where trimmed.hasPrefix(prefix) {
|
||||
let path = String(trimmed.dropFirst(prefix.count))
|
||||
return normalizedGitHubRepositorySlug(path)
|
||||
}
|
||||
|
||||
guard let url = URL(string: trimmed),
|
||||
let host = url.host?.lowercased(),
|
||||
host == "github.com" else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return String(data: data, encoding: .utf8)
|
||||
return normalizedGitHubRepositorySlug(url.path)
|
||||
}
|
||||
|
||||
private nonisolated static func normalizedGitHubRepositorySlug(_ rawPath: String) -> String? {
|
||||
let trimmedPath = rawPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
guard !trimmedPath.isEmpty else { return nil }
|
||||
let components = trimmedPath.split(separator: "/").map(String.init)
|
||||
guard components.count >= 2 else { return nil }
|
||||
let owner = components[0]
|
||||
var repo = components[1]
|
||||
if repo.hasSuffix(".git") {
|
||||
repo.removeLast(4)
|
||||
}
|
||||
guard !owner.isEmpty, !repo.isEmpty else { return nil }
|
||||
return "\(owner)/\(repo)"
|
||||
}
|
||||
|
||||
private nonisolated static func debugLogSnippet(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return String(trimmed.prefix(180))
|
||||
}
|
||||
|
||||
private nonisolated static func normalizedBranchName(_ branch: String?) -> String? {
|
||||
|
|
@ -1353,10 +1635,11 @@ class TabManager: ObservableObject {
|
|||
guard let currentIndex = tabs.firstIndex(where: { $0.id == tabId }) else { return false }
|
||||
if tabs.count <= 1 { return true }
|
||||
|
||||
let clamped = max(0, min(targetIndex, tabs.count - 1))
|
||||
let workspace = tabs[currentIndex]
|
||||
let clamped = clampedReorderIndex(for: workspace, targetIndex: targetIndex)
|
||||
if currentIndex == clamped { return true }
|
||||
|
||||
let workspace = tabs.remove(at: currentIndex)
|
||||
tabs.remove(at: currentIndex)
|
||||
tabs.insert(workspace, at: clamped)
|
||||
return true
|
||||
}
|
||||
|
|
@ -1412,6 +1695,15 @@ class TabManager: ObservableObject {
|
|||
tabs.insert(tab, at: insertIndex)
|
||||
}
|
||||
|
||||
private func clampedReorderIndex(for workspace: Workspace, targetIndex: Int) -> Int {
|
||||
let clamped = max(0, min(targetIndex, tabs.count - 1))
|
||||
let pinnedCount = tabs.filter { $0.isPinned }.count
|
||||
if workspace.isPinned {
|
||||
return min(clamped, max(0, pinnedCount - 1))
|
||||
}
|
||||
return max(clamped, pinnedCount)
|
||||
}
|
||||
|
||||
// MARK: - Surface Directory Updates (Backwards Compatibility)
|
||||
|
||||
func updateSurfaceDirectory(tabId: UUID, surfaceId: UUID, directory: String) {
|
||||
|
|
@ -2686,6 +2978,7 @@ class TabManager: ObservableObject {
|
|||
orientation: SplitOrientation,
|
||||
insertFirst: Bool = false,
|
||||
url: URL? = nil,
|
||||
preferredProfileID: UUID? = nil,
|
||||
focus: Bool = true
|
||||
) -> UUID? {
|
||||
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
|
||||
|
|
@ -2694,14 +2987,24 @@ class TabManager: ObservableObject {
|
|||
orientation: orientation,
|
||||
insertFirst: insertFirst,
|
||||
url: url,
|
||||
preferredProfileID: preferredProfileID,
|
||||
focus: focus
|
||||
)?.id
|
||||
}
|
||||
|
||||
/// Create a new browser surface in a pane
|
||||
func newBrowserSurface(tabId: UUID, inPane paneId: PaneID, url: URL? = nil) -> UUID? {
|
||||
func newBrowserSurface(
|
||||
tabId: UUID,
|
||||
inPane paneId: PaneID,
|
||||
url: URL? = nil,
|
||||
preferredProfileID: UUID? = nil
|
||||
) -> UUID? {
|
||||
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
|
||||
return tab.newBrowserSurface(inPane: paneId, url: url)?.id
|
||||
return tab.newBrowserSurface(
|
||||
inPane: paneId,
|
||||
url: url,
|
||||
preferredProfileID: preferredProfileID
|
||||
)?.id
|
||||
}
|
||||
|
||||
/// Get a browser panel by ID
|
||||
|
|
@ -2716,6 +3019,7 @@ class TabManager: ObservableObject {
|
|||
inWorkspace tabId: UUID,
|
||||
url: URL? = nil,
|
||||
preferSplitRight: Bool = false,
|
||||
preferredProfileID: UUID? = nil,
|
||||
insertAtEnd: Bool = false
|
||||
) -> UUID? {
|
||||
guard let workspace = tabs.first(where: { $0.id == tabId }) else { return nil }
|
||||
|
|
@ -2729,7 +3033,8 @@ class TabManager: ObservableObject {
|
|||
inPane: targetPaneId,
|
||||
url: url,
|
||||
focus: true,
|
||||
insertAtEnd: insertAtEnd
|
||||
insertAtEnd: insertAtEnd,
|
||||
preferredProfileID: preferredProfileID
|
||||
) {
|
||||
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
|
||||
return browserPanel.id
|
||||
|
|
@ -2755,6 +3060,7 @@ class TabManager: ObservableObject {
|
|||
from: splitSourcePanelId,
|
||||
orientation: .horizontal,
|
||||
url: url,
|
||||
preferredProfileID: preferredProfileID,
|
||||
focus: true
|
||||
) {
|
||||
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
|
||||
|
|
@ -2767,7 +3073,8 @@ class TabManager: ObservableObject {
|
|||
inPane: paneId,
|
||||
url: url,
|
||||
focus: true,
|
||||
insertAtEnd: insertAtEnd
|
||||
insertAtEnd: insertAtEnd,
|
||||
preferredProfileID: preferredProfileID
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -2777,12 +3084,17 @@ class TabManager: ObservableObject {
|
|||
|
||||
/// Open a browser in the currently focused pane (as a new surface)
|
||||
@discardableResult
|
||||
func openBrowser(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? {
|
||||
func openBrowser(
|
||||
url: URL? = nil,
|
||||
preferredProfileID: UUID? = nil,
|
||||
insertAtEnd: Bool = false
|
||||
) -> UUID? {
|
||||
guard let tabId = selectedTabId else { return nil }
|
||||
return openBrowser(
|
||||
inWorkspace: tabId,
|
||||
url: url,
|
||||
preferSplitRight: false,
|
||||
preferredProfileID: preferredProfileID,
|
||||
insertAtEnd: insertAtEnd
|
||||
)
|
||||
}
|
||||
|
|
@ -2878,7 +3190,12 @@ class TabManager: ObservableObject {
|
|||
in workspace: Workspace
|
||||
) -> UUID? {
|
||||
if let originalPane = workspace.bonsplitController.allPaneIds.first(where: { $0.id == snapshot.originalPaneId }),
|
||||
let browserPanel = workspace.newBrowserSurface(inPane: originalPane, url: snapshot.url, focus: true) {
|
||||
let browserPanel = workspace.newBrowserSurface(
|
||||
inPane: originalPane,
|
||||
url: snapshot.url,
|
||||
focus: true,
|
||||
preferredProfileID: snapshot.profileID
|
||||
) {
|
||||
let tabCount = workspace.bonsplitController.tabs(inPane: originalPane).count
|
||||
let maxIndex = max(0, tabCount - 1)
|
||||
let targetIndex = min(max(snapshot.originalTabIndex, 0), maxIndex)
|
||||
|
|
@ -2895,7 +3212,8 @@ class TabManager: ObservableObject {
|
|||
from: anchorPanelId,
|
||||
orientation: orientation,
|
||||
insertFirst: snapshot.fallbackSplitInsertFirst,
|
||||
url: snapshot.url
|
||||
url: snapshot.url,
|
||||
preferredProfileID: snapshot.profileID
|
||||
)?.id {
|
||||
return browserPanelId
|
||||
}
|
||||
|
|
@ -2903,7 +3221,12 @@ class TabManager: ObservableObject {
|
|||
guard let focusedPane = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else {
|
||||
return nil
|
||||
}
|
||||
return workspace.newBrowserSurface(inPane: focusedPane, url: snapshot.url, focus: true)?.id
|
||||
return workspace.newBrowserSurface(
|
||||
inPane: focusedPane,
|
||||
url: snapshot.url,
|
||||
focus: true,
|
||||
preferredProfileID: snapshot.profileID
|
||||
)?.id
|
||||
}
|
||||
|
||||
/// Flash the currently focused panel so the user can visually confirm focus.
|
||||
|
|
@ -4081,6 +4404,9 @@ extension TabManager {
|
|||
for tab in tabs {
|
||||
unwireClosedBrowserTracking(for: tab)
|
||||
}
|
||||
for workspaceId in Array(initialWorkspaceGitProbeGenerationByWorkspace.keys) {
|
||||
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
|
||||
}
|
||||
|
||||
// Clear non-@Published state without touching tabs/selectedTabId yet.
|
||||
lastFocusedPanelByTab.removeAll()
|
||||
|
|
@ -4137,6 +4463,21 @@ extension TabManager {
|
|||
// never see an intermediate state with empty tabs or nil selection.
|
||||
tabs = newTabs
|
||||
selectedTabId = newSelectedId
|
||||
for workspace in newTabs {
|
||||
guard let terminalPanel = workspace.focusedTerminalPanel ?? workspace.panels.values
|
||||
.compactMap({ $0 as? TerminalPanel })
|
||||
.first,
|
||||
let directory = normalizedWorkingDirectory(
|
||||
workspace.panelDirectories[terminalPanel.id] ?? workspace.currentDirectory
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
scheduleInitialWorkspaceGitMetadataRefresh(
|
||||
workspaceId: workspace.id,
|
||||
panelId: terminalPanel.id,
|
||||
directory: directory
|
||||
)
|
||||
}
|
||||
|
||||
if let selectedTabId {
|
||||
NotificationCenter.default.post(
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ class TerminalController {
|
|||
private nonisolated static let socketProbePollTimeoutMs: Int32 = 100
|
||||
private nonisolated static let socketProbePollAttempts = 3
|
||||
private nonisolated static let socketProbePollRetryBackoffUs: useconds_t = 50_000
|
||||
private nonisolated static let socketListenerFailureCaptureCooldown: TimeInterval = 60
|
||||
private nonisolated static let socketListenerFailureCaptureLock = NSLock()
|
||||
private nonisolated(unsafe) static var socketListenerFailureLastCapturedAt: [String: Date] = [:]
|
||||
private nonisolated static let unixSocketPathMaxLength: Int = {
|
||||
var addr = sockaddr_un()
|
||||
// Reserve one byte for the null terminator.
|
||||
|
|
@ -555,9 +558,35 @@ class TerminalController {
|
|||
) {
|
||||
let data = socketListenerEventData(stage: stage, errnoCode: errnoCode, extra: extra)
|
||||
sentryBreadcrumb(message, category: "socket", data: data)
|
||||
guard Self.shouldCaptureSocketListenerFailure(
|
||||
message: message,
|
||||
stage: stage,
|
||||
path: data["path"] as? String ?? "",
|
||||
errnoCode: errnoCode
|
||||
) else {
|
||||
return
|
||||
}
|
||||
sentryCaptureError(message, category: "socket", data: data, contextKey: "socket_listener")
|
||||
}
|
||||
|
||||
private nonisolated static func shouldCaptureSocketListenerFailure(
|
||||
message: String,
|
||||
stage: String,
|
||||
path: String,
|
||||
errnoCode: Int32?
|
||||
) -> Bool {
|
||||
let key = "\(message)|\(stage)|\(path)|\(errnoCode.map(String.init) ?? "none")"
|
||||
let now = Date()
|
||||
socketListenerFailureCaptureLock.lock()
|
||||
defer { socketListenerFailureCaptureLock.unlock() }
|
||||
if let lastCapturedAt = socketListenerFailureLastCapturedAt[key],
|
||||
now.timeIntervalSince(lastCapturedAt) < socketListenerFailureCaptureCooldown {
|
||||
return false
|
||||
}
|
||||
socketListenerFailureLastCapturedAt[key] = now
|
||||
return true
|
||||
}
|
||||
|
||||
nonisolated static func acceptErrorClassification(errnoCode: Int32) -> String {
|
||||
switch errnoCode {
|
||||
case EINTR, ECONNABORTED, EAGAIN, EWOULDBLOCK:
|
||||
|
|
@ -1572,6 +1601,12 @@ class TerminalController {
|
|||
case "clear_status":
|
||||
return clearStatus(args)
|
||||
|
||||
case "set_agent_pid":
|
||||
return setAgentPID(args)
|
||||
|
||||
case "clear_agent_pid":
|
||||
return clearAgentPID(args)
|
||||
|
||||
case "clear_meta":
|
||||
return clearMeta(args)
|
||||
|
||||
|
|
@ -13394,6 +13429,61 @@ class TerminalController {
|
|||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func schedulePanelMetadataMutation(
|
||||
args: String,
|
||||
options: [String: String],
|
||||
missingPanelUsage: String,
|
||||
mutation: @escaping (Tab, UUID) -> Void
|
||||
) -> String {
|
||||
let rawPanelArg = options["panel"] ?? options["surface"]
|
||||
let surfaceIdFromOptions: UUID?
|
||||
if let rawPanelArg {
|
||||
if rawPanelArg.isEmpty {
|
||||
return "ERROR: Missing panel id — usage: \(missingPanelUsage)"
|
||||
}
|
||||
guard let surfaceId = UUID(uuidString: rawPanelArg) else {
|
||||
return "ERROR: Invalid panel id '\(rawPanelArg)'"
|
||||
}
|
||||
surfaceIdFromOptions = surfaceId
|
||||
} else {
|
||||
surfaceIdFromOptions = nil
|
||||
}
|
||||
|
||||
if let tabArg = options["tab"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!tabArg.isEmpty,
|
||||
UUID(uuidString: tabArg) == nil,
|
||||
Int(tabArg) == nil {
|
||||
return "ERROR: Tab not found"
|
||||
}
|
||||
|
||||
if let scope = Self.explicitSocketScope(options: options) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self,
|
||||
let tab = self.tabForSidebarMutation(id: scope.workspaceId) else {
|
||||
return
|
||||
}
|
||||
let validSurfaceIds = Set(tab.panels.keys)
|
||||
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
||||
guard validSurfaceIds.contains(scope.panelId) else { return }
|
||||
mutation(tab, scope.panelId)
|
||||
}
|
||||
return "OK"
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self,
|
||||
let tab = self.resolveTabForReport(args) else {
|
||||
return
|
||||
}
|
||||
let validSurfaceIds = Set(tab.panels.keys)
|
||||
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
||||
guard let surfaceId = surfaceIdFromOptions ?? tab.focusedPanelId else { return }
|
||||
guard validSurfaceIds.contains(surfaceId) else { return }
|
||||
mutation(tab, surfaceId)
|
||||
}
|
||||
return "OK"
|
||||
}
|
||||
|
||||
private func upsertSidebarMetadata(_ args: String, missingError: String) -> String {
|
||||
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
||||
let parsed = parseOptionsNoStop(args)
|
||||
|
|
@ -13436,6 +13526,14 @@ class TerminalController {
|
|||
return tabResolution.error ?? "ERROR: No tab selected"
|
||||
}
|
||||
|
||||
let pidValue: pid_t? = {
|
||||
if let rawPid = normalizedOptionValue(parsed.options["pid"]),
|
||||
let p = Int32(rawPid), p > 0 {
|
||||
return p
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return }
|
||||
guard Self.shouldReplaceStatusEntry(
|
||||
|
|
@ -13448,6 +13546,10 @@ class TerminalController {
|
|||
priority: priority,
|
||||
format: format
|
||||
) else {
|
||||
// Still update PID tracking even if the status display hasn't changed.
|
||||
if let pidValue {
|
||||
tab.agentPIDs[key] = pidValue
|
||||
}
|
||||
return
|
||||
}
|
||||
tab.statusEntries[key] = SidebarStatusEntry(
|
||||
|
|
@ -13460,6 +13562,9 @@ class TerminalController {
|
|||
format: format,
|
||||
timestamp: Date()
|
||||
)
|
||||
if let pidValue {
|
||||
tab.agentPIDs[key] = pidValue
|
||||
}
|
||||
}
|
||||
return "OK"
|
||||
}
|
||||
|
|
@ -13479,10 +13584,50 @@ class TerminalController {
|
|||
if tab.statusEntries.removeValue(forKey: key) == nil {
|
||||
result = "OK (key not found)"
|
||||
}
|
||||
tab.agentPIDs.removeValue(forKey: key)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Register an agent PID for stale-session detection without setting a visible status entry.
|
||||
/// Usage: set_agent_pid <key> <pid> [--tab=<id>]
|
||||
private func setAgentPID(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
guard parsed.positional.count >= 2,
|
||||
let pid = Int32(parsed.positional[1]), pid > 0 else {
|
||||
return "ERROR: Usage: set_agent_pid <key> <pid> [--tab=<id>]"
|
||||
}
|
||||
let key = parsed.positional[0]
|
||||
let tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options)
|
||||
guard let targetTabId = tabResolution.tabId else {
|
||||
return tabResolution.error ?? "ERROR: No tab selected"
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return }
|
||||
tab.agentPIDs[key] = pid
|
||||
}
|
||||
return "OK"
|
||||
}
|
||||
|
||||
/// Unregister an agent PID. Usage: clear_agent_pid <key> [--tab=<id>]
|
||||
private func clearAgentPID(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
guard let key = parsed.positional.first else {
|
||||
return "ERROR: Usage: clear_agent_pid <key> [--tab=<id>]"
|
||||
}
|
||||
// Resolve tab ID synchronously before dispatching to avoid
|
||||
// racing against selection changes on the main queue.
|
||||
let tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options)
|
||||
guard let targetTabId = tabResolution.tabId else {
|
||||
return tabResolution.error ?? "ERROR: No tab selected"
|
||||
}
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return }
|
||||
tab.agentPIDs.removeValue(forKey: key)
|
||||
}
|
||||
return "OK"
|
||||
}
|
||||
|
||||
private func sidebarMetadataLine(_ entry: SidebarStatusEntry) -> String {
|
||||
var line = "\(entry.key)=\(entry.value)"
|
||||
if let icon = entry.icon { line += " icon=\(icon)" }
|
||||
|
|
@ -13863,40 +14008,13 @@ class TerminalController {
|
|||
}
|
||||
let label = String(labelRaw.prefix(16))
|
||||
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
||||
return
|
||||
}
|
||||
let validSurfaceIds = Set(tab.panels.keys)
|
||||
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
||||
|
||||
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
|
||||
let surfaceId: UUID
|
||||
if let panelArg {
|
||||
if panelArg.isEmpty {
|
||||
result = "ERROR: Missing panel id — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]"
|
||||
return
|
||||
}
|
||||
guard let parsedId = UUID(uuidString: panelArg) else {
|
||||
result = "ERROR: Invalid panel id '\(panelArg)'"
|
||||
return
|
||||
}
|
||||
surfaceId = parsedId
|
||||
} else {
|
||||
guard let focused = tab.focusedPanelId else {
|
||||
result = "ERROR: Missing panel id (no focused surface)"
|
||||
return
|
||||
}
|
||||
surfaceId = focused
|
||||
}
|
||||
|
||||
guard validSurfaceIds.contains(surfaceId) else {
|
||||
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
|
||||
return
|
||||
}
|
||||
|
||||
// Shell integration provides explicit workspace/panel UUIDs for browser metadata.
|
||||
// Keep this telemetry path off-main so SwiftUI render passes can't deadlock the socket handler.
|
||||
return schedulePanelMetadataMutation(
|
||||
args: args,
|
||||
options: parsed.options,
|
||||
missingPanelUsage: "report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]"
|
||||
) { tab, surfaceId in
|
||||
guard Self.shouldReplacePullRequest(
|
||||
current: tab.panelPullRequests[surfaceId],
|
||||
number: number,
|
||||
|
|
@ -13915,48 +14033,17 @@ class TerminalController {
|
|||
status: status
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func clearPullRequest(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
||||
return
|
||||
}
|
||||
let validSurfaceIds = Set(tab.panels.keys)
|
||||
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
||||
|
||||
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
|
||||
let surfaceId: UUID
|
||||
if let panelArg {
|
||||
if panelArg.isEmpty {
|
||||
result = "ERROR: Missing panel id — usage: clear_pr [--tab=X] [--panel=Y]"
|
||||
return
|
||||
}
|
||||
guard let parsedId = UUID(uuidString: panelArg) else {
|
||||
result = "ERROR: Invalid panel id '\(panelArg)'"
|
||||
return
|
||||
}
|
||||
surfaceId = parsedId
|
||||
} else {
|
||||
guard let focused = tab.focusedPanelId else {
|
||||
result = "ERROR: Missing panel id (no focused surface)"
|
||||
return
|
||||
}
|
||||
surfaceId = focused
|
||||
}
|
||||
|
||||
guard validSurfaceIds.contains(surfaceId) else {
|
||||
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
|
||||
return
|
||||
}
|
||||
|
||||
return schedulePanelMetadataMutation(
|
||||
args: args,
|
||||
options: parsed.options,
|
||||
missingPanelUsage: "clear_pr [--tab=X] [--panel=Y]"
|
||||
) { tab, surfaceId in
|
||||
tab.clearPanelPullRequest(panelId: surfaceId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func reportPorts(_ args: String) -> String {
|
||||
|
|
|
|||
|
|
@ -27,10 +27,10 @@ class UpdateController {
|
|||
}
|
||||
|
||||
init() {
|
||||
// Default to manual update checks. This also prevents Sparkle from prompting at startup.
|
||||
// cmux checks for updates in the background, but keeps automatic download and
|
||||
// profile submission disabled so all install intent stays user-driven.
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.register(defaults: [
|
||||
"SUEnableAutomaticChecks": false,
|
||||
"SUSendProfileInfo": false,
|
||||
"SUAutomaticallyUpdate": false,
|
||||
])
|
||||
|
|
@ -59,8 +59,8 @@ class UpdateController {
|
|||
guard !didStartUpdater else { return }
|
||||
ensureSparkleInstallationCache()
|
||||
#if DEBUG
|
||||
// UI tests need to exercise Sparkle's permission request deterministically.
|
||||
// Clearing these defaults causes Sparkle to re-request permission on next start.
|
||||
// Keep the permission-related defaults resettable for UI tests even though the
|
||||
// delegate now suppresses Sparkle's permission UI entirely.
|
||||
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_RESET_SPARKLE_PERMISSION"] == "1" {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.removeObject(forKey: "SUEnableAutomaticChecks")
|
||||
|
|
@ -71,13 +71,9 @@ class UpdateController {
|
|||
}
|
||||
#endif
|
||||
do {
|
||||
// cmux never enables automatic update checks; we rely on the in-app update pill.
|
||||
// Sparkle reads these from defaults, but set them explicitly before starting.
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(false, forKey: "SUEnableAutomaticChecks")
|
||||
defaults.set(false, forKey: "SUSendProfileInfo")
|
||||
defaults.set(false, forKey: "SUAutomaticallyUpdate")
|
||||
|
||||
updater.automaticallyChecksForUpdates = true
|
||||
updater.automaticallyDownloadsUpdates = false
|
||||
updater.sendsSystemProfile = false
|
||||
try updater.start()
|
||||
didStartUpdater = true
|
||||
} catch {
|
||||
|
|
@ -201,7 +197,7 @@ class UpdateController {
|
|||
/// Validate the check for updates menu item.
|
||||
func validateMenuItem(_ item: NSMenuItem) -> Bool {
|
||||
if item.action == #selector(checkForUpdates) {
|
||||
// Always allow user-initiated checks; we start Sparkle lazily on first use.
|
||||
// Always allow user-initiated checks; Sparkle can safely surface current progress.
|
||||
return true
|
||||
}
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ enum UpdateFeedResolver {
|
|||
}
|
||||
|
||||
extension UpdateDriver: SPUUpdaterDelegate {
|
||||
func updaterShouldPromptForPermissionToCheck(forUpdates _: SPUUpdater) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
func feedURLString(for updater: SPUUpdater) -> String? {
|
||||
#if DEBUG
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
|
|
@ -35,6 +39,7 @@ extension UpdateDriver: SPUUpdaterDelegate {
|
|||
/// Called when an update is scheduled to install silently,
|
||||
/// which occurs when automatic download is enabled.
|
||||
func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool {
|
||||
viewModel.clearDetectedUpdate()
|
||||
viewModel.state = .installing(.init(
|
||||
isAutoUpdate: true,
|
||||
retryTerminatingApplication: immediateInstallHandler,
|
||||
|
|
@ -56,6 +61,7 @@ extension UpdateDriver: SPUUpdaterDelegate {
|
|||
}
|
||||
|
||||
func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
|
||||
viewModel.recordDetectedUpdate(item)
|
||||
let version = item.displayVersionString
|
||||
let fileURL = item.fileURL?.absoluteString ?? ""
|
||||
if fileURL.isEmpty {
|
||||
|
|
@ -66,6 +72,7 @@ extension UpdateDriver: SPUUpdaterDelegate {
|
|||
}
|
||||
|
||||
func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) {
|
||||
viewModel.clearDetectedUpdate()
|
||||
let nsError = error as NSError
|
||||
let reasonValue = (nsError.userInfo[SPUNoUpdateFoundReasonKey] as? NSNumber)?.intValue
|
||||
let reason = reasonValue.map { SPUNoUpdateFoundReason(rawValue: OSStatus($0)) } ?? nil
|
||||
|
|
@ -80,13 +87,18 @@ extension UpdateDriver: SPUUpdaterDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func updater(_ updater: SPUUpdater, userDidMake _: SPUUserUpdateChoice, forUpdate _: SUAppcastItem, state _: SPUUserUpdateState) {
|
||||
viewModel.clearDetectedUpdate()
|
||||
}
|
||||
|
||||
func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
|
||||
AppDelegate.shared?.persistSessionForUpdateRelaunch()
|
||||
TerminalController.shared.stop()
|
||||
NSApp.invalidateRestorableState()
|
||||
for window in NSApp.windows {
|
||||
window.invalidateRestorableState()
|
||||
Task { @MainActor in
|
||||
AppDelegate.shared?.persistSessionForUpdateRelaunch()
|
||||
TerminalController.shared.stop()
|
||||
NSApp.invalidateRestorableState()
|
||||
for window in NSApp.windows {
|
||||
window.invalidateRestorableState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,14 @@ enum UpdateTestSupport {
|
|||
static func applyIfNeeded(to viewModel: UpdateViewModel) {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard env["CMUX_UI_TEST_MODE"] == "1" else { return }
|
||||
|
||||
if let detectedVersion = env["CMUX_UI_TEST_DETECTED_UPDATE_VERSION"],
|
||||
!detectedVersion.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
viewModel.detectedUpdateVersion = UpdateViewModel.normalizedDetectedUpdateVersion(from: detectedVersion)
|
||||
}
|
||||
}
|
||||
|
||||
guard let state = env["CMUX_UI_TEST_UPDATE_STATE"] else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import Sparkle
|
|||
class UpdateViewModel: ObservableObject {
|
||||
@Published var state: UpdateState = .idle
|
||||
@Published var overrideState: UpdateState?
|
||||
@Published var detectedUpdateVersion: String?
|
||||
#if DEBUG
|
||||
@Published var debugOverrideText: String?
|
||||
#endif
|
||||
|
|
@ -14,6 +15,14 @@ class UpdateViewModel: ObservableObject {
|
|||
overrideState ?? state
|
||||
}
|
||||
|
||||
func recordDetectedUpdate(_ item: SUAppcastItem) {
|
||||
detectedUpdateVersion = Self.normalizedDetectedUpdateVersion(from: item.displayVersionString)
|
||||
}
|
||||
|
||||
func clearDetectedUpdate() {
|
||||
detectedUpdateVersion = nil
|
||||
}
|
||||
|
||||
var text: String {
|
||||
#if DEBUG
|
||||
if let debugOverrideText { return debugOverrideText }
|
||||
|
|
@ -334,6 +343,11 @@ class UpdateViewModel: ObservableObject {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func normalizedDetectedUpdateVersion(from version: String) -> String? {
|
||||
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
enum UpdateState: Equatable {
|
||||
|
|
|
|||
|
|
@ -240,20 +240,11 @@ extension Workspace {
|
|||
setCustomColor(snapshot.customColor)
|
||||
isPinned = snapshot.isPinned
|
||||
|
||||
statusEntries = Dictionary(
|
||||
uniqueKeysWithValues: snapshot.statusEntries.map { entry in
|
||||
(
|
||||
entry.key,
|
||||
SidebarStatusEntry(
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
icon: entry.icon,
|
||||
color: entry.color,
|
||||
timestamp: Date(timeIntervalSince1970: entry.timestamp)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
// Status entries and agent PIDs are ephemeral runtime state tied to running
|
||||
// processes (e.g. claude_code "Running"). Don't restore them across app
|
||||
// restarts because the processes that set them are gone.
|
||||
statusEntries.removeAll()
|
||||
agentPIDs.removeAll()
|
||||
logEntries = snapshot.logEntries.map { entry in
|
||||
SidebarLogEntry(
|
||||
message: entry.message,
|
||||
|
|
@ -382,6 +373,7 @@ extension Workspace {
|
|||
let historySnapshot = browserPanel.sessionNavigationHistorySnapshot()
|
||||
browserSnapshot = SessionBrowserPanelSnapshot(
|
||||
urlString: browserPanel.preferredURLStringForOmnibar(),
|
||||
profileID: browserPanel.profileID,
|
||||
shouldRenderWebView: browserPanel.shouldRenderWebView,
|
||||
pageZoom: Double(browserPanel.currentPageZoomFactor()),
|
||||
developerToolsVisible: browserPanel.isDeveloperToolsVisible(),
|
||||
|
|
@ -567,7 +559,8 @@ extension Workspace {
|
|||
guard let browserPanel = newBrowserSurface(
|
||||
inPane: paneId,
|
||||
url: initialURL,
|
||||
focus: false
|
||||
focus: false,
|
||||
preferredProfileID: snapshot.browser?.profileID
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -4517,6 +4510,7 @@ enum SidebarBranchOrdering {
|
|||
struct ClosedBrowserPanelRestoreSnapshot {
|
||||
let workspaceId: UUID
|
||||
let url: URL?
|
||||
let profileID: UUID?
|
||||
let originalPaneId: UUID
|
||||
let originalTabIndex: Int
|
||||
let fallbackSplitOrientation: SplitOrientation?
|
||||
|
|
@ -4534,6 +4528,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
@Published var isPinned: Bool = false
|
||||
@Published var customColor: String? // hex string, e.g. "#C0392B"
|
||||
@Published var currentDirectory: String
|
||||
private(set) var preferredBrowserProfileID: UUID?
|
||||
|
||||
/// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session)
|
||||
var portOrdinal: Int = 0
|
||||
|
|
@ -4641,6 +4636,9 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
return formatter
|
||||
}()
|
||||
private var panelShellActivityStates: [UUID: PanelShellActivityState] = [:]
|
||||
/// PIDs associated with agent status entries (e.g. claude_code), keyed by status key.
|
||||
/// Used for stale-session detection: if the PID is dead, the status entry is cleared.
|
||||
var agentPIDs: [String: pid_t] = [:]
|
||||
private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:]
|
||||
|
||||
private static func isProxyOnlyRemoteError(_ detail: String) -> Bool {
|
||||
|
|
@ -5014,6 +5012,35 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
)
|
||||
}
|
||||
panelSubscriptions[browserPanel.id] = subscription
|
||||
setPreferredBrowserProfileID(browserPanel.profileID)
|
||||
}
|
||||
|
||||
func setPreferredBrowserProfileID(_ profileID: UUID?) {
|
||||
guard let profileID else {
|
||||
preferredBrowserProfileID = nil
|
||||
return
|
||||
}
|
||||
guard BrowserProfileStore.shared.profileDefinition(id: profileID) != nil else { return }
|
||||
preferredBrowserProfileID = profileID
|
||||
}
|
||||
|
||||
private func resolvedNewBrowserProfileID(
|
||||
preferredProfileID: UUID? = nil,
|
||||
sourcePanelId: UUID? = nil
|
||||
) -> UUID {
|
||||
if let preferredProfileID,
|
||||
BrowserProfileStore.shared.profileDefinition(id: preferredProfileID) != nil {
|
||||
return preferredProfileID
|
||||
}
|
||||
if let sourcePanelId,
|
||||
let sourceBrowserPanel = browserPanel(for: sourcePanelId) {
|
||||
return sourceBrowserPanel.profileID
|
||||
}
|
||||
if let preferredBrowserProfileID,
|
||||
BrowserProfileStore.shared.profileDefinition(id: preferredBrowserProfileID) != nil {
|
||||
return preferredBrowserProfileID
|
||||
}
|
||||
return BrowserProfileStore.shared.effectiveLastUsedProfileID
|
||||
}
|
||||
|
||||
private func installMarkdownPanelSubscription(_ markdownPanel: MarkdownPanel) {
|
||||
|
|
@ -5410,6 +5437,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
func resetSidebarContext(reason: String = "unspecified") {
|
||||
statusEntries.removeAll()
|
||||
agentPIDs.removeAll()
|
||||
logEntries.removeAll()
|
||||
progress = nil
|
||||
gitBranch = nil
|
||||
|
|
@ -6301,6 +6329,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
orientation: SplitOrientation,
|
||||
insertFirst: Bool = false,
|
||||
url: URL? = nil,
|
||||
preferredProfileID: UUID? = nil,
|
||||
focus: Bool = true
|
||||
) -> BrowserPanel? {
|
||||
// Find the pane containing the source panel
|
||||
|
|
@ -6319,6 +6348,10 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
// Create browser panel
|
||||
let browserPanel = BrowserPanel(
|
||||
workspaceId: id,
|
||||
profileID: resolvedNewBrowserProfileID(
|
||||
preferredProfileID: preferredProfileID,
|
||||
sourcePanelId: panelId
|
||||
),
|
||||
initialURL: url,
|
||||
proxyEndpoint: remoteProxyEndpoint,
|
||||
isRemoteWorkspace: isRemoteWorkspace,
|
||||
|
|
@ -6326,6 +6359,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
)
|
||||
panels[browserPanel.id] = browserPanel
|
||||
panelTitles[browserPanel.id] = browserPanel.displayTitle
|
||||
setPreferredBrowserProfileID(browserPanel.profileID)
|
||||
|
||||
// Pre-generate the bonsplit tab ID so the mapping exists before the split lands.
|
||||
let newTab = Bonsplit.Tab(
|
||||
|
|
@ -6382,6 +6416,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
url: URL? = nil,
|
||||
focus: Bool? = nil,
|
||||
insertAtEnd: Bool = false,
|
||||
preferredProfileID: UUID? = nil,
|
||||
bypassInsecureHTTPHostOnce: String? = nil
|
||||
) -> BrowserPanel? {
|
||||
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
|
||||
|
|
@ -6390,6 +6425,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
let browserPanel = BrowserPanel(
|
||||
workspaceId: id,
|
||||
profileID: resolvedNewBrowserProfileID(preferredProfileID: preferredProfileID),
|
||||
initialURL: url,
|
||||
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce,
|
||||
proxyEndpoint: remoteProxyEndpoint,
|
||||
|
|
@ -6398,6 +6434,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
)
|
||||
panels[browserPanel.id] = browserPanel
|
||||
panelTitles[browserPanel.id] = browserPanel.displayTitle
|
||||
setPreferredBrowserProfileID(browserPanel.profileID)
|
||||
|
||||
guard let newTabId = bonsplitController.createTab(
|
||||
title: browserPanel.displayTitle,
|
||||
|
|
@ -6827,6 +6864,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot(
|
||||
workspaceId: id,
|
||||
url: resolvedURL,
|
||||
profileID: browserPanel.profileID,
|
||||
originalPaneId: pane.id,
|
||||
originalTabIndex: tabIndex,
|
||||
fallbackSplitOrientation: fallbackPlan?.orientation,
|
||||
|
|
@ -8010,14 +8048,27 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
private func createBrowserToRight(of anchorTabId: TabID, inPane paneId: PaneID, url: URL? = nil) {
|
||||
let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId)
|
||||
guard let newPanel = newBrowserSurface(inPane: paneId, url: url, focus: true) else { return }
|
||||
let preferredProfileID = panelIdFromSurfaceId(anchorTabId).flatMap { browserPanel(for: $0)?.profileID }
|
||||
guard let newPanel = newBrowserSurface(
|
||||
inPane: paneId,
|
||||
url: url,
|
||||
focus: true,
|
||||
preferredProfileID: preferredProfileID
|
||||
) else { return }
|
||||
_ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
|
||||
}
|
||||
|
||||
private func duplicateBrowserToRight(anchorTabId: TabID, inPane paneId: PaneID) {
|
||||
guard let panelId = panelIdFromSurfaceId(anchorTabId),
|
||||
let browser = browserPanel(for: panelId) else { return }
|
||||
createBrowserToRight(of: anchorTabId, inPane: paneId, url: browser.currentURL)
|
||||
let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId)
|
||||
guard let newPanel = newBrowserSurface(
|
||||
inPane: paneId,
|
||||
url: browser.currentURL,
|
||||
focus: true,
|
||||
preferredProfileID: browser.profileID
|
||||
) else { return }
|
||||
_ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
|
||||
}
|
||||
|
||||
private func promptRenamePanel(tabId: TabID) {
|
||||
|
|
|
|||
|
|
@ -587,6 +587,10 @@ struct cmuxApp: App {
|
|||
BrowserHistoryStore.shared.clearHistory()
|
||||
}
|
||||
|
||||
Button(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…")) {
|
||||
BrowserDataImportCoordinator.shared.presentImportDialog()
|
||||
}
|
||||
|
||||
splitCommandButton(title: String(localized: "menu.view.nextWorkspace", defaultValue: "Next Workspace"), shortcut: nextWorkspaceMenuShortcut) {
|
||||
activeTabManager.selectNextTab()
|
||||
}
|
||||
|
|
@ -645,7 +649,6 @@ struct cmuxApp: App {
|
|||
|
||||
private func showAboutPanel() {
|
||||
AboutWindowController.shared.show()
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
private func applyAppearance() {
|
||||
|
|
@ -1033,8 +1036,8 @@ struct cmuxApp: App {
|
|||
}
|
||||
|
||||
private func closePanelOrWindow() {
|
||||
if let window = NSApp.keyWindow,
|
||||
window.identifier?.rawValue == "cmux.settings" {
|
||||
if let window = NSApp.keyWindow ?? NSApp.mainWindow,
|
||||
cmuxWindowShouldOwnCloseShortcut(window) {
|
||||
window.performClose(nil)
|
||||
return
|
||||
}
|
||||
|
|
@ -1061,6 +1064,26 @@ struct cmuxApp: App {
|
|||
}
|
||||
}
|
||||
|
||||
private let cmuxAuxiliaryWindowIdentifiers: Set<String> = [
|
||||
"cmux.settings",
|
||||
"cmux.about",
|
||||
"cmux.licenses",
|
||||
"cmux.browser-popup",
|
||||
"cmux.settingsAboutTitlebarDebug",
|
||||
"cmux.debugWindowControls",
|
||||
"cmux.sidebarDebug",
|
||||
"cmux.menubarDebug",
|
||||
"cmux.backgroundDebug",
|
||||
]
|
||||
|
||||
/// Returns whether the given window should handle the standard close shortcut
|
||||
/// as a standalone auxiliary window instead of routing it through workspace or
|
||||
/// panel-close behavior.
|
||||
func cmuxWindowShouldOwnCloseShortcut(_ window: NSWindow?) -> Bool {
|
||||
guard let identifier = window?.identifier?.rawValue else { return false }
|
||||
return cmuxAuxiliaryWindowIdentifiers.contains(identifier)
|
||||
}
|
||||
|
||||
private enum SettingsAboutWindowKind: String, CaseIterable, Identifiable {
|
||||
case settings
|
||||
case about
|
||||
|
|
@ -1526,6 +1549,8 @@ private enum DebugWindowConfigSnapshot {
|
|||
sidebarState=\(stringValue(defaults, key: "sidebarState", fallback: SidebarStateOption.followWindow.rawValue))
|
||||
sidebarBlurOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarBlurOpacity", fallback: 1.0)))
|
||||
sidebarTintHex=\(stringValue(defaults, key: "sidebarTintHex", fallback: "#000000"))
|
||||
sidebarTintHexLight=\(stringValue(defaults, key: "sidebarTintHexLight", fallback: "(nil)"))
|
||||
sidebarTintHexDark=\(stringValue(defaults, key: "sidebarTintHexDark", fallback: "(nil)"))
|
||||
sidebarTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarTintOpacity", fallback: 0.18)))
|
||||
sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0)))
|
||||
sidebarBranchVerticalLayout=\(boolValue(defaults, key: SidebarBranchLayoutSettings.key, fallback: SidebarBranchLayoutSettings.defaultVerticalLayout))
|
||||
|
|
@ -2153,8 +2178,10 @@ private struct AboutPanelView: View {
|
|||
|
||||
private struct SidebarDebugView: View {
|
||||
@AppStorage("sidebarPreset") private var sidebarPreset = SidebarPresetOption.nativeSidebar.rawValue
|
||||
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = 0.18
|
||||
@AppStorage("sidebarTintHex") private var sidebarTintHex = "#000000"
|
||||
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity
|
||||
@AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex
|
||||
@AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String?
|
||||
@AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String?
|
||||
@AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue
|
||||
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue
|
||||
@AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue
|
||||
|
|
@ -2308,7 +2335,9 @@ private struct SidebarDebugView: View {
|
|||
HStack(spacing: 12) {
|
||||
Button("Reset Tint") {
|
||||
sidebarTintOpacity = 0.62
|
||||
sidebarTintHex = "#000000"
|
||||
sidebarTintHex = SidebarTintDefaults.hex
|
||||
sidebarTintHexLight = nil
|
||||
sidebarTintHexDark = nil
|
||||
}
|
||||
Button("Reset Blur") {
|
||||
sidebarMaterial = SidebarMaterialOption.hudWindow.rawValue
|
||||
|
|
@ -2389,6 +2418,8 @@ private struct SidebarDebugView: View {
|
|||
sidebarState=\(sidebarState)
|
||||
sidebarBlurOpacity=\(String(format: "%.2f", sidebarBlurOpacity))
|
||||
sidebarTintHex=\(sidebarTintHex)
|
||||
sidebarTintHexLight=\(sidebarTintHexLight ?? "(nil)")
|
||||
sidebarTintHexDark=\(sidebarTintHexDark ?? "(nil)")
|
||||
sidebarTintOpacity=\(String(format: "%.2f", sidebarTintOpacity))
|
||||
sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius))
|
||||
sidebarBranchVerticalLayout=\(sidebarBranchVerticalLayout)
|
||||
|
|
@ -2416,6 +2447,8 @@ private struct SidebarDebugView: View {
|
|||
sidebarTintOpacity = preset.tintOpacity
|
||||
sidebarCornerRadius = preset.cornerRadius
|
||||
sidebarBlurOpacity = preset.blurOpacity
|
||||
sidebarTintHexLight = nil
|
||||
sidebarTintHexDark = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3006,6 +3039,18 @@ enum CommandPaletteRenameSelectionSettings {
|
|||
}
|
||||
}
|
||||
|
||||
enum CommandPaletteSwitcherSearchSettings {
|
||||
static let searchAllSurfacesKey = "commandPalette.switcherSearchAllSurfaces"
|
||||
static let defaultSearchAllSurfaces = false
|
||||
|
||||
static func searchAllSurfacesEnabled(defaults: UserDefaults = .standard) -> Bool {
|
||||
if defaults.object(forKey: searchAllSurfacesKey) == nil {
|
||||
return defaultSearchAllSurfaces
|
||||
}
|
||||
return defaults.bool(forKey: searchAllSurfacesKey)
|
||||
}
|
||||
}
|
||||
|
||||
enum ClaudeCodeIntegrationSettings {
|
||||
static let hooksEnabledKey = "claudeCodeHooksEnabled"
|
||||
static let defaultHooksEnabled = true
|
||||
|
|
@ -3073,6 +3118,8 @@ struct SettingsView: View {
|
|||
@AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
|
||||
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
|
||||
private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
||||
@AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey)
|
||||
private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces
|
||||
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey)
|
||||
private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
||||
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
||||
|
|
@ -3095,6 +3142,10 @@ struct SettingsView: View {
|
|||
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
|
||||
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
|
||||
@AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true
|
||||
@AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex
|
||||
@AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String?
|
||||
@AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String?
|
||||
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity
|
||||
@ObservedObject private var notificationStore = TerminalNotificationStore.shared
|
||||
@State private var shortcutResetToken = UUID()
|
||||
@State private var topBlurOpacity: Double = 0
|
||||
|
|
@ -3104,6 +3155,7 @@ struct SettingsView: View {
|
|||
@State private var showOpenAccessConfirmation = false
|
||||
@State private var pendingOpenAccessMode: SocketControlMode?
|
||||
@State private var browserHistoryEntryCount: Int = 0
|
||||
@State private var detectedImportBrowsers: [InstalledBrowserCandidate] = []
|
||||
@State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
|
||||
@State private var socketPasswordDraft = ""
|
||||
@State private var socketPasswordStatusMessage: String?
|
||||
|
|
@ -3169,6 +3221,30 @@ struct SettingsView: View {
|
|||
)
|
||||
}
|
||||
|
||||
private var settingsSidebarTintLightBinding: Binding<Color> {
|
||||
Binding(
|
||||
get: {
|
||||
Color(nsColor: NSColor(hex: sidebarTintHexLight ?? sidebarTintHex) ?? .black)
|
||||
},
|
||||
set: { newColor in
|
||||
let nsColor = NSColor(newColor)
|
||||
sidebarTintHexLight = nsColor.hexString()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var settingsSidebarTintDarkBinding: Binding<Color> {
|
||||
Binding(
|
||||
get: {
|
||||
Color(nsColor: NSColor(hex: sidebarTintHexDark ?? sidebarTintHex) ?? .black)
|
||||
},
|
||||
set: { newColor in
|
||||
let nsColor = NSColor(newColor)
|
||||
sidebarTintHexDark = nsColor.hexString()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var hasSocketPasswordConfigured: Bool {
|
||||
SocketControlPasswordStore.hasConfiguredPassword()
|
||||
}
|
||||
|
|
@ -3184,6 +3260,10 @@ struct SettingsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var browserImportSubtitle: String {
|
||||
InstalledBrowserDetector.summaryText(for: detectedImportBrowsers)
|
||||
}
|
||||
|
||||
private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool {
|
||||
browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist
|
||||
}
|
||||
|
|
@ -3696,6 +3776,23 @@ struct SettingsView: View {
|
|||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces"),
|
||||
subtitle: commandPaletteSearchAllSurfaces
|
||||
? String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOn", defaultValue: "Cmd+P also matches terminal, browser, and markdown surfaces across workspaces.")
|
||||
: String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOff", defaultValue: "Cmd+P matches workspace rows only.")
|
||||
) {
|
||||
Toggle("", isOn: $commandPaletteSearchAllSurfaces)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
.accessibilityIdentifier("CommandPaletteSearchAllSurfacesToggle")
|
||||
.accessibilityLabel(
|
||||
String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces")
|
||||
)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
String(localized: "settings.app.hideAllSidebarDetails", defaultValue: "Hide All Sidebar Details"),
|
||||
subtitle: sidebarHideAllDetails
|
||||
|
|
@ -3920,6 +4017,83 @@ struct SettingsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
SettingsSectionHeader(title: String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar Appearance"))
|
||||
SettingsCard {
|
||||
SettingsCardRow(
|
||||
String(localized: "settings.sidebarAppearance.tintColorLight", defaultValue: "Light Mode Tint"),
|
||||
subtitle: String(localized: "settings.sidebarAppearance.tintColorLight.subtitle", defaultValue: "Sidebar tint color when using light appearance.")
|
||||
) {
|
||||
HStack(spacing: 8) {
|
||||
ColorPicker(
|
||||
String(localized: "settings.sidebarAppearance.tintColorLight.picker", defaultValue: "Light tint"),
|
||||
selection: settingsSidebarTintLightBinding,
|
||||
supportsOpacity: false
|
||||
)
|
||||
.labelsHidden()
|
||||
.frame(width: 38)
|
||||
|
||||
Text(sidebarTintHexLight ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default"))
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 76, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
String(localized: "settings.sidebarAppearance.tintColorDark", defaultValue: "Dark Mode Tint"),
|
||||
subtitle: String(localized: "settings.sidebarAppearance.tintColorDark.subtitle", defaultValue: "Sidebar tint color when using dark appearance.")
|
||||
) {
|
||||
HStack(spacing: 8) {
|
||||
ColorPicker(
|
||||
String(localized: "settings.sidebarAppearance.tintColorDark.picker", defaultValue: "Dark tint"),
|
||||
selection: settingsSidebarTintDarkBinding,
|
||||
supportsOpacity: false
|
||||
)
|
||||
.labelsHidden()
|
||||
.frame(width: 38)
|
||||
|
||||
Text(sidebarTintHexDark ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default"))
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 76, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
String(localized: "settings.sidebarAppearance.tintOpacity", defaultValue: "Tint Opacity"),
|
||||
subtitle: String(localized: "settings.sidebarAppearance.tintOpacity.subtitle", defaultValue: "How strongly the tint color shows over the sidebar material.")
|
||||
) {
|
||||
HStack(spacing: 8) {
|
||||
Slider(value: $sidebarTintOpacity, in: 0...1)
|
||||
.frame(width: 140)
|
||||
Text(String(format: "%.0f%%", sidebarTintOpacity * 100))
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 36, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
String(localized: "settings.sidebarAppearance.reset", defaultValue: "Reset Sidebar Tint"),
|
||||
subtitle: String(localized: "settings.sidebarAppearance.reset.subtitle", defaultValue: "Restore default sidebar appearance.")
|
||||
) {
|
||||
Button(String(localized: "settings.sidebarAppearance.reset.button", defaultValue: "Reset")) {
|
||||
sidebarTintHexLight = nil
|
||||
sidebarTintHexDark = nil
|
||||
sidebarTintHex = SidebarTintDefaults.hex
|
||||
sidebarTintOpacity = SidebarTintDefaults.opacity
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSectionHeader(title: String(localized: "settings.section.automation", defaultValue: "Automation"))
|
||||
SettingsCard {
|
||||
SettingsPickerRow(
|
||||
|
|
@ -4196,6 +4370,25 @@ struct SettingsView: View {
|
|||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(String(localized: "settings.browser.import", defaultValue: "Import From Browser"), subtitle: browserImportSubtitle) {
|
||||
HStack(spacing: 8) {
|
||||
Button(String(localized: "settings.browser.import.choose", defaultValue: "Choose…")) {
|
||||
BrowserDataImportCoordinator.shared.presentImportDialog()
|
||||
refreshDetectedImportBrowsers()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
|
||||
Button(String(localized: "settings.browser.import.refresh", defaultValue: "Refresh")) {
|
||||
refreshDetectedImportBrowsers()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(String(localized: "settings.browser.history", defaultValue: "Browsing History"), subtitle: browserHistorySubtitle) {
|
||||
Button(String(localized: "settings.browser.history.clearButton", defaultValue: "Clear History…")) {
|
||||
showClearBrowserHistoryConfirmation = true
|
||||
|
|
@ -4338,6 +4531,7 @@ struct SettingsView: View {
|
|||
browserThemeMode = BrowserThemeSettings.mode(defaults: .standard).rawValue
|
||||
browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count
|
||||
browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist
|
||||
refreshDetectedImportBrowsers()
|
||||
reloadWorkspaceTabColorSettings()
|
||||
refreshNotificationCustomSoundStatus()
|
||||
}
|
||||
|
|
@ -4467,6 +4661,7 @@ struct SettingsView: View {
|
|||
showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar
|
||||
warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
|
||||
commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
||||
commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces
|
||||
ShortcutHintDebugSettings.resetVisibilityDefaults()
|
||||
alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
||||
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
||||
|
|
@ -4484,11 +4679,16 @@ struct SettingsView: View {
|
|||
sidebarShowLog = true
|
||||
sidebarShowProgress = true
|
||||
sidebarShowMetadata = true
|
||||
sidebarTintHex = SidebarTintDefaults.hex
|
||||
sidebarTintHexLight = nil
|
||||
sidebarTintHexDark = nil
|
||||
sidebarTintOpacity = SidebarTintDefaults.opacity
|
||||
showOpenAccessConfirmation = false
|
||||
pendingOpenAccessMode = nil
|
||||
socketPasswordDraft = ""
|
||||
socketPasswordStatusMessage = nil
|
||||
socketPasswordStatusIsError = false
|
||||
refreshDetectedImportBrowsers()
|
||||
KeyboardShortcutSettings.resetAll()
|
||||
WorkspaceTabColorSettings.reset()
|
||||
reloadWorkspaceTabColorSettings()
|
||||
|
|
@ -4534,6 +4734,10 @@ struct SettingsView: View {
|
|||
private func saveBrowserInsecureHTTPAllowlist() {
|
||||
browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft
|
||||
}
|
||||
|
||||
private func refreshDetectedImportBrowsers() {
|
||||
detectedImportBrowsers = InstalledBrowserDetector.detectInstalledBrowsers()
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsTopOffsetPreferenceKey: PreferenceKey {
|
||||
|
|
|
|||
|
|
@ -571,6 +571,65 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testCmdWClosesAuxiliaryWindowInsteadOfMainTerminalPanel() throws {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
XCTAssertNotNil(window(withId: windowId), "Expected test window")
|
||||
|
||||
guard let manager = appDelegate.tabManagerFor(windowId: windowId) else {
|
||||
XCTFail("Expected test manager")
|
||||
return
|
||||
}
|
||||
|
||||
let mainWorkspaceCount = manager.tabs.count
|
||||
let auxiliaryWindow = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
|
||||
styleMask: [.titled, .closable, .miniaturizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
auxiliaryWindow.isReleasedWhenClosed = false
|
||||
auxiliaryWindow.identifier = NSUserInterfaceItemIdentifier("cmux.about")
|
||||
auxiliaryWindow.makeKeyAndOrderFront(nil)
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
defer {
|
||||
if auxiliaryWindow.isVisible {
|
||||
auxiliaryWindow.performClose(nil)
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
}
|
||||
}
|
||||
|
||||
guard let event = makeKeyDownEvent(
|
||||
key: "w",
|
||||
modifiers: [.command],
|
||||
keyCode: 13,
|
||||
windowNumber: auxiliaryWindow.windowNumber
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+W event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
throw XCTSkip("debugHandleCustomShortcut is only available in DEBUG builds")
|
||||
#endif
|
||||
|
||||
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
||||
|
||||
XCTAssertFalse(auxiliaryWindow.isVisible, "Cmd+W should close the auxiliary window")
|
||||
XCTAssertNotNil(self.window(withId: windowId), "Cmd+W in auxiliary window should not close the main window")
|
||||
XCTAssertEqual(manager.tabs.count, mainWorkspaceCount, "Cmd+W in auxiliary window should not close a terminal panel")
|
||||
XCTAssertNotEqual(NSApp.keyWindow?.identifier?.rawValue, "cmux.about", "Closed auxiliary window should not remain key")
|
||||
}
|
||||
|
||||
func testCmdPhysicalIWithDvorakCharactersDoesNotTriggerShowNotifications() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
|
|
@ -955,6 +1014,63 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
|||
#endif
|
||||
}
|
||||
|
||||
func testCmdShiftPRequestsCommandPaletteCommands() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer { closeWindow(withId: windowId) }
|
||||
|
||||
guard let window = window(withId: windowId) else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
let paletteExpectation = expectation(description: "Expected command palette commands request for Cmd+Shift+P")
|
||||
var observedPaletteWindow: NSWindow?
|
||||
let paletteToken = NotificationCenter.default.addObserver(
|
||||
forName: .commandPaletteRequested,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { notification in
|
||||
observedPaletteWindow = notification.object as? NSWindow
|
||||
paletteExpectation.fulfill()
|
||||
}
|
||||
defer { NotificationCenter.default.removeObserver(paletteToken) }
|
||||
|
||||
let switcherExpectation = expectation(description: "Cmd+Shift+P should not request command palette switcher")
|
||||
switcherExpectation.isInverted = true
|
||||
let switcherToken = NotificationCenter.default.addObserver(
|
||||
forName: .commandPaletteSwitcherRequested,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { _ in
|
||||
switcherExpectation.fulfill()
|
||||
}
|
||||
defer { NotificationCenter.default.removeObserver(switcherToken) }
|
||||
|
||||
guard let event = makeKeyDownEvent(
|
||||
key: "P",
|
||||
modifiers: [.command, .shift],
|
||||
keyCode: 35,
|
||||
windowNumber: window.windowNumber
|
||||
) else {
|
||||
XCTFail("Failed to construct Cmd+Shift+P event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
wait(for: [paletteExpectation, switcherExpectation], timeout: 1.0)
|
||||
XCTAssertEqual(observedPaletteWindow?.windowNumber, window.windowNumber)
|
||||
}
|
||||
|
||||
func testCmdPhysicalWWithDvorakCharactersDoesNotTriggerClosePanelShortcut() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
|
|
@ -1712,6 +1828,77 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
|||
XCTAssertEqual(observedDismissWindow?.windowNumber, window.windowNumber)
|
||||
}
|
||||
|
||||
func testArrowNavigationRoutesWhileCommandPaletteOverlayIsInteractiveBeforeVisibilitySync() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
return
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
defer {
|
||||
closeWindow(withId: windowId)
|
||||
}
|
||||
|
||||
guard let window = window(withId: windowId),
|
||||
let contentView = window.contentView else {
|
||||
XCTFail("Expected test window")
|
||||
return
|
||||
}
|
||||
|
||||
let overlayContainer = NSView(frame: contentView.bounds)
|
||||
overlayContainer.identifier = commandPaletteOverlayContainerIdentifier
|
||||
overlayContainer.alphaValue = 1
|
||||
overlayContainer.isHidden = false
|
||||
contentView.addSubview(overlayContainer)
|
||||
|
||||
let fieldEditor = CommandPaletteMarkedTextFieldEditor(frame: NSRect(x: 0, y: 0, width: 200, height: 24))
|
||||
fieldEditor.isFieldEditor = true
|
||||
overlayContainer.addSubview(fieldEditor)
|
||||
XCTAssertTrue(window.makeFirstResponder(fieldEditor))
|
||||
|
||||
appDelegate.setCommandPaletteVisible(false, for: window)
|
||||
defer {
|
||||
overlayContainer.removeFromSuperview()
|
||||
fieldEditor.removeFromSuperview()
|
||||
}
|
||||
|
||||
let moveExpectation = expectation(
|
||||
description: "Expected command palette move-selection notification while overlay is interactive"
|
||||
)
|
||||
var observedDelta: Int?
|
||||
var observedWindow: NSWindow?
|
||||
let moveToken = NotificationCenter.default.addObserver(
|
||||
forName: .commandPaletteMoveSelection,
|
||||
object: nil,
|
||||
queue: nil
|
||||
) { notification in
|
||||
observedWindow = notification.object as? NSWindow
|
||||
observedDelta = notification.userInfo?["delta"] as? Int
|
||||
moveExpectation.fulfill()
|
||||
}
|
||||
defer { NotificationCenter.default.removeObserver(moveToken) }
|
||||
|
||||
guard let downArrowEvent = makeKeyDownEvent(
|
||||
key: String(UnicodeScalar(NSDownArrowFunctionKey)!),
|
||||
modifiers: [],
|
||||
keyCode: 125,
|
||||
windowNumber: window.windowNumber
|
||||
) else {
|
||||
XCTFail("Failed to construct Down Arrow event")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: downArrowEvent))
|
||||
#else
|
||||
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
|
||||
#endif
|
||||
|
||||
wait(for: [moveExpectation], timeout: 1.0)
|
||||
XCTAssertEqual(observedWindow?.windowNumber, window.windowNumber)
|
||||
XCTAssertEqual(observedDelta, 1)
|
||||
}
|
||||
|
||||
func testEscapeDismissesCommandPaletteWhenVisibilityStateStaysStalePastInitialPendingWindow() {
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
XCTFail("Expected AppDelegate.shared")
|
||||
|
|
|
|||
232
cmuxTests/BrowserImportMappingTests.swift
Normal file
232
cmuxTests/BrowserImportMappingTests.swift
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import XCTest
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
#elseif canImport(cmux)
|
||||
@testable import cmux
|
||||
#endif
|
||||
|
||||
final class BrowserImportMappingTests: XCTestCase {
|
||||
@MainActor
|
||||
func testDefaultExecutionPlanUsesSeparateModeForMultipleSourceProfiles() {
|
||||
let defaultProfile = BrowserProfileDefinition(
|
||||
id: UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!,
|
||||
displayName: "Default",
|
||||
createdAt: .distantPast,
|
||||
isBuiltInDefault: true
|
||||
)
|
||||
let sourceProfiles = [
|
||||
makeSourceProfile(displayName: "You", path: "/tmp/browser-import-you", isDefault: true),
|
||||
makeSourceProfile(displayName: "austin", path: "/tmp/browser-import-austin", isDefault: false),
|
||||
]
|
||||
|
||||
let plan = BrowserImportPlanResolver.defaultPlan(
|
||||
selectedSourceProfiles: sourceProfiles,
|
||||
destinationProfiles: [defaultProfile],
|
||||
preferredSingleDestinationProfileID: defaultProfile.id
|
||||
)
|
||||
|
||||
XCTAssertEqual(plan.mode, .separateProfiles)
|
||||
XCTAssertEqual(plan.entries.count, 2)
|
||||
XCTAssertEqual(plan.entries.map { $0.sourceProfiles.map(\.displayName) }, [["You"], ["austin"]])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testDefaultExecutionPlanUsesSingleDestinationForSingleSourceProfile() {
|
||||
let defaultProfileID = UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!
|
||||
let sourceProfile = makeSourceProfile(
|
||||
displayName: "You",
|
||||
path: "/tmp/browser-import-single",
|
||||
isDefault: true
|
||||
)
|
||||
|
||||
let plan = BrowserImportPlanResolver.defaultPlan(
|
||||
selectedSourceProfiles: [sourceProfile],
|
||||
destinationProfiles: [],
|
||||
preferredSingleDestinationProfileID: defaultProfileID
|
||||
)
|
||||
|
||||
XCTAssertEqual(plan.mode, .singleDestination)
|
||||
XCTAssertEqual(plan.entries.count, 1)
|
||||
XCTAssertEqual(plan.entries[0].sourceProfiles.map(\.displayName), ["You"])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSeparatePlanReusesExistingSameNamedDestinationProfiles() {
|
||||
let workID = UUID()
|
||||
let destinationProfiles = [
|
||||
BrowserProfileDefinition(
|
||||
id: workID,
|
||||
displayName: "You",
|
||||
createdAt: .distantPast,
|
||||
isBuiltInDefault: false
|
||||
)
|
||||
]
|
||||
let sourceProfiles = [
|
||||
makeSourceProfile(displayName: " you ", path: "/tmp/browser-import-match", isDefault: true)
|
||||
]
|
||||
|
||||
let plan = BrowserImportPlanResolver.separateProfilesPlan(
|
||||
selectedSourceProfiles: sourceProfiles,
|
||||
destinationProfiles: destinationProfiles
|
||||
)
|
||||
|
||||
XCTAssertEqual(plan.entries.count, 1)
|
||||
XCTAssertEqual(plan.entries[0].destination, .existing(workID))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSeparatePlanUsesStableCreateNamesWhenTwoSourceProfilesShareDisplayName() {
|
||||
let sourceProfiles = [
|
||||
makeSourceProfile(displayName: "Work", path: "/tmp/browser-import-work-1", isDefault: true),
|
||||
makeSourceProfile(displayName: "Work", path: "/tmp/browser-import-work-2", isDefault: false),
|
||||
]
|
||||
|
||||
let plan = BrowserImportPlanResolver.separateProfilesPlan(
|
||||
selectedSourceProfiles: sourceProfiles,
|
||||
destinationProfiles: []
|
||||
)
|
||||
|
||||
XCTAssertEqual(plan.entries.count, 2)
|
||||
XCTAssertEqual(plan.entries[0].destination, .createNamed("Work"))
|
||||
XCTAssertEqual(plan.entries[1].destination, .createNamed("Work (2)"))
|
||||
}
|
||||
|
||||
func testStep3PresentationShowsPerProfileRowsWhenPlanUsesSeparateMode() {
|
||||
let presentation = BrowserImportStep3Presentation(
|
||||
plan: BrowserImportExecutionPlan(
|
||||
mode: .separateProfiles,
|
||||
entries: [
|
||||
BrowserImportExecutionEntry(
|
||||
sourceProfiles: [
|
||||
makeSourceProfile(
|
||||
displayName: "You",
|
||||
path: "/tmp/browser-import-presentation-separate",
|
||||
isDefault: true
|
||||
)
|
||||
],
|
||||
destination: .createNamed("You")
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertTrue(presentation.showsSeparateRows)
|
||||
XCTAssertFalse(presentation.showsSingleDestinationPicker)
|
||||
}
|
||||
|
||||
func testStep3PresentationShowsSingleDestinationPickerWhenPlanUsesMergeMode() {
|
||||
let presentation = BrowserImportStep3Presentation(
|
||||
plan: BrowserImportExecutionPlan(
|
||||
mode: .mergeIntoOne,
|
||||
entries: []
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertFalse(presentation.showsSeparateRows)
|
||||
XCTAssertTrue(presentation.showsSingleDestinationPicker)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testRealizePlanCreatesMissingDestinationProfilesOnlyWhenRequested() throws {
|
||||
let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let store = BrowserProfileStore(defaults: defaults)
|
||||
let plan = BrowserImportExecutionPlan(
|
||||
mode: .separateProfiles,
|
||||
entries: [
|
||||
BrowserImportExecutionEntry(
|
||||
sourceProfiles: [
|
||||
makeSourceProfile(
|
||||
displayName: "You",
|
||||
path: "/tmp/browser-import-realize-create",
|
||||
isDefault: true
|
||||
)
|
||||
],
|
||||
destination: .createNamed("You")
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
let realized = try BrowserImportPlanResolver.realize(plan: plan, profileStore: store)
|
||||
|
||||
XCTAssertEqual(realized.createdProfiles.map(\.displayName), ["You"])
|
||||
XCTAssertEqual(store.profiles.map(\.displayName), ["Default", "You"])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testRealizePlanReusesExistingProfileInsteadOfCreatingDuplicate() throws {
|
||||
let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let store = BrowserProfileStore(defaults: defaults)
|
||||
let existing = try XCTUnwrap(store.createProfile(named: "You"))
|
||||
let plan = BrowserImportExecutionPlan(
|
||||
mode: .separateProfiles,
|
||||
entries: [
|
||||
BrowserImportExecutionEntry(
|
||||
sourceProfiles: [
|
||||
makeSourceProfile(
|
||||
displayName: "You",
|
||||
path: "/tmp/browser-import-realize-existing",
|
||||
isDefault: true
|
||||
)
|
||||
],
|
||||
destination: .existing(existing.id)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
let realized = try BrowserImportPlanResolver.realize(plan: plan, profileStore: store)
|
||||
|
||||
XCTAssertTrue(realized.createdProfiles.isEmpty)
|
||||
XCTAssertEqual(realized.entries[0].destinationProfileID, existing.id)
|
||||
}
|
||||
|
||||
func testAggregateOutcomeIncludesOneMappingLinePerDestination() {
|
||||
let outcome = BrowserImportOutcome(
|
||||
browserName: "Helium",
|
||||
scope: .cookiesAndHistory,
|
||||
domainFilters: [],
|
||||
createdDestinationProfileNames: ["You", "austin"],
|
||||
entries: [
|
||||
BrowserImportOutcomeEntry(
|
||||
sourceProfileNames: ["You"],
|
||||
destinationProfileName: "You",
|
||||
importedCookies: 10,
|
||||
skippedCookies: 0,
|
||||
importedHistoryEntries: 20,
|
||||
warnings: []
|
||||
),
|
||||
BrowserImportOutcomeEntry(
|
||||
sourceProfileNames: ["austin"],
|
||||
destinationProfileName: "austin",
|
||||
importedCookies: 5,
|
||||
skippedCookies: 1,
|
||||
importedHistoryEntries: 9,
|
||||
warnings: []
|
||||
),
|
||||
],
|
||||
warnings: []
|
||||
)
|
||||
|
||||
let lines = BrowserImportOutcomeFormatter.lines(for: outcome)
|
||||
|
||||
XCTAssertTrue(lines.contains("You -> You"))
|
||||
XCTAssertTrue(lines.contains("austin -> austin"))
|
||||
XCTAssertTrue(lines.contains("Created cmux profiles: You, austin"))
|
||||
}
|
||||
|
||||
private func makeSourceProfile(displayName: String, path: String, isDefault: Bool) -> InstalledBrowserProfile {
|
||||
InstalledBrowserProfile(
|
||||
displayName: displayName,
|
||||
rootURL: URL(fileURLWithPath: path, isDirectory: true),
|
||||
isDefault: isDefault
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -232,10 +232,10 @@ final class CJKIMEMarkedTextTests: XCTestCase {
|
|||
|
||||
// MARK: - selectedRange / validAttributesForMarkedText
|
||||
|
||||
func testSelectedRangeReturnsNotFound() {
|
||||
func testSelectedRangeReturnsEmptyRangeWithoutSelection() {
|
||||
let view = GhosttyNSView(frame: .zero)
|
||||
let range = view.selectedRange()
|
||||
XCTAssertEqual(range.location, NSNotFound)
|
||||
XCTAssertEqual(range, NSRange(location: 0, length: 0))
|
||||
}
|
||||
|
||||
func testValidAttributesForMarkedTextReturnsEmpty() {
|
||||
|
|
@ -694,6 +694,68 @@ final class CJKIMEFirstRectTests: XCTestCase {
|
|||
XCTAssertEqual(rect.width, 36, accuracy: 0.001)
|
||||
XCTAssertEqual(rect.height, 18, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testFirstRectUsesZeroWidthForInsertionPointWithoutOffsettingCaretAnchor() {
|
||||
let frame = NSRect(x: 0, y: 0, width: 640, height: 480)
|
||||
let view = GhosttyNSView(frame: frame)
|
||||
view.cellSize = CGSize(width: 9, height: 18)
|
||||
view.setIMEPointForTesting(x: 80, y: 120, width: 36, height: 24)
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 40, y: 40, width: 640, height: 480),
|
||||
styleMask: [.titled],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
let content = NSView(frame: frame)
|
||||
window.contentView = content
|
||||
content.addSubview(view)
|
||||
view.frame = frame
|
||||
|
||||
defer {
|
||||
view.clearIMEPointForTesting()
|
||||
window.orderOut(nil)
|
||||
}
|
||||
|
||||
let rect = view.firstRect(forCharacterRange: NSRange(location: 5, length: 0), actualRange: nil)
|
||||
let expectedViewRect = NSRect(x: 80, y: frame.height - 120, width: 0, height: 24)
|
||||
let expectedScreenRect = window.convertToScreen(view.convert(expectedViewRect, to: nil))
|
||||
|
||||
XCTAssertEqual(rect.origin.x, expectedScreenRect.origin.x, accuracy: 0.001)
|
||||
XCTAssertEqual(rect.origin.y, expectedScreenRect.origin.y, accuracy: 0.001)
|
||||
XCTAssertEqual(rect.width, 0, accuracy: 0.001)
|
||||
XCTAssertEqual(rect.height, 24, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testDocumentVisibleRectUsesScreenCoordinates() {
|
||||
guard #available(macOS 14.0, *) else { return }
|
||||
|
||||
let frame = NSRect(x: 0, y: 0, width: 640, height: 480)
|
||||
let view = GhosttyNSView(frame: frame)
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 40, y: 40, width: 640, height: 480),
|
||||
styleMask: [.titled],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
let content = NSView(frame: frame)
|
||||
window.contentView = content
|
||||
content.addSubview(view)
|
||||
view.frame = frame
|
||||
|
||||
defer {
|
||||
window.orderOut(nil)
|
||||
}
|
||||
|
||||
let expected = window.convertToScreen(view.convert(view.visibleRect, to: nil))
|
||||
let rect = view.documentVisibleRect
|
||||
|
||||
XCTAssertEqual(rect.origin.x, expected.origin.x, accuracy: 0.001)
|
||||
XCTAssertEqual(rect.origin.y, expected.origin.y, accuracy: 0.001)
|
||||
XCTAssertEqual(rect.width, expected.width, accuracy: 0.001)
|
||||
XCTAssertEqual(rect.height, expected.height, accuracy: 0.001)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Key text accumulator during CJK IME composition
|
||||
|
|
|
|||
|
|
@ -1197,6 +1197,55 @@ final class AppDelegateWindowContextRoutingTests: XCTestCase {
|
|||
XCTAssertEqual(managerB.tabs.count, originalTabCountB + 1)
|
||||
XCTAssertTrue(managerB.tabs.contains(where: { $0.id == createdWorkspaceId }))
|
||||
}
|
||||
|
||||
func testApplicationOpenURLsAddsWorkspaceForDroppedFolderURL() throws {
|
||||
_ = NSApplication.shared
|
||||
let app = AppDelegate()
|
||||
|
||||
let windowId = UUID()
|
||||
let window = makeMainWindow(id: windowId)
|
||||
defer { window.orderOut(nil) }
|
||||
|
||||
let manager = TabManager()
|
||||
app.registerMainWindow(
|
||||
window,
|
||||
windowId: windowId,
|
||||
tabManager: manager,
|
||||
sidebarState: SidebarState(),
|
||||
sidebarSelectionState: SidebarSelectionState()
|
||||
)
|
||||
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
_ = app.synchronizeActiveMainWindowContext(preferredWindow: window)
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
let previousWelcomeShown = defaults.object(forKey: WelcomeSettings.shownKey)
|
||||
defaults.set(true, forKey: WelcomeSettings.shownKey)
|
||||
defer {
|
||||
if let previousWelcomeShown {
|
||||
defaults.set(previousWelcomeShown, forKey: WelcomeSettings.shownKey)
|
||||
} else {
|
||||
defaults.removeObject(forKey: WelcomeSettings.shownKey)
|
||||
}
|
||||
}
|
||||
|
||||
let rootDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let droppedDirectory = rootDirectory.appendingPathComponent("project", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: droppedDirectory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: rootDirectory) }
|
||||
|
||||
let existingWorkspaceIds = Set(manager.tabs.map(\.id))
|
||||
|
||||
app.application(
|
||||
NSApplication.shared,
|
||||
open: [URL(fileURLWithPath: droppedDirectory.path)]
|
||||
)
|
||||
|
||||
let createdWorkspace = manager.tabs.first { !existingWorkspaceIds.contains($0.id) }
|
||||
XCTAssertNotNil(createdWorkspace)
|
||||
XCTAssertEqual(createdWorkspace?.currentDirectory, droppedDirectory.path)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
@ -2617,6 +2666,113 @@ final class BrowserNavigationNewTabDecisionTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class BrowserPopupDecisionTests: XCTestCase {
|
||||
func testLinkActivatedPlainLeftClickDoesNotCreatePopup() {
|
||||
XCTAssertFalse(
|
||||
browserNavigationShouldCreatePopup(
|
||||
navigationType: .linkActivated,
|
||||
modifierFlags: [],
|
||||
buttonNumber: 0
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testOtherNavigationPlainLeftClickCreatesPopup() {
|
||||
XCTAssertTrue(
|
||||
browserNavigationShouldCreatePopup(
|
||||
navigationType: .other,
|
||||
modifierFlags: [],
|
||||
buttonNumber: 0
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testOtherNavigationMiddleClickDoesNotCreatePopup() {
|
||||
XCTAssertFalse(
|
||||
browserNavigationShouldCreatePopup(
|
||||
navigationType: .other,
|
||||
modifierFlags: [],
|
||||
buttonNumber: 2
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testLinkActivatedCmdClickDoesNotCreatePopup() {
|
||||
XCTAssertFalse(
|
||||
browserNavigationShouldCreatePopup(
|
||||
navigationType: .linkActivated,
|
||||
modifierFlags: [.command],
|
||||
buttonNumber: 0
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class BrowserNilTargetFallbackDecisionTests: XCTestCase {
|
||||
func testOtherNavigationDoesNotFallbackToNewTab() {
|
||||
XCTAssertFalse(
|
||||
browserNavigationShouldFallbackNilTargetToNewTab(
|
||||
navigationType: .other
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testLinkActivatedNavigationFallsBackToNewTab() {
|
||||
XCTAssertTrue(
|
||||
browserNavigationShouldFallbackNilTargetToNewTab(
|
||||
navigationType: .linkActivated
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class BrowserPopupContentRectTests: XCTestCase {
|
||||
func testExplicitTopOriginCoordinatesConvertToAppKitBottomOrigin() {
|
||||
let rect = browserPopupContentRect(
|
||||
requestedWidth: 400,
|
||||
requestedHeight: 300,
|
||||
requestedX: 150,
|
||||
requestedTopY: 120,
|
||||
visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800)
|
||||
)
|
||||
|
||||
XCTAssertEqual(rect.origin.x, 150, accuracy: 0.01)
|
||||
XCTAssertEqual(rect.origin.y, 430, accuracy: 0.01)
|
||||
XCTAssertEqual(rect.width, 400, accuracy: 0.01)
|
||||
XCTAssertEqual(rect.height, 300, accuracy: 0.01)
|
||||
}
|
||||
|
||||
func testExplicitCoordinatesClampToVisibleFrame() {
|
||||
let rect = browserPopupContentRect(
|
||||
requestedWidth: 1400,
|
||||
requestedHeight: 1200,
|
||||
requestedX: 900,
|
||||
requestedTopY: -25,
|
||||
visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800)
|
||||
)
|
||||
|
||||
XCTAssertEqual(rect.origin.x, 100, accuracy: 0.01)
|
||||
XCTAssertEqual(rect.origin.y, 50, accuracy: 0.01)
|
||||
XCTAssertEqual(rect.width, 1000, accuracy: 0.01)
|
||||
XCTAssertEqual(rect.height, 800, accuracy: 0.01)
|
||||
}
|
||||
|
||||
func testMissingCoordinatesCentersPopup() {
|
||||
let rect = browserPopupContentRect(
|
||||
requestedWidth: 300,
|
||||
requestedHeight: 200,
|
||||
requestedX: nil,
|
||||
requestedTopY: nil,
|
||||
visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800)
|
||||
)
|
||||
|
||||
XCTAssertEqual(rect.origin.x, 450, accuracy: 0.01)
|
||||
XCTAssertEqual(rect.origin.y, 350, accuracy: 0.01)
|
||||
XCTAssertEqual(rect.width, 300, accuracy: 0.01)
|
||||
XCTAssertEqual(rect.height, 200, accuracy: 0.01)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class BrowserJavaScriptDialogDelegateTests: XCTestCase {
|
||||
func testBrowserPanelUIDelegateImplementsJavaScriptDialogSelectors() {
|
||||
|
|
@ -5125,6 +5281,32 @@ final class WorkspaceReorderTests: XCTestCase {
|
|||
let manager = TabManager()
|
||||
XCTAssertFalse(manager.reorderWorkspace(tabId: UUID(), toIndex: 0))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testReorderWorkspaceKeepsUnpinnedWorkspaceBelowPinnedSegment() {
|
||||
let manager = TabManager()
|
||||
let firstPinned = manager.tabs[0]
|
||||
manager.setPinned(firstPinned, pinned: true)
|
||||
let secondPinned = manager.addWorkspace()
|
||||
manager.setPinned(secondPinned, pinned: true)
|
||||
let unpinned = manager.addWorkspace()
|
||||
|
||||
XCTAssertTrue(manager.reorderWorkspace(tabId: unpinned.id, toIndex: 0))
|
||||
XCTAssertEqual(manager.tabs.map(\.id), [firstPinned.id, secondPinned.id, unpinned.id])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testReorderWorkspaceKeepsPinnedWorkspaceInsidePinnedSegment() {
|
||||
let manager = TabManager()
|
||||
let firstPinned = manager.tabs[0]
|
||||
manager.setPinned(firstPinned, pinned: true)
|
||||
let secondPinned = manager.addWorkspace()
|
||||
manager.setPinned(secondPinned, pinned: true)
|
||||
let unpinned = manager.addWorkspace()
|
||||
|
||||
XCTAssertTrue(manager.reorderWorkspace(tabId: firstPinned.id, toIndex: 999))
|
||||
XCTAssertEqual(manager.tabs.map(\.id), [secondPinned.id, firstPinned.id, unpinned.id])
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
@ -7152,14 +7334,16 @@ final class SidebarDropPlannerTests: XCTestCase {
|
|||
SidebarDropPlanner.indicator(
|
||||
draggedTabId: first,
|
||||
targetTabId: first,
|
||||
tabIds: tabIds
|
||||
tabIds: tabIds,
|
||||
pinnedTabIds: []
|
||||
)
|
||||
)
|
||||
XCTAssertNil(
|
||||
SidebarDropPlanner.indicator(
|
||||
draggedTabId: third,
|
||||
targetTabId: nil,
|
||||
tabIds: tabIds
|
||||
tabIds: tabIds,
|
||||
pinnedTabIds: []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -7170,14 +7354,16 @@ final class SidebarDropPlannerTests: XCTestCase {
|
|||
SidebarDropPlanner.indicator(
|
||||
draggedTabId: only,
|
||||
targetTabId: nil,
|
||||
tabIds: [only]
|
||||
tabIds: [only],
|
||||
pinnedTabIds: []
|
||||
)
|
||||
)
|
||||
XCTAssertNil(
|
||||
SidebarDropPlanner.indicator(
|
||||
draggedTabId: only,
|
||||
targetTabId: only,
|
||||
tabIds: [only]
|
||||
tabIds: [only],
|
||||
pinnedTabIds: []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -7191,7 +7377,8 @@ final class SidebarDropPlannerTests: XCTestCase {
|
|||
let indicator = SidebarDropPlanner.indicator(
|
||||
draggedTabId: second,
|
||||
targetTabId: nil,
|
||||
tabIds: tabIds
|
||||
tabIds: tabIds,
|
||||
pinnedTabIds: []
|
||||
)
|
||||
XCTAssertEqual(indicator?.tabId, nil)
|
||||
XCTAssertEqual(indicator?.edge, .bottom)
|
||||
|
|
@ -7207,7 +7394,8 @@ final class SidebarDropPlannerTests: XCTestCase {
|
|||
draggedTabId: second,
|
||||
targetTabId: nil,
|
||||
indicator: SidebarDropIndicator(tabId: nil, edge: .bottom),
|
||||
tabIds: tabIds
|
||||
tabIds: tabIds,
|
||||
pinnedTabIds: []
|
||||
)
|
||||
XCTAssertEqual(index, 2)
|
||||
}
|
||||
|
|
@ -7222,7 +7410,8 @@ final class SidebarDropPlannerTests: XCTestCase {
|
|||
SidebarDropPlanner.indicator(
|
||||
draggedTabId: second,
|
||||
targetTabId: second,
|
||||
tabIds: tabIds
|
||||
tabIds: tabIds,
|
||||
pinnedTabIds: []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -7238,6 +7427,7 @@ final class SidebarDropPlannerTests: XCTestCase {
|
|||
draggedTabId: first,
|
||||
targetTabId: second,
|
||||
tabIds: tabIds,
|
||||
pinnedTabIds: [],
|
||||
pointerY: 2,
|
||||
targetHeight: 40
|
||||
)
|
||||
|
|
@ -7254,6 +7444,7 @@ final class SidebarDropPlannerTests: XCTestCase {
|
|||
draggedTabId: first,
|
||||
targetTabId: second,
|
||||
tabIds: tabIds,
|
||||
pinnedTabIds: [],
|
||||
pointerY: 38,
|
||||
targetHeight: 40
|
||||
)
|
||||
|
|
@ -7264,7 +7455,8 @@ final class SidebarDropPlannerTests: XCTestCase {
|
|||
draggedTabId: first,
|
||||
targetTabId: second,
|
||||
indicator: indicator,
|
||||
tabIds: tabIds
|
||||
tabIds: tabIds,
|
||||
pinnedTabIds: []
|
||||
),
|
||||
1
|
||||
)
|
||||
|
|
@ -7280,6 +7472,7 @@ final class SidebarDropPlannerTests: XCTestCase {
|
|||
draggedTabId: third,
|
||||
targetTabId: first,
|
||||
tabIds: tabIds,
|
||||
pinnedTabIds: [],
|
||||
pointerY: 38,
|
||||
targetHeight: 40
|
||||
)
|
||||
|
|
@ -7287,6 +7480,7 @@ final class SidebarDropPlannerTests: XCTestCase {
|
|||
draggedTabId: third,
|
||||
targetTabId: second,
|
||||
tabIds: tabIds,
|
||||
pinnedTabIds: [],
|
||||
pointerY: 2,
|
||||
targetHeight: 40
|
||||
)
|
||||
|
|
@ -7308,11 +7502,53 @@ final class SidebarDropPlannerTests: XCTestCase {
|
|||
draggedTabId: third,
|
||||
targetTabId: second,
|
||||
tabIds: tabIds,
|
||||
pinnedTabIds: [],
|
||||
pointerY: 38,
|
||||
targetHeight: 40
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testIndicatorSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() {
|
||||
let pinnedA = UUID()
|
||||
let pinnedB = UUID()
|
||||
let unpinnedA = UUID()
|
||||
let unpinnedB = UUID()
|
||||
let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB]
|
||||
let pinnedIds: Set<UUID> = [pinnedA, pinnedB]
|
||||
|
||||
let indicator = SidebarDropPlanner.indicator(
|
||||
draggedTabId: unpinnedB,
|
||||
targetTabId: pinnedA,
|
||||
tabIds: tabIds,
|
||||
pinnedTabIds: pinnedIds,
|
||||
pointerY: 2,
|
||||
targetHeight: 40
|
||||
)
|
||||
|
||||
XCTAssertEqual(indicator?.tabId, unpinnedA)
|
||||
XCTAssertEqual(indicator?.edge, .top)
|
||||
}
|
||||
|
||||
func testTargetIndexSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() {
|
||||
let pinnedA = UUID()
|
||||
let pinnedB = UUID()
|
||||
let unpinnedA = UUID()
|
||||
let unpinnedB = UUID()
|
||||
let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB]
|
||||
let pinnedIds: Set<UUID> = [pinnedA, pinnedB]
|
||||
|
||||
let targetIndex = SidebarDropPlanner.targetIndex(
|
||||
draggedTabId: unpinnedB,
|
||||
targetTabId: pinnedA,
|
||||
indicator: SidebarDropIndicator(tabId: pinnedA, edge: .top),
|
||||
tabIds: tabIds,
|
||||
pinnedTabIds: pinnedIds
|
||||
)
|
||||
|
||||
XCTAssertEqual(targetIndex, 2)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
final class SidebarDragAutoScrollPlannerTests: XCTestCase {
|
||||
|
|
@ -12055,6 +12291,18 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
|
|||
return nil
|
||||
}
|
||||
|
||||
private func firstResponderOwnsTextField(_ firstResponder: NSResponder?, textField: NSTextField) -> Bool {
|
||||
if firstResponder === textField {
|
||||
return true
|
||||
}
|
||||
if let editor = firstResponder as? NSTextView,
|
||||
editor.isFieldEditor,
|
||||
editor.delegate as? NSTextField === textField {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func testTrackpadScrollRoutesToTerminalSurfaceAndPreservesKeyboardFocusPath() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
|
||||
|
|
@ -12187,12 +12435,105 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
|
|||
|
||||
let searchState = TerminalSurface.SearchState(needle: "example")
|
||||
hostedView.setSearchOverlay(searchState: searchState)
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
XCTAssertTrue(hostedView.debugHasSearchOverlay())
|
||||
|
||||
hostedView.setSearchOverlay(searchState: nil)
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
XCTAssertFalse(hostedView.debugHasSearchOverlay())
|
||||
}
|
||||
|
||||
func testRapidSearchOverlayToggleDoesNotLeaveStaleOverlayMounted() {
|
||||
let surface = TerminalSurface(
|
||||
tabId: UUID(),
|
||||
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
||||
configTemplate: nil,
|
||||
workingDirectory: nil
|
||||
)
|
||||
let hostedView = surface.hostedView
|
||||
|
||||
hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "example"))
|
||||
hostedView.setSearchOverlay(searchState: nil)
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
|
||||
XCTAssertFalse(
|
||||
hostedView.debugHasSearchOverlay(),
|
||||
"A stale deferred mount must not resurrect the find overlay after it closes"
|
||||
)
|
||||
}
|
||||
|
||||
func testSearchOverlayFocusesSearchFieldAfterDeferredAttach() {
|
||||
let surface = TerminalSurface(
|
||||
tabId: UUID(),
|
||||
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
||||
configTemplate: nil,
|
||||
workingDirectory: nil
|
||||
)
|
||||
let hostedView = surface.hostedView
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
defer { window.orderOut(nil) }
|
||||
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
hostedView.frame = contentView.bounds
|
||||
hostedView.autoresizingMask = [.width, .height]
|
||||
contentView.addSubview(hostedView)
|
||||
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.displayIfNeeded()
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
hostedView.setVisibleInUI(true)
|
||||
hostedView.setActive(true)
|
||||
|
||||
let searchState = TerminalSurface.SearchState(needle: "")
|
||||
surface.searchState = searchState
|
||||
hostedView.setSearchOverlay(searchState: searchState)
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
|
||||
guard let searchField = findEditableTextField(in: hostedView) else {
|
||||
XCTFail("Expected mounted find text field")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertTrue(
|
||||
firstResponderOwnsTextField(window.firstResponder, textField: searchField),
|
||||
"Deferred search overlay attach should still move focus into the find field"
|
||||
)
|
||||
}
|
||||
|
||||
func testStartOrFocusTerminalSearchReusesExistingSearchState() {
|
||||
let surface = TerminalSurface(
|
||||
tabId: UUID(),
|
||||
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
||||
configTemplate: nil,
|
||||
workingDirectory: nil
|
||||
)
|
||||
let existingSearchState = TerminalSurface.SearchState(needle: "existing")
|
||||
surface.searchState = existingSearchState
|
||||
|
||||
var focusNotificationCount = 0
|
||||
XCTAssertTrue(
|
||||
startOrFocusTerminalSearch(surface) { _ in
|
||||
focusNotificationCount += 1
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertTrue(surface.searchState === existingSearchState)
|
||||
XCTAssertEqual(
|
||||
focusNotificationCount,
|
||||
1,
|
||||
"Re-triggering terminal Find should refocus the existing overlay without recreating state"
|
||||
)
|
||||
}
|
||||
|
||||
func testEscapeDismissingFindOverlayDoesNotLeakEscapeKeyUpToTerminal() {
|
||||
_ = NSApplication.shared
|
||||
|
||||
|
|
@ -12428,6 +12769,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
|
|||
)
|
||||
let hostedView = surface.hostedView
|
||||
hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "split"))
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
XCTAssertTrue(hostedView.debugHasSearchOverlay())
|
||||
|
||||
portal.bind(hostedView: hostedView, to: anchorA, visibleInUI: true)
|
||||
|
|
@ -12466,6 +12808,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
|
|||
)
|
||||
let hostedView = surface.hostedView
|
||||
hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "workspace"))
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
XCTAssertTrue(hostedView.debugHasSearchOverlay())
|
||||
|
||||
portal.bind(hostedView: hostedView, to: anchor, visibleInUI: true)
|
||||
|
|
|
|||
|
|
@ -434,6 +434,78 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testVisibleResultsResetWhenQueryChangesCommandPaletteScope() {
|
||||
XCTAssertTrue(
|
||||
ContentView.commandPaletteShouldResetVisibleResultsForQueryTransition(
|
||||
oldQuery: ">",
|
||||
newQuery: "",
|
||||
hasVisibleResults: true
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
ContentView.commandPaletteShouldResetVisibleResultsForQueryTransition(
|
||||
oldQuery: "",
|
||||
newQuery: ">",
|
||||
hasVisibleResults: true
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
ContentView.commandPaletteShouldResetVisibleResultsForQueryTransition(
|
||||
oldQuery: ">rename",
|
||||
newQuery: ">renam",
|
||||
hasVisibleResults: true
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
ContentView.commandPaletteShouldResetVisibleResultsForQueryTransition(
|
||||
oldQuery: ">",
|
||||
newQuery: "",
|
||||
hasVisibleResults: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testRefreshInputsPreferObservedQueryOverStaleState() {
|
||||
let inputs = ContentView.commandPaletteRefreshInputsForTests(
|
||||
stateQuery: ">",
|
||||
observedQuery: "",
|
||||
searchAllSurfaces: true
|
||||
)
|
||||
|
||||
XCTAssertEqual(inputs.scope, "switcher")
|
||||
XCTAssertEqual(inputs.matchingQuery, "")
|
||||
XCTAssertFalse(inputs.includesSurfaces)
|
||||
}
|
||||
|
||||
func testRefreshInputsIncludeSurfacesOnlyForNonEmptySwitcherQuery() {
|
||||
let switcherInputs = ContentView.commandPaletteRefreshInputsForTests(
|
||||
stateQuery: "",
|
||||
observedQuery: " feature/search ",
|
||||
searchAllSurfaces: true
|
||||
)
|
||||
XCTAssertEqual(switcherInputs.scope, "switcher")
|
||||
XCTAssertEqual(switcherInputs.matchingQuery, "feature/search")
|
||||
XCTAssertTrue(switcherInputs.includesSurfaces)
|
||||
|
||||
let commandInputs = ContentView.commandPaletteRefreshInputsForTests(
|
||||
stateQuery: "",
|
||||
observedQuery: ">feature/search",
|
||||
searchAllSurfaces: true
|
||||
)
|
||||
XCTAssertEqual(commandInputs.scope, "commands")
|
||||
XCTAssertEqual(commandInputs.matchingQuery, "feature/search")
|
||||
XCTAssertFalse(commandInputs.includesSurfaces)
|
||||
|
||||
let workspaceOnlyInputs = ContentView.commandPaletteRefreshInputsForTests(
|
||||
stateQuery: "",
|
||||
observedQuery: "feature/search",
|
||||
searchAllSurfaces: false
|
||||
)
|
||||
XCTAssertEqual(workspaceOnlyInputs.scope, "switcher")
|
||||
XCTAssertEqual(workspaceOnlyInputs.matchingQuery, "feature/search")
|
||||
XCTAssertFalse(workspaceOnlyInputs.includesSurfaces)
|
||||
}
|
||||
|
||||
func testCommandContextFingerprintTracksExactContextValues() {
|
||||
let base = ContentView.commandPaletteContextFingerprint(
|
||||
boolValues: [
|
||||
|
|
@ -490,7 +562,8 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
|
|||
directories: ["/Users/example/dev/cmuxterm"],
|
||||
branches: ["feature/search-speed"],
|
||||
ports: [3000]
|
||||
)
|
||||
),
|
||||
surfaces: []
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
@ -510,7 +583,8 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
|
|||
directories: ["/Users/example/dev/other"],
|
||||
branches: ["feature/search-speed"],
|
||||
ports: [4000]
|
||||
)
|
||||
),
|
||||
surfaces: []
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
@ -530,7 +604,8 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
|
|||
directories: ["/Users/example/dev/cmuxterm"],
|
||||
branches: ["feature/search-speed"],
|
||||
ports: [3000]
|
||||
)
|
||||
),
|
||||
surfaces: []
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
@ -541,6 +616,100 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
|
|||
XCTAssertNotEqual(base, changedDisplayName)
|
||||
}
|
||||
|
||||
func testSwitcherFingerprintTracksSurfaceValuesAtSameCardinality() {
|
||||
let windowID = UUID()
|
||||
let workspaceID = UUID()
|
||||
let surfaceID = UUID()
|
||||
|
||||
let base = ContentView.commandPaletteSwitcherFingerprint(
|
||||
windowContexts: [
|
||||
ContentView.CommandPaletteSwitcherFingerprintContext(
|
||||
windowId: windowID,
|
||||
windowLabel: nil,
|
||||
selectedWorkspaceId: workspaceID,
|
||||
workspaces: [
|
||||
ContentView.CommandPaletteSwitcherFingerprintWorkspace(
|
||||
id: workspaceID,
|
||||
displayName: "Workspace Alpha",
|
||||
metadata: CommandPaletteSwitcherSearchMetadata(),
|
||||
surfaces: [
|
||||
ContentView.CommandPaletteSwitcherFingerprintSurface(
|
||||
id: surfaceID,
|
||||
displayName: "Terminal",
|
||||
kindLabel: "Terminal",
|
||||
metadata: CommandPaletteSwitcherSearchMetadata(
|
||||
directories: ["/tmp/search-alpha"],
|
||||
branches: ["feature/a"],
|
||||
ports: [3000]
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
let changedSurfaceMetadata = ContentView.commandPaletteSwitcherFingerprint(
|
||||
windowContexts: [
|
||||
ContentView.CommandPaletteSwitcherFingerprintContext(
|
||||
windowId: windowID,
|
||||
windowLabel: nil,
|
||||
selectedWorkspaceId: workspaceID,
|
||||
workspaces: [
|
||||
ContentView.CommandPaletteSwitcherFingerprintWorkspace(
|
||||
id: workspaceID,
|
||||
displayName: "Workspace Alpha",
|
||||
metadata: CommandPaletteSwitcherSearchMetadata(),
|
||||
surfaces: [
|
||||
ContentView.CommandPaletteSwitcherFingerprintSurface(
|
||||
id: surfaceID,
|
||||
displayName: "Terminal",
|
||||
kindLabel: "Terminal",
|
||||
metadata: CommandPaletteSwitcherSearchMetadata(
|
||||
directories: ["/tmp/search-beta"],
|
||||
branches: ["feature/a"],
|
||||
ports: [3000]
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
let changedSurfaceKind = ContentView.commandPaletteSwitcherFingerprint(
|
||||
windowContexts: [
|
||||
ContentView.CommandPaletteSwitcherFingerprintContext(
|
||||
windowId: windowID,
|
||||
windowLabel: nil,
|
||||
selectedWorkspaceId: workspaceID,
|
||||
workspaces: [
|
||||
ContentView.CommandPaletteSwitcherFingerprintWorkspace(
|
||||
id: workspaceID,
|
||||
displayName: "Workspace Alpha",
|
||||
metadata: CommandPaletteSwitcherSearchMetadata(),
|
||||
surfaces: [
|
||||
ContentView.CommandPaletteSwitcherFingerprintSurface(
|
||||
id: surfaceID,
|
||||
displayName: "Terminal",
|
||||
kindLabel: "Browser",
|
||||
metadata: CommandPaletteSwitcherSearchMetadata(
|
||||
directories: ["/tmp/search-alpha"],
|
||||
branches: ["feature/a"],
|
||||
ports: [3000]
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertNotEqual(base, changedSurfaceMetadata)
|
||||
XCTAssertNotEqual(base, changedSurfaceKind)
|
||||
}
|
||||
|
||||
func testCommandSearchBenchmarkBeatsLegacyPipeline() {
|
||||
let entries = makeCommandEntries(count: 900)
|
||||
let corpus = entries.map { entry in
|
||||
|
|
|
|||
|
|
@ -47,19 +47,6 @@ final class GhosttyConfigTests: XCTestCase {
|
|||
let blue: Int
|
||||
}
|
||||
|
||||
private func writeAppSupportConfig(
|
||||
root: URL,
|
||||
bundleIdentifier: String,
|
||||
name: String = "config",
|
||||
contents: String = "font-size = 14\n"
|
||||
) throws -> URL {
|
||||
let directory = root.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let url = directory.appendingPathComponent(name, isDirectory: false)
|
||||
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||
return url
|
||||
}
|
||||
|
||||
func testResolveThemeNamePrefersLightEntryForPairedTheme() {
|
||||
let resolved = GhosttyConfig.resolveThemeName(
|
||||
from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark",
|
||||
|
|
@ -324,71 +311,84 @@ final class GhosttyConfigTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testReleaseAppSupportFallbackLoadsForDebugWhenOnlyReleaseConfigExists() {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
func testCmuxAppSupportConfigURLsUseReleaseConfigForDebugBundleWithoutCurrentConfig() throws {
|
||||
try withTemporaryAppSupportDirectory { appSupportDirectory in
|
||||
let releaseConfigURL = try writeAppSupportConfig(
|
||||
appSupportDirectory: appSupportDirectory,
|
||||
bundleIdentifier: "com.cmuxterm.app",
|
||||
filename: "config",
|
||||
contents: "font-size = 13\n"
|
||||
)
|
||||
|
||||
let releaseURL = try? writeAppSupportConfig(
|
||||
root: root,
|
||||
bundleIdentifier: "com.cmuxterm.app"
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
GhosttyApp.cmuxAppSupportConfigURLs(
|
||||
currentBundleIdentifier: "com.cmuxterm.app.debug",
|
||||
appSupportDirectory: root
|
||||
),
|
||||
[releaseURL].compactMap { $0 }
|
||||
)
|
||||
XCTAssertEqual(
|
||||
GhosttyApp.cmuxAppSupportConfigURLs(
|
||||
currentBundleIdentifier: "com.cmuxterm.app.debug",
|
||||
appSupportDirectory: appSupportDirectory
|
||||
),
|
||||
[releaseConfigURL]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testReleaseAppSupportFallbackSkipsWhenDebugConfigAlreadyExists() {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
func testCmuxAppSupportConfigURLsPreferCurrentBundleConfigWhenPresent() throws {
|
||||
try withTemporaryAppSupportDirectory { appSupportDirectory in
|
||||
_ = try writeAppSupportConfig(
|
||||
appSupportDirectory: appSupportDirectory,
|
||||
bundleIdentifier: "com.cmuxterm.app",
|
||||
filename: "config",
|
||||
contents: "font-size = 13\n"
|
||||
)
|
||||
let currentConfigURL = try writeAppSupportConfig(
|
||||
appSupportDirectory: appSupportDirectory,
|
||||
bundleIdentifier: "com.cmuxterm.app.debug.issue-829",
|
||||
filename: "config.ghostty",
|
||||
contents: "font-size = 14\n"
|
||||
)
|
||||
|
||||
_ = try? writeAppSupportConfig(root: root, bundleIdentifier: "com.cmuxterm.app")
|
||||
let debugURL = try? writeAppSupportConfig(
|
||||
root: root,
|
||||
bundleIdentifier: "com.cmuxterm.app.debug.issue-829",
|
||||
name: "config.ghostty"
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
GhosttyApp.cmuxAppSupportConfigURLs(
|
||||
currentBundleIdentifier: "com.cmuxterm.app.debug.issue-829",
|
||||
appSupportDirectory: root
|
||||
),
|
||||
[debugURL].compactMap { $0 }
|
||||
)
|
||||
XCTAssertEqual(
|
||||
GhosttyApp.cmuxAppSupportConfigURLs(
|
||||
currentBundleIdentifier: "com.cmuxterm.app.debug.issue-829",
|
||||
appSupportDirectory: appSupportDirectory
|
||||
),
|
||||
[currentConfigURL]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testReleaseAppSupportFallbackSkipsForNonDebugBundleOrMissingReleaseConfig() {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
func testCmuxAppSupportConfigURLsSkipReleaseFallbackForNonDebugBundle() throws {
|
||||
try withTemporaryAppSupportDirectory { appSupportDirectory in
|
||||
_ = try writeAppSupportConfig(
|
||||
appSupportDirectory: appSupportDirectory,
|
||||
bundleIdentifier: "com.cmuxterm.app",
|
||||
filename: "config",
|
||||
contents: "font-size = 13\n"
|
||||
)
|
||||
|
||||
_ = try? writeAppSupportConfig(root: root, bundleIdentifier: "com.cmuxterm.app")
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.cmuxAppSupportConfigURLs(
|
||||
currentBundleIdentifier: "com.example.other-app",
|
||||
appSupportDirectory: appSupportDirectory
|
||||
).isEmpty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
XCTAssertEqual(
|
||||
GhosttyApp.cmuxAppSupportConfigURLs(
|
||||
currentBundleIdentifier: "com.cmuxterm.app",
|
||||
appSupportDirectory: root
|
||||
).count,
|
||||
1
|
||||
)
|
||||
func testCmuxAppSupportConfigURLsIgnoreMissingOrEmptyFiles() throws {
|
||||
try withTemporaryAppSupportDirectory { appSupportDirectory in
|
||||
_ = try writeAppSupportConfig(
|
||||
appSupportDirectory: appSupportDirectory,
|
||||
bundleIdentifier: "com.cmuxterm.app",
|
||||
filename: "config.ghostty",
|
||||
contents: ""
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
GhosttyApp.cmuxAppSupportConfigURLs(
|
||||
currentBundleIdentifier: "com.cmuxterm.app.debug",
|
||||
appSupportDirectory: root.appendingPathComponent("missing", isDirectory: true)
|
||||
),
|
||||
[]
|
||||
)
|
||||
XCTAssertTrue(
|
||||
GhosttyApp.cmuxAppSupportConfigURLs(
|
||||
currentBundleIdentifier: "com.cmuxterm.app.debug",
|
||||
appSupportDirectory: appSupportDirectory
|
||||
).isEmpty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testDefaultBackgroundUpdateScopePrioritizesSurfaceOverAppAndUnscoped() {
|
||||
|
|
@ -597,6 +597,33 @@ final class GhosttyConfigTests: XCTestCase {
|
|||
blue: Int(round(blue * 255))
|
||||
)
|
||||
}
|
||||
|
||||
private func withTemporaryAppSupportDirectory(
|
||||
_ body: (URL) throws -> Void
|
||||
) throws {
|
||||
let fileManager = FileManager.default
|
||||
let directory = fileManager.temporaryDirectory
|
||||
.appendingPathComponent("cmux-app-support-\(UUID().uuidString)", isDirectory: true)
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? fileManager.removeItem(at: directory) }
|
||||
try body(directory)
|
||||
}
|
||||
|
||||
private func writeAppSupportConfig(
|
||||
appSupportDirectory: URL,
|
||||
bundleIdentifier: String,
|
||||
filename: String,
|
||||
contents: String
|
||||
) throws -> URL {
|
||||
let fileManager = FileManager.default
|
||||
let bundleDirectory = appSupportDirectory
|
||||
.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
||||
try fileManager.createDirectory(at: bundleDirectory, withIntermediateDirectories: true)
|
||||
|
||||
let configURL = bundleDirectory.appendingPathComponent(filename, isDirectory: false)
|
||||
try contents.write(to: configURL, atomically: true, encoding: .utf8)
|
||||
return configURL
|
||||
}
|
||||
}
|
||||
|
||||
final class WorkspaceChromeThemeTests: XCTestCase {
|
||||
|
|
@ -1447,6 +1474,7 @@ final class RecentlyClosedBrowserStackTests: XCTestCase {
|
|||
ClosedBrowserPanelRestoreSnapshot(
|
||||
workspaceId: UUID(),
|
||||
url: URL(string: "https://example.com/\(index)"),
|
||||
profileID: nil,
|
||||
originalPaneId: UUID(),
|
||||
originalTabIndex: index,
|
||||
fallbackSplitOrientation: .horizontal,
|
||||
|
|
@ -2018,6 +2046,157 @@ final class GhosttyMouseFocusTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class SidebarBackgroundConfigTests: XCTestCase {
|
||||
|
||||
func testParseSidebarBackgroundSingleHex() {
|
||||
var config = GhosttyConfig()
|
||||
config.parse("sidebar-background = #336699")
|
||||
XCTAssertEqual(config.rawSidebarBackground, "#336699")
|
||||
}
|
||||
|
||||
func testParseSidebarBackgroundDualMode() {
|
||||
var config = GhosttyConfig()
|
||||
config.parse("sidebar-background = light:#fbf3db,dark:#103c48")
|
||||
XCTAssertEqual(config.rawSidebarBackground, "light:#fbf3db,dark:#103c48")
|
||||
}
|
||||
|
||||
func testParseSidebarTintOpacity() {
|
||||
var config = GhosttyConfig()
|
||||
config.parse("sidebar-tint-opacity = 0.4")
|
||||
XCTAssertEqual(config.sidebarTintOpacity ?? -1, 0.4, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testParseSidebarTintOpacityClampedAboveOne() {
|
||||
var config = GhosttyConfig()
|
||||
config.parse("sidebar-tint-opacity = 1.5")
|
||||
XCTAssertEqual(config.sidebarTintOpacity ?? -1, 1.0, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testParseSidebarTintOpacityClampedBelowZero() {
|
||||
var config = GhosttyConfig()
|
||||
config.parse("sidebar-tint-opacity = -0.3")
|
||||
XCTAssertEqual(config.sidebarTintOpacity ?? -1, 0.0, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testResolveSidebarBackgroundSingleHex() {
|
||||
var config = GhosttyConfig()
|
||||
config.rawSidebarBackground = "#336699"
|
||||
config.resolveSidebarBackground(preferredColorScheme: .light)
|
||||
|
||||
XCTAssertNotNil(config.sidebarBackground)
|
||||
XCTAssertNil(config.sidebarBackgroundLight)
|
||||
XCTAssertNil(config.sidebarBackgroundDark)
|
||||
}
|
||||
|
||||
func testResolveSidebarBackgroundDualModeSetsLightAndDark() {
|
||||
var config = GhosttyConfig()
|
||||
config.rawSidebarBackground = "light:#fbf3db,dark:#103c48"
|
||||
config.resolveSidebarBackground(preferredColorScheme: .light)
|
||||
|
||||
XCTAssertNotNil(config.sidebarBackgroundLight)
|
||||
XCTAssertNotNil(config.sidebarBackgroundDark)
|
||||
XCTAssertNotNil(config.sidebarBackground)
|
||||
}
|
||||
|
||||
func testResolveSidebarBackgroundNilWhenNoRaw() {
|
||||
var config = GhosttyConfig()
|
||||
config.resolveSidebarBackground(preferredColorScheme: .dark)
|
||||
|
||||
XCTAssertNil(config.sidebarBackground)
|
||||
XCTAssertNil(config.sidebarBackgroundLight)
|
||||
XCTAssertNil(config.sidebarBackgroundDark)
|
||||
}
|
||||
|
||||
func testApplyToUserDefaultsSkipsWritesWhenNoConfig() {
|
||||
let defaults = UserDefaults.standard
|
||||
let testKey = "sidebarTintHex"
|
||||
let original = defaults.string(forKey: testKey)
|
||||
defer { restoreDefaultsValue(original, key: testKey, defaults: defaults) }
|
||||
|
||||
defaults.set("#AAAAAA", forKey: testKey)
|
||||
|
||||
var config = GhosttyConfig()
|
||||
config.applySidebarAppearanceToUserDefaults()
|
||||
|
||||
XCTAssertEqual(defaults.string(forKey: testKey), "#AAAAAA",
|
||||
"Should not overwrite UserDefaults when rawSidebarBackground is nil")
|
||||
}
|
||||
|
||||
func testApplyToUserDefaultsWritesHexWhenConfigSet() {
|
||||
let defaults = UserDefaults.standard
|
||||
let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark"]
|
||||
let originals = keys.map { defaults.object(forKey: $0) }
|
||||
defer {
|
||||
for (key, original) in zip(keys, originals) {
|
||||
restoreDefaultsValue(original, key: key, defaults: defaults)
|
||||
}
|
||||
}
|
||||
|
||||
var config = GhosttyConfig()
|
||||
config.rawSidebarBackground = "#336699"
|
||||
config.resolveSidebarBackground(preferredColorScheme: .light)
|
||||
config.applySidebarAppearanceToUserDefaults()
|
||||
|
||||
XCTAssertEqual(defaults.string(forKey: "sidebarTintHex"), "#336699")
|
||||
XCTAssertNil(defaults.string(forKey: "sidebarTintHexLight"))
|
||||
XCTAssertNil(defaults.string(forKey: "sidebarTintHexDark"))
|
||||
}
|
||||
|
||||
func testApplyToUserDefaultsClearsStaleKeysOnSwitchFromDualToSingle() {
|
||||
let defaults = UserDefaults.standard
|
||||
let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark"]
|
||||
let originals = keys.map { defaults.object(forKey: $0) }
|
||||
defer {
|
||||
for (key, original) in zip(keys, originals) {
|
||||
restoreDefaultsValue(original, key: key, defaults: defaults)
|
||||
}
|
||||
}
|
||||
|
||||
defaults.set("#AAAAAA", forKey: "sidebarTintHexLight")
|
||||
defaults.set("#BBBBBB", forKey: "sidebarTintHexDark")
|
||||
|
||||
var config = GhosttyConfig()
|
||||
config.rawSidebarBackground = "#222222"
|
||||
config.resolveSidebarBackground(preferredColorScheme: .light)
|
||||
config.applySidebarAppearanceToUserDefaults()
|
||||
|
||||
XCTAssertEqual(defaults.string(forKey: "sidebarTintHex"), "#222222")
|
||||
XCTAssertNil(defaults.string(forKey: "sidebarTintHexLight"),
|
||||
"Stale light key should be cleared")
|
||||
XCTAssertNil(defaults.string(forKey: "sidebarTintHexDark"),
|
||||
"Stale dark key should be cleared")
|
||||
}
|
||||
|
||||
func testApplyToUserDefaultsOnlyWritesOpacityWhenExplicit() {
|
||||
let defaults = UserDefaults.standard
|
||||
let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark", "sidebarTintOpacity"]
|
||||
let originals = keys.map { defaults.object(forKey: $0) }
|
||||
defer {
|
||||
for (key, original) in zip(keys, originals) {
|
||||
restoreDefaultsValue(original, key: key, defaults: defaults)
|
||||
}
|
||||
}
|
||||
|
||||
defaults.set(0.18, forKey: "sidebarTintOpacity")
|
||||
|
||||
var config = GhosttyConfig()
|
||||
config.rawSidebarBackground = "#336699"
|
||||
config.resolveSidebarBackground(preferredColorScheme: .light)
|
||||
config.applySidebarAppearanceToUserDefaults()
|
||||
|
||||
XCTAssertEqual(defaults.double(forKey: "sidebarTintOpacity"), 0.18, accuracy: 0.0001,
|
||||
"Should not overwrite opacity when config doesn't set sidebar-tint-opacity")
|
||||
}
|
||||
|
||||
private func restoreDefaultsValue(_ value: Any?, key: String, defaults: UserDefaults) {
|
||||
if let value = value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class ZshShellIntegrationHandoffTests: XCTestCase {
|
||||
func testGhosttyPromptHooksLoadWhenCmuxRequestsZshIntegration() throws {
|
||||
let output = try runInteractiveZsh(cmuxLoadGhosttyIntegration: true)
|
||||
|
|
@ -2133,3 +2312,232 @@ final class ZshShellIntegrationHandoffTests: XCTestCase {
|
|||
return output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
final class BrowserInstallDetectorTests: XCTestCase {
|
||||
func testDetectInstalledBrowsersUsesBundleIdAndProfileData() throws {
|
||||
let home = makeTemporaryHome()
|
||||
defer { try? FileManager.default.removeItem(at: home) }
|
||||
|
||||
try createFile(
|
||||
at: home
|
||||
.appendingPathComponent("Library/Application Support/Google/Chrome/Default/History"),
|
||||
contents: Data()
|
||||
)
|
||||
try createFile(
|
||||
at: home
|
||||
.appendingPathComponent("Library/Application Support/Firefox/Profiles/dev.default-release/cookies.sqlite"),
|
||||
contents: Data()
|
||||
)
|
||||
|
||||
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
|
||||
homeDirectoryURL: home,
|
||||
bundleLookup: { bundleIdentifier in
|
||||
if bundleIdentifier == "com.google.Chrome" {
|
||||
return URL(fileURLWithPath: "/Applications/Google Chrome.app", isDirectory: true)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
applicationSearchDirectories: []
|
||||
)
|
||||
|
||||
guard let chrome = detected.first(where: { $0.descriptor.id == "google-chrome" }) else {
|
||||
XCTFail("Expected Chrome to be detected")
|
||||
return
|
||||
}
|
||||
guard let firefox = detected.first(where: { $0.descriptor.id == "firefox" }) else {
|
||||
XCTFail("Expected Firefox to be detected from profile data")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertNotNil(chrome.appURL)
|
||||
XCTAssertEqual(firefox.profileURLs.count, 1)
|
||||
XCTAssertNil(firefox.appURL)
|
||||
}
|
||||
|
||||
func testDetectInstalledBrowsersReturnsEmptyWhenNoSignalsExist() throws {
|
||||
let home = makeTemporaryHome()
|
||||
defer { try? FileManager.default.removeItem(at: home) }
|
||||
|
||||
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
|
||||
homeDirectoryURL: home,
|
||||
bundleLookup: { _ in nil },
|
||||
applicationSearchDirectories: []
|
||||
)
|
||||
|
||||
XCTAssertTrue(detected.isEmpty)
|
||||
}
|
||||
|
||||
func testUngoogledChromiumRequiresAppSignal() throws {
|
||||
let home = makeTemporaryHome()
|
||||
defer { try? FileManager.default.removeItem(at: home) }
|
||||
|
||||
try createFile(
|
||||
at: home
|
||||
.appendingPathComponent("Library/Application Support/Chromium/Default/History"),
|
||||
contents: Data()
|
||||
)
|
||||
|
||||
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
|
||||
homeDirectoryURL: home,
|
||||
bundleLookup: { _ in nil },
|
||||
applicationSearchDirectories: []
|
||||
)
|
||||
|
||||
XCTAssertTrue(detected.contains(where: { $0.descriptor.id == "chromium" }))
|
||||
XCTAssertFalse(detected.contains(where: { $0.descriptor.id == "ungoogled-chromium" }))
|
||||
}
|
||||
|
||||
func testDetectInstalledBrowsersDiscoversHeliumProfilesFromChromiumLayout() throws {
|
||||
let home = makeTemporaryHome()
|
||||
defer { try? FileManager.default.removeItem(at: home) }
|
||||
|
||||
let heliumRoot = home.appendingPathComponent("Library/Application Support/net.imput.helium", isDirectory: true)
|
||||
try createFile(
|
||||
at: heliumRoot.appendingPathComponent("Default/History"),
|
||||
contents: Data()
|
||||
)
|
||||
try createFile(
|
||||
at: heliumRoot.appendingPathComponent("Profile 1/Cookies"),
|
||||
contents: Data()
|
||||
)
|
||||
try createFile(
|
||||
at: heliumRoot.appendingPathComponent("Local State"),
|
||||
contents: Data(
|
||||
"""
|
||||
{
|
||||
"profile": {
|
||||
"info_cache": {
|
||||
"Default": {
|
||||
"name": "Personal"
|
||||
},
|
||||
"Profile 1": {
|
||||
"name": "Work"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""".utf8
|
||||
)
|
||||
)
|
||||
|
||||
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
|
||||
homeDirectoryURL: home,
|
||||
bundleLookup: { _ in nil },
|
||||
applicationSearchDirectories: []
|
||||
)
|
||||
|
||||
guard let helium = detected.first(where: { $0.descriptor.id == "helium" }) else {
|
||||
XCTFail("Expected Helium to be detected")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(helium.family, .chromium)
|
||||
XCTAssertEqual(helium.profiles.map(\.displayName), ["Personal", "Work"])
|
||||
XCTAssertEqual(
|
||||
helium.profiles.map(\.rootURL.lastPathComponent),
|
||||
["Default", "Profile 1"]
|
||||
)
|
||||
}
|
||||
|
||||
func testDetectInstalledBrowsersDiscoversSafariProfiles() throws {
|
||||
let home = makeTemporaryHome()
|
||||
defer { try? FileManager.default.removeItem(at: home) }
|
||||
|
||||
try createFile(
|
||||
at: home.appendingPathComponent("Library/Safari/History.db"),
|
||||
contents: Data()
|
||||
)
|
||||
try createFile(
|
||||
at: home.appendingPathComponent(
|
||||
"Library/Safari/Profiles/Work/History.db"
|
||||
),
|
||||
contents: Data()
|
||||
)
|
||||
try createFile(
|
||||
at: home.appendingPathComponent(
|
||||
"Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel/History.db"
|
||||
),
|
||||
contents: Data()
|
||||
)
|
||||
|
||||
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
|
||||
homeDirectoryURL: home,
|
||||
bundleLookup: { _ in nil },
|
||||
applicationSearchDirectories: []
|
||||
)
|
||||
|
||||
guard let safari = detected.first(where: { $0.descriptor.id == "safari" }) else {
|
||||
XCTFail("Expected Safari to be detected")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(safari.profiles.map(\.displayName), ["Default", "Work", "Travel"])
|
||||
XCTAssertEqual(
|
||||
safari.profiles.map { $0.rootURL.path(percentEncoded: false) }.sorted(),
|
||||
[
|
||||
home.appendingPathComponent("Library/Safari", isDirectory: true).path(percentEncoded: false),
|
||||
home.appendingPathComponent("Library/Safari/Profiles/Work", isDirectory: true).path(percentEncoded: false),
|
||||
home.appendingPathComponent(
|
||||
"Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel",
|
||||
isDirectory: true
|
||||
).path(percentEncoded: false),
|
||||
].sorted()
|
||||
)
|
||||
}
|
||||
|
||||
private func makeTemporaryHome() -> URL {
|
||||
FileManager.default.temporaryDirectory.appendingPathComponent("cmux-browser-detect-\(UUID().uuidString)")
|
||||
}
|
||||
|
||||
private func createFile(at url: URL, contents: Data) throws {
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
_ = FileManager.default.createFile(atPath: url.path, contents: contents)
|
||||
}
|
||||
}
|
||||
|
||||
final class BrowserImportScopeTests: XCTestCase {
|
||||
func testFromSelectionCookiesOnly() {
|
||||
let scope = BrowserImportScope.fromSelection(
|
||||
includeCookies: true,
|
||||
includeHistory: false,
|
||||
includeAdditionalData: false
|
||||
)
|
||||
XCTAssertEqual(scope, .cookiesOnly)
|
||||
}
|
||||
|
||||
func testFromSelectionHistoryOnly() {
|
||||
let scope = BrowserImportScope.fromSelection(
|
||||
includeCookies: false,
|
||||
includeHistory: true,
|
||||
includeAdditionalData: false
|
||||
)
|
||||
XCTAssertEqual(scope, .historyOnly)
|
||||
}
|
||||
|
||||
func testFromSelectionCookiesAndHistory() {
|
||||
let scope = BrowserImportScope.fromSelection(
|
||||
includeCookies: true,
|
||||
includeHistory: true,
|
||||
includeAdditionalData: false
|
||||
)
|
||||
XCTAssertEqual(scope, .cookiesAndHistory)
|
||||
}
|
||||
|
||||
func testFromSelectionEverything() {
|
||||
let scope = BrowserImportScope.fromSelection(
|
||||
includeCookies: false,
|
||||
includeHistory: false,
|
||||
includeAdditionalData: true
|
||||
)
|
||||
XCTAssertEqual(scope, .everything)
|
||||
}
|
||||
|
||||
func testFromSelectionRejectsEmptySelection() {
|
||||
let scope = BrowserImportScope.fromSelection(
|
||||
includeCookies: false,
|
||||
includeHistory: false,
|
||||
includeAdditionalData: false
|
||||
)
|
||||
XCTAssertNil(scope)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,8 +184,10 @@ final class SessionPersistenceTests: XCTestCase {
|
|||
}
|
||||
|
||||
func testSessionBrowserPanelSnapshotHistoryRoundTrip() throws {
|
||||
let profileID = UUID(uuidString: "8F03A658-5A84-428B-AD03-5A6D04692F64")
|
||||
let source = SessionBrowserPanelSnapshot(
|
||||
urlString: "https://example.com/current",
|
||||
profileID: profileID,
|
||||
shouldRenderWebView: true,
|
||||
pageZoom: 1.2,
|
||||
developerToolsVisible: true,
|
||||
|
|
@ -201,6 +203,7 @@ final class SessionPersistenceTests: XCTestCase {
|
|||
let data = try JSONEncoder().encode(source)
|
||||
let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: data)
|
||||
XCTAssertEqual(decoded.urlString, source.urlString)
|
||||
XCTAssertEqual(decoded.profileID, source.profileID)
|
||||
XCTAssertEqual(decoded.backHistoryURLStrings, source.backHistoryURLStrings)
|
||||
XCTAssertEqual(decoded.forwardHistoryURLStrings, source.forwardHistoryURLStrings)
|
||||
}
|
||||
|
|
@ -217,6 +220,7 @@ final class SessionPersistenceTests: XCTestCase {
|
|||
|
||||
let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: json)
|
||||
XCTAssertEqual(decoded.urlString, "https://example.com/current")
|
||||
XCTAssertNil(decoded.profileID)
|
||||
XCTAssertNil(decoded.backHistoryURLStrings)
|
||||
XCTAssertNil(decoded.forwardHistoryURLStrings)
|
||||
}
|
||||
|
|
|
|||
17
cmuxTests/SidebarWidthPolicyTests.swift
Normal file
17
cmuxTests/SidebarWidthPolicyTests.swift
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import XCTest
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
#elseif canImport(cmux)
|
||||
@testable import cmux
|
||||
#endif
|
||||
|
||||
final class SidebarWidthPolicyTests: XCTestCase {
|
||||
func testContentViewClampAllowsNarrowSidebarBelowLegacyMinimum() {
|
||||
XCTAssertEqual(
|
||||
ContentView.clampedSidebarWidth(184, maximumWidth: 600),
|
||||
184,
|
||||
accuracy: 0.001
|
||||
)
|
||||
}
|
||||
}
|
||||
159
cmuxUITests/BrowserImportProfilesUITests.swift
Normal file
159
cmuxUITests/BrowserImportProfilesUITests.swift
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import XCTest
|
||||
import Foundation
|
||||
|
||||
final class BrowserImportProfilesUITests: XCTestCase {
|
||||
private var capturePath = ""
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
continueAfterFailure = false
|
||||
capturePath = "/tmp/cmux-ui-test-browser-import-\(UUID().uuidString).json"
|
||||
try? FileManager.default.removeItem(atPath: capturePath)
|
||||
}
|
||||
|
||||
func testMultipleSourceProfilesDefaultToSeparateDestinations() throws {
|
||||
let app = launchApp()
|
||||
|
||||
openImportWizard(app)
|
||||
app.buttons["Next"].click()
|
||||
app.buttons["Next"].click()
|
||||
|
||||
XCTAssertTrue(
|
||||
app.radioButtons["Keep profiles separate"].waitForExistence(timeout: 5.0),
|
||||
"Expected Step 3 to show the separate-profiles default"
|
||||
)
|
||||
XCTAssertTrue(app.radioButtons["Merge all into one cmux profile"].exists)
|
||||
XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-you"].exists)
|
||||
XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-austin"].exists)
|
||||
|
||||
app.buttons["Start Import"].click()
|
||||
|
||||
let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0))
|
||||
XCTAssertEqual(capture["mode"] as? String, "separateProfiles")
|
||||
XCTAssertEqual(capture["scope"] as? String, "cookiesAndHistory")
|
||||
|
||||
let entries = try XCTUnwrap(capture["entries"] as? [[String: Any]])
|
||||
XCTAssertEqual(entries.count, 2)
|
||||
XCTAssertEqual(entries[0]["sourceProfiles"] as? [String], ["You"])
|
||||
XCTAssertEqual(entries[0]["destinationKind"] as? String, "create")
|
||||
XCTAssertEqual(entries[0]["destinationName"] as? String, "You")
|
||||
XCTAssertEqual(entries[1]["sourceProfiles"] as? [String], ["austin"])
|
||||
XCTAssertEqual(entries[1]["destinationKind"] as? String, "create")
|
||||
XCTAssertEqual(entries[1]["destinationName"] as? String, "austin")
|
||||
}
|
||||
|
||||
func testMergeModeCapturesSingleMergedDestination() throws {
|
||||
let app = launchApp()
|
||||
|
||||
openImportWizard(app)
|
||||
app.buttons["Next"].click()
|
||||
app.buttons["Next"].click()
|
||||
|
||||
let mergeRadio = app.radioButtons["Merge all into one cmux profile"]
|
||||
XCTAssertTrue(mergeRadio.waitForExistence(timeout: 5.0))
|
||||
mergeRadio.click()
|
||||
|
||||
XCTAssertTrue(
|
||||
app.popUpButtons["BrowserImportDestinationPopup-merge"].waitForExistence(timeout: 5.0),
|
||||
"Expected merge mode to show the single destination popup"
|
||||
)
|
||||
|
||||
app.buttons["Start Import"].click()
|
||||
|
||||
let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0))
|
||||
XCTAssertEqual(capture["mode"] as? String, "mergeIntoOne")
|
||||
|
||||
let entries = try XCTUnwrap(capture["entries"] as? [[String: Any]])
|
||||
XCTAssertEqual(entries.count, 1)
|
||||
XCTAssertEqual(entries[0]["sourceProfiles"] as? [String], ["You", "austin"])
|
||||
XCTAssertEqual(entries[0]["destinationKind"] as? String, "existing")
|
||||
XCTAssertEqual(entries[0]["destinationName"] as? String, "Default")
|
||||
}
|
||||
|
||||
func testAdditionalDataSelectionCapturesEverythingScope() throws {
|
||||
let app = launchApp()
|
||||
|
||||
openImportWizard(app)
|
||||
app.buttons["Next"].click()
|
||||
app.buttons["Next"].click()
|
||||
|
||||
let cookiesCheckbox = app.checkBoxes["BrowserImportCookiesCheckbox"]
|
||||
XCTAssertTrue(cookiesCheckbox.waitForExistence(timeout: 5.0))
|
||||
cookiesCheckbox.click()
|
||||
|
||||
let historyCheckbox = app.checkBoxes["BrowserImportHistoryCheckbox"]
|
||||
XCTAssertTrue(historyCheckbox.waitForExistence(timeout: 5.0))
|
||||
historyCheckbox.click()
|
||||
|
||||
let additionalDataCheckbox = app.checkBoxes["BrowserImportAdditionalDataCheckbox"]
|
||||
XCTAssertTrue(
|
||||
additionalDataCheckbox.waitForExistence(timeout: 5.0),
|
||||
"Expected Step 3 to expose the additional data checkbox"
|
||||
)
|
||||
additionalDataCheckbox.click()
|
||||
|
||||
app.buttons["Start Import"].click()
|
||||
|
||||
let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0))
|
||||
XCTAssertEqual(capture["scope"] as? String, "everything")
|
||||
}
|
||||
|
||||
private func launchApp() -> XCUIApplication {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_FIXTURE"] = #"{"browserName":"Helium","profiles":["You","austin"]}"#
|
||||
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_DESTINATIONS"] = #"["Default"]"#
|
||||
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_MODE"] = "capture-only"
|
||||
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_CAPTURE_PATH"] = capturePath
|
||||
app.launch()
|
||||
XCTAssertTrue(
|
||||
ensureForegroundAfterLaunch(app, timeout: 12.0),
|
||||
"Expected app to launch in the foreground for browser import UI tests"
|
||||
)
|
||||
return app
|
||||
}
|
||||
|
||||
private func openImportWizard(_ app: XCUIApplication) {
|
||||
let viewMenu = app.menuBars.menuBarItems["View"].firstMatch
|
||||
XCTAssertTrue(viewMenu.waitForExistence(timeout: 5.0), "Expected View menu to exist")
|
||||
viewMenu.click()
|
||||
|
||||
let importItem = app.menuItems["Import From Browser…"].firstMatch
|
||||
XCTAssertTrue(importItem.waitForExistence(timeout: 5.0), "Expected Import From Browser menu item to exist")
|
||||
importItem.click()
|
||||
|
||||
XCTAssertTrue(
|
||||
app.staticTexts["Import Browser Data"].waitForExistence(timeout: 5.0),
|
||||
"Expected the import wizard to open"
|
||||
)
|
||||
}
|
||||
|
||||
private func waitForCapturedSelection(timeout: TimeInterval) -> [String: Any]? {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
let url = URL(fileURLWithPath: capturePath)
|
||||
while Date() < deadline {
|
||||
if let data = try? Data(contentsOf: url),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
return object
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
return object
|
||||
}
|
||||
|
||||
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
|
||||
if app.wait(for: .runningForeground, timeout: timeout) {
|
||||
return true
|
||||
}
|
||||
if app.state == .runningBackground {
|
||||
app.activate()
|
||||
return app.wait(for: .runningForeground, timeout: 6.0)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -294,3 +294,456 @@ final class FeedbackComposerShortcutUITests: XCTestCase {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class CommandPaletteAllSurfacesUITests: XCTestCase {
|
||||
private var socketPath = ""
|
||||
private let hiddenSurfaceToken = "cmux-command-palette-hidden-surface"
|
||||
private let visibleSurfaceToken = "cmux-command-palette-visible-surface"
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
continueAfterFailure = false
|
||||
socketPath = "/tmp/cmux-ui-test-command-palette-\(UUID().uuidString).sock"
|
||||
try? FileManager.default.removeItem(atPath: socketPath)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
try? FileManager.default.removeItem(atPath: socketPath)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testCmdShiftPBackspaceReturnsToWorkspaceResults() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"]
|
||||
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
launchAndActivate(app)
|
||||
|
||||
XCTAssertTrue(
|
||||
sidebarHelpPollUntil(timeout: 8.0) {
|
||||
app.windows.count >= 1
|
||||
},
|
||||
"Expected the main window to be visible"
|
||||
)
|
||||
XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)")
|
||||
|
||||
let mainWindowId = try XCTUnwrap(
|
||||
socketCommand("current_window")?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
|
||||
openCommandPaletteCommands(app: app)
|
||||
|
||||
_ = try XCTUnwrap(
|
||||
waitForCommandPaletteSnapshot(windowId: mainWindowId, mode: "commands", query: "", timeout: 5.0) { snapshot in
|
||||
self.commandPaletteResultRows(from: snapshot).contains { row in
|
||||
let commandId = row["command_id"] as? String ?? ""
|
||||
return !commandId.hasPrefix("switcher.")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
|
||||
|
||||
let switcherSnapshot = try XCTUnwrap(
|
||||
waitForCommandPaletteSnapshot(windowId: mainWindowId, mode: "switcher", query: "", timeout: 5.0) { snapshot in
|
||||
self.commandPaletteResultRows(from: snapshot).contains { row in
|
||||
let commandId = row["command_id"] as? String ?? ""
|
||||
return commandId.hasPrefix("switcher.workspace.")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertTrue(
|
||||
commandPaletteResultRows(from: switcherSnapshot).contains { row in
|
||||
let commandId = row["command_id"] as? String ?? ""
|
||||
return commandId.hasPrefix("switcher.workspace.")
|
||||
},
|
||||
"Expected deleting the command prefix to restore workspace rows. snapshot=\(switcherSnapshot)"
|
||||
)
|
||||
|
||||
let rows = commandPaletteResultRows(from: switcherSnapshot)
|
||||
let firstRowCommandId = rows.first?["command_id"] as? String ?? ""
|
||||
XCTAssertTrue(
|
||||
firstRowCommandId.hasPrefix("switcher.workspace."),
|
||||
"Expected the first restored row to be a workspace. snapshot=\(switcherSnapshot)"
|
||||
)
|
||||
|
||||
let firstWorkspaceRow = try XCTUnwrap(
|
||||
rows.first(where: { row in
|
||||
let commandId = row["command_id"] as? String ?? ""
|
||||
return commandId.hasPrefix("switcher.workspace.")
|
||||
}),
|
||||
"Expected a workspace row in the restored switcher results. snapshot=\(switcherSnapshot)"
|
||||
)
|
||||
let workspaceTitle = try XCTUnwrap(
|
||||
firstWorkspaceRow["title"] as? String,
|
||||
"Expected the restored workspace row to include a title. snapshot=\(switcherSnapshot)"
|
||||
)
|
||||
let workspaceLabel = app.staticTexts[workspaceTitle].firstMatch
|
||||
XCTAssertTrue(
|
||||
sidebarHelpPollUntil(timeout: 2.0) {
|
||||
workspaceLabel.exists && workspaceLabel.isHittable
|
||||
},
|
||||
"Expected the restored workspace row to be visibly rendered. title=\(workspaceTitle) snapshot=\(switcherSnapshot)"
|
||||
)
|
||||
|
||||
let staleCommandLabel = app.staticTexts["Close Other Workspaces"].firstMatch
|
||||
XCTAssertTrue(
|
||||
sidebarHelpPollUntil(timeout: 2.0) {
|
||||
!staleCommandLabel.exists || !staleCommandLabel.isHittable
|
||||
},
|
||||
"Expected the stale command row to disappear after deleting the command prefix. snapshot=\(switcherSnapshot)"
|
||||
)
|
||||
}
|
||||
|
||||
func testCmdPSearchCanIncludeSurfacesFromOtherWorkspacesWhenEnabled() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"]
|
||||
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_SHOW_SETTINGS"] = "1"
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
launchAndActivate(app)
|
||||
|
||||
XCTAssertTrue(
|
||||
sidebarHelpPollUntil(timeout: 8.0) {
|
||||
app.windows.count >= 2
|
||||
},
|
||||
"Expected the main window and Settings window to be visible"
|
||||
)
|
||||
XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)")
|
||||
|
||||
let mainWindowId = try XCTUnwrap(socketCommand("current_window")?.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
let secondaryWorkspaceId = try XCTUnwrap(okUUID(from: socketCommand("new_workspace")))
|
||||
let initialSurfaceId = try XCTUnwrap(waitForSurfaceIDs(minimumCount: 1, timeout: 5.0).first)
|
||||
let hiddenSurfaceId = try XCTUnwrap(okUUID(from: socketCommand("new_surface --type=terminal")))
|
||||
|
||||
XCTAssertEqual(
|
||||
socketCommand("report_pwd /tmp/\(hiddenSurfaceToken) --tab=\(secondaryWorkspaceId) --panel=\(hiddenSurfaceId)"),
|
||||
"OK"
|
||||
)
|
||||
XCTAssertEqual(socketCommand("focus_surface \(initialSurfaceId)"), "OK")
|
||||
XCTAssertEqual(
|
||||
socketCommand("report_pwd /tmp/\(visibleSurfaceToken) --tab=\(secondaryWorkspaceId) --panel=\(initialSurfaceId)"),
|
||||
"OK"
|
||||
)
|
||||
XCTAssertEqual(socketCommand("select_workspace 0"), "OK")
|
||||
XCTAssertEqual(socketCommand("focus_window \(mainWindowId)"), "OK")
|
||||
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
|
||||
|
||||
openCommandPalette(app: app, query: hiddenSurfaceToken)
|
||||
let disabledSnapshot = try XCTUnwrap(
|
||||
waitForCommandPaletteSnapshot(windowId: mainWindowId, query: hiddenSurfaceToken, timeout: 5.0) { snapshot in
|
||||
self.commandPaletteResultRows(from: snapshot).isEmpty
|
||||
}
|
||||
)
|
||||
XCTAssertEqual(commandPaletteResultRows(from: disabledSnapshot).count, 0)
|
||||
dismissCommandPalette(app: app)
|
||||
|
||||
focusSettingsWindow(app: app)
|
||||
let toggle = try requireSearchAllSurfacesToggle(app: app)
|
||||
if !toggleIsOn(toggle) {
|
||||
toggle.click()
|
||||
}
|
||||
XCTAssertTrue(
|
||||
sidebarHelpPollUntil(timeout: 3.0) {
|
||||
toggle.exists && toggleIsOn(toggle)
|
||||
},
|
||||
"Expected the all-surfaces search setting to be enabled"
|
||||
)
|
||||
|
||||
XCTAssertEqual(socketCommand("focus_window \(mainWindowId)"), "OK")
|
||||
|
||||
openCommandPalette(app: app, query: hiddenSurfaceToken)
|
||||
let enabledSnapshot = try XCTUnwrap(
|
||||
waitForCommandPaletteSnapshot(windowId: mainWindowId, query: hiddenSurfaceToken, timeout: 5.0) { snapshot in
|
||||
self.commandPaletteResultRows(from: snapshot).contains { row in
|
||||
let commandId = row["command_id"] as? String ?? ""
|
||||
let trailingLabel = row["trailing_label"] as? String ?? ""
|
||||
return commandId.hasPrefix("switcher.surface.") && trailingLabel == "Terminal"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertTrue(
|
||||
commandPaletteResultRows(from: enabledSnapshot).contains { row in
|
||||
let commandId = row["command_id"] as? String ?? ""
|
||||
let trailingLabel = row["trailing_label"] as? String ?? ""
|
||||
return commandId.hasPrefix("switcher.surface.") && trailingLabel == "Terminal"
|
||||
},
|
||||
"Expected Cmd+P to surface the hidden terminal when all-surfaces search is enabled. snapshot=\(enabledSnapshot)"
|
||||
)
|
||||
}
|
||||
|
||||
private func launchAndActivate(_ app: XCUIApplication) {
|
||||
app.launch()
|
||||
XCTAssertTrue(
|
||||
sidebarHelpPollUntil(timeout: 4.0) {
|
||||
guard app.state != .runningForeground else { return true }
|
||||
app.activate()
|
||||
return app.state == .runningForeground
|
||||
},
|
||||
"App did not reach runningForeground before UI interactions"
|
||||
)
|
||||
}
|
||||
|
||||
private func openCommandPalette(app: XCUIApplication, query: String) {
|
||||
let searchField = app.textFields["CommandPaletteSearchField"]
|
||||
app.typeKey("p", modifierFlags: [.command])
|
||||
XCTAssertTrue(searchField.waitForExistence(timeout: 5.0), "Expected command palette search field")
|
||||
searchField.click()
|
||||
searchField.typeText(query)
|
||||
}
|
||||
|
||||
private func openCommandPaletteCommands(app: XCUIApplication) {
|
||||
let searchField = app.textFields["CommandPaletteSearchField"]
|
||||
app.typeKey("p", modifierFlags: [.command, .shift])
|
||||
XCTAssertTrue(searchField.waitForExistence(timeout: 5.0), "Expected command palette search field")
|
||||
searchField.click()
|
||||
}
|
||||
|
||||
private func dismissCommandPalette(app: XCUIApplication) {
|
||||
let searchField = app.textFields["CommandPaletteSearchField"]
|
||||
for _ in 0..<2 {
|
||||
app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
|
||||
if sidebarHelpPollUntil(timeout: 1.0) { !searchField.exists } {
|
||||
return
|
||||
}
|
||||
}
|
||||
XCTAssertFalse(searchField.exists, "Expected command palette to dismiss")
|
||||
}
|
||||
|
||||
private func focusSettingsWindow(app: XCUIApplication) {
|
||||
app.typeKey(",", modifierFlags: [.command])
|
||||
}
|
||||
|
||||
private func requireSearchAllSurfacesToggle(app: XCUIApplication) throws -> XCUIElement {
|
||||
let toggleId = "CommandPaletteSearchAllSurfacesToggle"
|
||||
let scrollView = app.scrollViews.firstMatch
|
||||
let candidates = [
|
||||
app.switches[toggleId],
|
||||
app.checkBoxes[toggleId],
|
||||
app.buttons[toggleId],
|
||||
app.otherElements[toggleId],
|
||||
]
|
||||
|
||||
for _ in 0..<8 {
|
||||
if let element = firstExistingElement(candidates: candidates, timeout: 0.4), element.isHittable {
|
||||
return element
|
||||
}
|
||||
if scrollView.exists {
|
||||
scrollView.swipeUp()
|
||||
}
|
||||
}
|
||||
|
||||
throw XCTSkip("Could not find the command palette all-surfaces toggle")
|
||||
}
|
||||
|
||||
private func toggleIsOn(_ element: XCUIElement) -> Bool {
|
||||
let value = String(describing: element.value ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return value == "1" || value == "true" || value == "on"
|
||||
}
|
||||
|
||||
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 waitForSocketPong(timeout: TimeInterval) -> Bool {
|
||||
sidebarHelpPollUntil(timeout: timeout) {
|
||||
socketCommand("ping") == "PONG"
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForSurfaceIDs(minimumCount: Int, timeout: TimeInterval) -> [String] {
|
||||
var ids: [String] = []
|
||||
let found = sidebarHelpPollUntil(timeout: timeout) {
|
||||
ids = surfaceIDs()
|
||||
return ids.count >= minimumCount
|
||||
}
|
||||
return found ? ids : surfaceIDs()
|
||||
}
|
||||
|
||||
private func surfaceIDs() -> [String] {
|
||||
guard let response = socketCommand("list_surfaces"), !response.isEmpty, !response.hasPrefix("No surfaces") else {
|
||||
return []
|
||||
}
|
||||
return response
|
||||
.split(separator: "\n")
|
||||
.compactMap { line in
|
||||
guard let range = line.range(of: ": ") else { return nil }
|
||||
return String(line[range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
private func okUUID(from response: String?) -> String? {
|
||||
guard let response, response.hasPrefix("OK ") else { return nil }
|
||||
let value = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return UUID(uuidString: value) != nil ? value : nil
|
||||
}
|
||||
|
||||
private func socketCommand(_ command: String) -> String? {
|
||||
ControlSocketClient(path: socketPath, responseTimeout: 2.0).sendLine(command)
|
||||
}
|
||||
|
||||
private func commandPaletteResultRows(from snapshot: [String: Any]) -> [[String: Any]] {
|
||||
snapshot["results"] as? [[String: Any]] ?? []
|
||||
}
|
||||
|
||||
private func waitForCommandPaletteSnapshot(
|
||||
windowId: String,
|
||||
mode: String = "switcher",
|
||||
query: String,
|
||||
timeout: TimeInterval,
|
||||
predicate: (([String: Any]) -> Bool)? = nil
|
||||
) -> [String: Any]? {
|
||||
var latest: [String: Any]?
|
||||
let matched = sidebarHelpPollUntil(timeout: timeout) {
|
||||
guard let snapshot = commandPaletteSnapshot(windowId: windowId) else { return false }
|
||||
latest = snapshot
|
||||
guard (snapshot["visible"] as? Bool) == true else { return false }
|
||||
guard (snapshot["mode"] as? String) == mode else { return false }
|
||||
guard (snapshot["query"] as? String) == query else { return false }
|
||||
return predicate?(snapshot) ?? true
|
||||
}
|
||||
return matched ? latest : latest
|
||||
}
|
||||
|
||||
private func commandPaletteSnapshot(windowId: String) -> [String: Any]? {
|
||||
let envelope = socketJSON(
|
||||
method: "debug.command_palette.results",
|
||||
params: [
|
||||
"window_id": windowId,
|
||||
"limit": 20,
|
||||
]
|
||||
)
|
||||
guard let ok = envelope?["ok"] as? Bool, ok else { return nil }
|
||||
return envelope?["result"] as? [String: Any]
|
||||
}
|
||||
|
||||
private func socketJSON(method: String, params: [String: Any]) -> [String: Any]? {
|
||||
let request: [String: Any] = [
|
||||
"id": UUID().uuidString,
|
||||
"method": method,
|
||||
"params": params,
|
||||
]
|
||||
return ControlSocketClient(path: socketPath, responseTimeout: 2.0).sendJSON(request)
|
||||
}
|
||||
|
||||
private final class ControlSocketClient {
|
||||
private let path: String
|
||||
private let responseTimeout: TimeInterval
|
||||
|
||||
init(path: String, responseTimeout: TimeInterval) {
|
||||
self.path = path
|
||||
self.responseTimeout = responseTimeout
|
||||
}
|
||||
|
||||
func sendJSON(_ object: [String: Any]) -> [String: Any]? {
|
||||
guard JSONSerialization.isValidJSONObject(object),
|
||||
let data = try? JSONSerialization.data(withJSONObject: object),
|
||||
let line = String(data: data, encoding: .utf8),
|
||||
let response = sendLine(line),
|
||||
let responseData = response.data(using: .utf8),
|
||||
let parsed = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func sendLine(_ line: String) -> 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 bytes = Array(path.utf8CString)
|
||||
guard bytes.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..<bytes.count {
|
||||
raw[index] = bytes[index]
|
||||
}
|
||||
}
|
||||
|
||||
let pathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0
|
||||
let addrLen = socklen_t(pathOffset + bytes.count)
|
||||
#if os(macOS)
|
||||
addr.sun_len = UInt8(min(Int(addrLen), 255))
|
||||
#endif
|
||||
|
||||
let connected = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
connect(fd, sa, addrLen)
|
||||
}
|
||||
}
|
||||
guard connected == 0 else { return nil }
|
||||
|
||||
let payload = line + "\n"
|
||||
let wrote: Bool = 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 wrote else { return nil }
|
||||
|
||||
let deadline = Date().addingTimeInterval(responseTimeout)
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
var accumulator = ""
|
||||
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) {
|
||||
accumulator.append(chunk)
|
||||
if let newline = accumulator.firstIndex(of: "\n") {
|
||||
return String(accumulator[..<newline])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accumulator.isEmpty ? nil : accumulator.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,30 @@ final class SidebarResizeUITests: XCTestCase {
|
|||
XCTAssertGreaterThanOrEqual(leftDelta, -122, "Resizer moved farther than requested drag-left offset")
|
||||
}
|
||||
|
||||
func testSidebarResizerAllowsSmallerMinimumWidth() {
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
let window = app.windows.firstMatch
|
||||
XCTAssertTrue(window.waitForExistence(timeout: 5.0))
|
||||
|
||||
let elements = app.descendants(matching: .any)
|
||||
let resizer = elements["SidebarResizer"]
|
||||
XCTAssertTrue(resizer.waitForExistence(timeout: 5.0))
|
||||
XCTAssertTrue(waitForElementHittable(resizer, timeout: 5.0), "Expected sidebar resizer to become hittable")
|
||||
|
||||
let start = resizer.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
||||
let farLeft = start.withOffset(CGVector(dx: -max(200, window.frame.width), dy: 0))
|
||||
start.press(forDuration: 0.1, thenDragTo: farLeft)
|
||||
|
||||
let sidebarWidth = max(0, resizer.frame.midX - window.frame.minX)
|
||||
XCTAssertLessThanOrEqual(
|
||||
sidebarWidth,
|
||||
185,
|
||||
"Expected sidebar minimum width to allow a narrower sidebar than the previous 186 px floor. width=\(sidebarWidth)"
|
||||
)
|
||||
}
|
||||
|
||||
func testSidebarResizerHasMaximumWidthCap() {
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
|
|
|||
|
|
@ -124,6 +124,23 @@ final class UpdatePillUITests: XCTestCase {
|
|||
assertVisibleSize(noUpdatePill)
|
||||
}
|
||||
|
||||
func testBackgroundDetectedUpdateKeepsOnlyBottomUpdatePill() {
|
||||
let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences")
|
||||
systemSettings.terminate()
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_DETECTED_UPDATE_VERSION"] = "9.9.9"
|
||||
app.launchEnvironment["CMUX_UI_TEST_UPDATE_STATE"] = "available"
|
||||
app.launchEnvironment["CMUX_UI_TEST_UPDATE_VERSION"] = "9.9.9"
|
||||
launchAndActivate(app)
|
||||
|
||||
let pill = pillButton(app: app, expectedLabel: "Update Available: 9.9.9")
|
||||
XCTAssertTrue(pill.waitForExistence(timeout: 6.0))
|
||||
assertVisibleSize(pill)
|
||||
XCTAssertFalse(app.otherElements["SidebarUpdateBanner"].exists)
|
||||
XCTAssertFalse(app.buttons["SidebarUpdateBannerAction"].exists)
|
||||
}
|
||||
|
||||
func testNoSparklePermissionDialogIsShown() {
|
||||
let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences")
|
||||
systemSettings.terminate()
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit dcfaa081e5b3e0ad62c5c1a5a4d58f4562f6be71
|
||||
Subproject commit a5f372ecfa5ee3903af6e1faba0eda096b4f5746
|
||||
127
scripts/build-ghostty-cli-helper.sh
Executable file
127
scripts/build-ghostty-cli-helper.sh
Executable file
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ./scripts/build-ghostty-cli-helper.sh [--universal | --target <zig-target>] --output <path>
|
||||
|
||||
Options:
|
||||
--universal Build a universal macOS helper (arm64 + x86_64).
|
||||
--target <triple>
|
||||
Build a single target, e.g. `aarch64-macos` or `x86_64-macos`.
|
||||
--output <path> Destination path for the built helper.
|
||||
EOF
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
GHOSTTY_DIR="$REPO_ROOT/ghostty"
|
||||
|
||||
OUTPUT_PATH=""
|
||||
TARGET_TRIPLE=""
|
||||
UNIVERSAL="false"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--universal)
|
||||
UNIVERSAL="true"
|
||||
shift
|
||||
;;
|
||||
--target)
|
||||
TARGET_TRIPLE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--output)
|
||||
OUTPUT_PATH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$OUTPUT_PATH" ]]; then
|
||||
echo "Missing required --output path" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$UNIVERSAL" == "true" && -n "$TARGET_TRIPLE" ]]; then
|
||||
echo "--universal and --target are mutually exclusive" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$TARGET_TRIPLE" ]]; then
|
||||
case "$TARGET_TRIPLE" in
|
||||
aarch64-macos|x86_64-macos)
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported --target value: $TARGET_TRIPLE" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if ! command -v zig >/dev/null 2>&1; then
|
||||
echo "error: zig is required to build the Ghostty CLI helper" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$GHOSTTY_DIR/build.zig" ]]; then
|
||||
echo "error: Ghostty submodule is missing at $GHOSTTY_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build_helper() {
|
||||
local prefix="$1"
|
||||
local target="${2:-}"
|
||||
local args=(
|
||||
zig build
|
||||
cli-helper
|
||||
-Dapp-runtime=none
|
||||
-Demit-macos-app=false
|
||||
-Demit-xcframework=false
|
||||
-Doptimize=ReleaseFast
|
||||
--prefix
|
||||
"$prefix"
|
||||
)
|
||||
|
||||
if [[ -n "$target" ]]; then
|
||||
args+=("-Dtarget=$target")
|
||||
fi
|
||||
|
||||
(
|
||||
cd "$GHOSTTY_DIR"
|
||||
"${args[@]}"
|
||||
)
|
||||
}
|
||||
|
||||
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cmux-ghostty-helper.XXXXXX")"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
mkdir -p "$(dirname "$OUTPUT_PATH")"
|
||||
|
||||
if [[ "$UNIVERSAL" == "true" ]]; then
|
||||
ARM64_PREFIX="$TMP_DIR/arm64"
|
||||
X86_PREFIX="$TMP_DIR/x86_64"
|
||||
build_helper "$ARM64_PREFIX" "aarch64-macos"
|
||||
build_helper "$X86_PREFIX" "x86_64-macos"
|
||||
/usr/bin/lipo -create \
|
||||
"$ARM64_PREFIX/bin/ghostty" \
|
||||
"$X86_PREFIX/bin/ghostty" \
|
||||
-output "$OUTPUT_PATH"
|
||||
else
|
||||
SINGLE_PREFIX="$TMP_DIR/single"
|
||||
build_helper "$SINGLE_PREFIX" "$TARGET_TRIPLE"
|
||||
install -m 755 "$SINGLE_PREFIX/bin/ghostty" "$OUTPUT_PATH"
|
||||
fi
|
||||
|
||||
chmod +x "$OUTPUT_PATH"
|
||||
|
|
@ -74,6 +74,12 @@ rm -rf build/
|
|||
xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO build 2>&1 | tail -5
|
||||
echo "Build succeeded"
|
||||
|
||||
HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty"
|
||||
if [ ! -x "$HELPER_PATH" ]; then
|
||||
echo "Ghostty theme picker helper not found at $HELPER_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Inject Sparkle keys ---
|
||||
echo "Injecting Sparkle keys..."
|
||||
SPARKLE_PUBLIC_KEY_DERIVED=$(swift scripts/derive_sparkle_public_key.swift "$SPARKLE_PRIVATE_KEY")
|
||||
|
|
@ -90,6 +96,9 @@ CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
|
|||
if [ -f "$CLI_PATH" ]; then
|
||||
/usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$ENTITLEMENTS" "$CLI_PATH"
|
||||
fi
|
||||
if [ -f "$HELPER_PATH" ]; then
|
||||
/usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$ENTITLEMENTS" "$HELPER_PATH"
|
||||
fi
|
||||
/usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH"
|
||||
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
|
||||
echo "Codesign verified"
|
||||
|
|
|
|||
42
tests/test_bundled_ghostty_theme_picker_helper.sh
Executable file
42
tests/test_bundled_ghostty_theme_picker_helper.sh
Executable file
|
|
@ -0,0 +1,42 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SOURCE_PACKAGES_DIR="${CMUX_SOURCE_PACKAGES_DIR:-$PWD/.ci-source-packages}"
|
||||
DERIVED_DATA_PATH="${CMUX_DERIVED_DATA_PATH:-$PWD/.ci-bundled-ghostty-helper}"
|
||||
CONFIGURATION="${CMUX_CONFIGURATION:-Debug}"
|
||||
|
||||
case "$CONFIGURATION" in
|
||||
Debug)
|
||||
APP_NAME="cmux DEV.app"
|
||||
;;
|
||||
Release)
|
||||
APP_NAME="cmux.app"
|
||||
;;
|
||||
*)
|
||||
echo "FAIL: unsupported configuration $CONFIGURATION" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir -p "$SOURCE_PACKAGES_DIR"
|
||||
rm -rf "$DERIVED_DATA_PATH"
|
||||
|
||||
xcodebuild \
|
||||
-project GhosttyTabs.xcodeproj \
|
||||
-scheme cmux \
|
||||
-configuration "$CONFIGURATION" \
|
||||
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
|
||||
-disableAutomaticPackageResolution \
|
||||
-derivedDataPath "$DERIVED_DATA_PATH" \
|
||||
-destination "platform=macOS" \
|
||||
build
|
||||
|
||||
APP_PATH="$DERIVED_DATA_PATH/Build/Products/$CONFIGURATION/$APP_NAME"
|
||||
HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty"
|
||||
|
||||
if [ ! -x "$HELPER_PATH" ]; then
|
||||
echo "FAIL: bundled Ghostty theme picker helper missing at $HELPER_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PASS: bundled Ghostty theme picker helper present at $HELPER_PATH"
|
||||
|
|
@ -139,10 +139,22 @@ def test_live_socket_injects_supported_hooks(failures: list[str]) -> None:
|
|||
|
||||
settings = parse_settings_arg(real_argv)
|
||||
hooks = settings.get("hooks", {})
|
||||
expect(set(hooks.keys()) == {"SessionStart", "Stop", "Notification"}, f"unexpected hook keys: {hooks.keys()}", failures)
|
||||
serialized = json.dumps(settings, sort_keys=True)
|
||||
expect("UserPromptSubmit" not in serialized, "UserPromptSubmit hook should not be injected", failures)
|
||||
expect("prompt-submit" not in serialized, "prompt-submit subcommand should not be injected", failures)
|
||||
expected_hooks = {"SessionStart", "Stop", "SessionEnd", "Notification", "UserPromptSubmit", "PreToolUse"}
|
||||
expect(set(hooks.keys()) == expected_hooks, f"unexpected hook keys: {hooks.keys()}, expected {expected_hooks}", failures)
|
||||
# PreToolUse should be async to avoid blocking tool execution
|
||||
pre_tool_use_hooks = hooks.get("PreToolUse", [{}])[0].get("hooks", [{}])
|
||||
expect(
|
||||
any(h.get("async") is True for h in pre_tool_use_hooks),
|
||||
f"PreToolUse hook should have async:true, got {pre_tool_use_hooks}",
|
||||
failures,
|
||||
)
|
||||
# SessionEnd should have a short timeout (session is exiting)
|
||||
session_end_hooks = hooks.get("SessionEnd", [{}])[0].get("hooks", [{}])
|
||||
expect(
|
||||
any(h.get("timeout", 999) <= 2 for h in session_end_hooks),
|
||||
f"SessionEnd hook should have short timeout, got {session_end_hooks}",
|
||||
failures,
|
||||
)
|
||||
|
||||
|
||||
def test_missing_socket_skips_hook_injection(failures: list[str]) -> None:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ Validates that shell integration:
|
|||
4) recovers when a gh probe wedges longer than the async timeout
|
||||
5) keeps polling in bash after prompt-render helper commands run
|
||||
6) tears down the timed-out gh probe instead of leaking it in the background
|
||||
7) falls back to explicit branch lookup when implicit gh branch resolution fails
|
||||
8) does not clear an existing PR badge on the first prompt while establishing
|
||||
the HEAD baseline
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -77,6 +80,11 @@ def _git_stub() -> str:
|
|||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$1" = "remote" ] && [ "$2" = "get-url" ] && [ "$3" = "origin" ]; then
|
||||
printf 'https://github.com/manaflow-ai/cmux.git\\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$1" = "status" ] && [ "$2" = "--porcelain" ] && [ "$3" = "-uno" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
|
@ -111,6 +119,17 @@ def _gh_stub() -> str:
|
|||
exit 9
|
||||
fi
|
||||
|
||||
requested_branch=""
|
||||
if [ $# -ge 3 ]; then
|
||||
case "$3" in
|
||||
--*)
|
||||
;;
|
||||
*)
|
||||
requested_branch="$3"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
branch=""
|
||||
if [ -f "$head_file" ]; then
|
||||
head_line="$(cat "$head_file")"
|
||||
|
|
@ -125,6 +144,9 @@ def _gh_stub() -> str:
|
|||
prompt_helper_idle)
|
||||
printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n'
|
||||
;;
|
||||
initial_prompt_preserves_pr_badge)
|
||||
printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n'
|
||||
;;
|
||||
transient_same_context)
|
||||
if [ "$count" -eq 1 ]; then
|
||||
printf 'rate limit exceeded\\n' >&2
|
||||
|
|
@ -154,6 +176,18 @@ def _gh_stub() -> str:
|
|||
fi
|
||||
printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n'
|
||||
;;
|
||||
explicit_branch_fallback)
|
||||
if [ -z "$requested_branch" ]; then
|
||||
printf 'no pull requests found for branch "%s"\\n' "$branch" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$requested_branch" = "$branch" ]; then
|
||||
printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n'
|
||||
exit 0
|
||||
fi
|
||||
printf 'unexpected branch lookup: %s\\n' "$requested_branch" >&2
|
||||
exit 8
|
||||
;;
|
||||
*)
|
||||
printf 'unknown scenario: %s\\n' "$scenario" >&2
|
||||
exit 2
|
||||
|
|
@ -198,6 +232,20 @@ def _shell_command(kind: str, scenario: str) -> str:
|
|||
'sleep 4\n'
|
||||
'_cmux_cleanup\n'
|
||||
),
|
||||
"explicit_branch_fallback": (
|
||||
'cd "$CMUX_TEST_REPO"\n'
|
||||
'_CMUX_PR_POLL_INTERVAL=10\n'
|
||||
'_cmux_prompt_entry\n'
|
||||
'sleep 2\n'
|
||||
'_cmux_cleanup\n'
|
||||
),
|
||||
"initial_prompt_preserves_pr_badge": (
|
||||
'cd "$CMUX_TEST_REPO"\n'
|
||||
'_CMUX_PR_POLL_INTERVAL=10\n'
|
||||
'_cmux_prompt_entry\n'
|
||||
'sleep 2\n'
|
||||
'_cmux_cleanup\n'
|
||||
),
|
||||
}[scenario]
|
||||
|
||||
if kind == "zsh":
|
||||
|
|
@ -344,6 +392,27 @@ def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path, sc
|
|||
return (1, f"{shell}/{scenario}: timed-out gh probe still running as pid {gh_pid}")
|
||||
return (0, f"{shell}/{scenario}: ok")
|
||||
|
||||
if scenario == "explicit_branch_fallback":
|
||||
if _report_line(1138) not in send_lines:
|
||||
return (1, f"{shell}/{scenario}: missing report_pr payload\n" + "\n".join(send_lines))
|
||||
if not any(line.startswith("pr view feature/issue-1138 ") for line in gh_args_lines):
|
||||
return (
|
||||
1,
|
||||
f"{shell}/{scenario}: expected explicit branch fallback\n" + "\n".join(gh_args_lines),
|
||||
)
|
||||
return (0, f"{shell}/{scenario}: ok")
|
||||
|
||||
if scenario == "initial_prompt_preserves_pr_badge":
|
||||
if _report_line(1138) not in send_lines:
|
||||
return (1, f"{shell}/{scenario}: missing report_pr payload\n" + "\n".join(send_lines))
|
||||
if any(line.startswith("clear_pr ") for line in send_lines):
|
||||
return (
|
||||
1,
|
||||
f"{shell}/{scenario}: initial prompt should not clear an existing PR badge\n"
|
||||
+ "\n".join(send_lines),
|
||||
)
|
||||
return (0, f"{shell}/{scenario}: ok")
|
||||
|
||||
return (1, f"{shell}/{scenario}: unhandled scenario")
|
||||
|
||||
|
||||
|
|
@ -358,6 +427,8 @@ def main() -> int:
|
|||
"transient_same_context",
|
||||
"branch_switch_clear",
|
||||
"timeout_recovery",
|
||||
"explicit_branch_fallback",
|
||||
"initial_prompt_preserves_pr_badge",
|
||||
]
|
||||
|
||||
base = Path("/tmp") / f"cmux_issue_1138_pr_poll_{os.getpid()}"
|
||||
|
|
|
|||
2
vendor/bonsplit
vendored
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 73c1ef2df9a6c8a2837212ecce900794d0f21826
|
||||
Subproject commit 02fa188ccd244b1e6efc037e4ed631e966144795
|
||||
|
|
@ -158,7 +158,7 @@ printf '\\e]99;i=1;e=1;d=1;p=body:All tests passed\\e\\\\'`}</CodeBlock>
|
|||
[ -S /tmp/cmux.sock ] || exit 0
|
||||
|
||||
EVENT=$(cat)
|
||||
EVENT_TYPE=$(echo "$EVENT" | jq -r '.event // "unknown"')
|
||||
EVENT_TYPE=$(echo "$EVENT" | jq -r '.hook_event_name // "unknown"')
|
||||
TOOL=$(echo "$EVENT" | jq -r '.tool_name // ""')
|
||||
|
||||
case "$EVENT_TYPE" in
|
||||
|
|
@ -174,11 +174,26 @@ esac`}</CodeBlock>
|
|||
<h3>{t("configureClaude")}</h3>
|
||||
<CodeBlock title="~/.claude/settings.json" lang="json">{`{
|
||||
"hooks": {
|
||||
"Stop": ["~/.claude/hooks/cmux-notify.sh"],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.claude/hooks/cmux-notify.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Task",
|
||||
"hooks": ["~/.claude/hooks/cmux-notify.sh"]
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.claude/hooks/cmux-notify.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue