Merge remote-tracking branch 'origin/main' into pr-374-ssh-remote-cli-relay
# Conflicts: # CLI/cmux.swift # Sources/ContentView.swift # Sources/TerminalController.swift # Sources/Workspace.swift
This commit is contained in:
commit
f16d8f36e7
37 changed files with 9050 additions and 647 deletions
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
|
|
@ -11,7 +11,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Validate self-hosted runner guards
|
||||
run: ./tests/test_ci_self_hosted_guard.sh
|
||||
|
|
@ -23,10 +23,10 @@ jobs:
|
|||
working-directory: web
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
|
@ -43,7 +43,7 @@ jobs:
|
|||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
|
|
|
|||
6
.github/workflows/nightly.yml
vendored
6
.github/workflows/nightly.yml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
steps:
|
||||
- name: Decide whether a nightly build is needed
|
||||
id: decide
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
env:
|
||||
FORCE_BUILD: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force == 'true' && 'true' || 'false' }}
|
||||
with:
|
||||
|
|
@ -84,7 +84,7 @@ jobs:
|
|||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: ${{ needs.decide.outputs.head_sha }}
|
||||
submodules: recursive
|
||||
|
|
@ -326,7 +326,7 @@ jobs:
|
|||
git push origin refs/tags/nightly --force
|
||||
|
||||
- name: Publish nightly release assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
with:
|
||||
tag_name: nightly
|
||||
name: Nightly
|
||||
|
|
|
|||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -17,13 +17,13 @@ jobs:
|
|||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Guard immutable release assets
|
||||
id: guard_release_assets
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
script: |
|
||||
const { evaluateReleaseAssetGuard } = require('./scripts/release_asset_guard');
|
||||
|
|
@ -277,7 +277,7 @@ jobs:
|
|||
|
||||
- name: Upload release asset
|
||||
if: steps.guard_release_assets.outputs.skip_upload != 'true'
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
with:
|
||||
files: |
|
||||
cmux-macos.dmg
|
||||
|
|
|
|||
2
.github/workflows/update-homebrew.yml
vendored
2
.github/workflows/update-homebrew.yml
vendored
|
|
@ -65,7 +65,7 @@ jobs:
|
|||
echo "DMG SHA256: $SHA256"
|
||||
|
||||
- name: Checkout homebrew-cmux
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
repository: manaflow-ai/homebrew-cmux
|
||||
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
|
|
|
|||
339
CLI/cmux.swift
339
CLI/cmux.swift
|
|
@ -1,6 +1,9 @@
|
|||
import Foundation
|
||||
import Darwin
|
||||
import Security
|
||||
#if canImport(Sentry)
|
||||
import Sentry
|
||||
#endif
|
||||
|
||||
struct CLIError: Error, CustomStringConvertible {
|
||||
let message: String
|
||||
|
|
@ -8,6 +11,182 @@ struct CLIError: Error, CustomStringConvertible {
|
|||
var description: String { message }
|
||||
}
|
||||
|
||||
private final class CLISocketSentryTelemetry {
|
||||
private let command: String
|
||||
private let subcommand: String
|
||||
private let socketPath: String
|
||||
private let envSocketPath: String?
|
||||
private let workspaceId: String?
|
||||
private let surfaceId: String?
|
||||
private let disabledByEnv: Bool
|
||||
|
||||
#if canImport(Sentry)
|
||||
private static let startupLock = NSLock()
|
||||
private static var started = false
|
||||
private static let dsn = "https://ecba1ec90ecaee02a102fba931b6d2b3@o4507547940749312.ingest.us.sentry.io/4510796264636416"
|
||||
#endif
|
||||
|
||||
init(command: String, commandArgs: [String], socketPath: String, processEnv: [String: String]) {
|
||||
self.command = command.lowercased()
|
||||
self.subcommand = commandArgs.first?.lowercased() ?? "help"
|
||||
self.socketPath = socketPath
|
||||
self.envSocketPath = processEnv["CMUX_SOCKET_PATH"]
|
||||
self.workspaceId = processEnv["CMUX_WORKSPACE_ID"]
|
||||
self.surfaceId = processEnv["CMUX_SURFACE_ID"]
|
||||
self.disabledByEnv =
|
||||
processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" ||
|
||||
processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1"
|
||||
}
|
||||
|
||||
func breadcrumb(_ message: String, data: [String: Any] = [:]) {
|
||||
guard shouldEmit else { return }
|
||||
#if canImport(Sentry)
|
||||
Self.ensureStarted()
|
||||
var payload = baseContext()
|
||||
for (key, value) in data {
|
||||
payload[key] = value
|
||||
}
|
||||
let crumb = Breadcrumb(level: .info, category: "cmux.cli")
|
||||
crumb.message = message
|
||||
crumb.data = payload
|
||||
SentrySDK.addBreadcrumb(crumb)
|
||||
#endif
|
||||
}
|
||||
|
||||
func captureError(stage: String, error: Error) {
|
||||
guard shouldEmit else { return }
|
||||
#if canImport(Sentry)
|
||||
Self.ensureStarted()
|
||||
var context = baseContext()
|
||||
context["stage"] = stage
|
||||
context["error"] = String(describing: error)
|
||||
for (key, value) in socketDiagnostics() {
|
||||
context[key] = value
|
||||
}
|
||||
let subcommand = self.subcommand
|
||||
let command = self.command
|
||||
_ = SentrySDK.capture(error: error) { scope in
|
||||
scope.setLevel(.error)
|
||||
scope.setTag(value: "cmux-cli", key: "component")
|
||||
scope.setTag(value: command, key: "cli_command")
|
||||
scope.setTag(value: subcommand, key: "cli_subcommand")
|
||||
scope.setContext(value: context, key: "cli_socket")
|
||||
}
|
||||
SentrySDK.flush(timeout: 2.0)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var shouldEmit: Bool {
|
||||
!disabledByEnv
|
||||
}
|
||||
|
||||
private func baseContext() -> [String: Any] {
|
||||
var context: [String: Any] = [
|
||||
"command": command,
|
||||
"subcommand": subcommand,
|
||||
"requested_socket_path": socketPath,
|
||||
"env_socket_path": envSocketPath ?? "<unset>"
|
||||
]
|
||||
if let workspaceId {
|
||||
context["workspace_id"] = workspaceId
|
||||
}
|
||||
if let surfaceId {
|
||||
context["surface_id"] = surfaceId
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
private func socketDiagnostics() -> [String: Any] {
|
||||
var context: [String: Any] = [
|
||||
"cwd": FileManager.default.currentDirectoryPath,
|
||||
"uid": Int(getuid()),
|
||||
"euid": Int(geteuid())
|
||||
]
|
||||
|
||||
var st = stat()
|
||||
if lstat(socketPath, &st) == 0 {
|
||||
context["socket_exists"] = true
|
||||
context["socket_mode"] = String(format: "%o", Int(st.st_mode & 0o7777))
|
||||
context["socket_owner_uid"] = Int(st.st_uid)
|
||||
context["socket_owner_gid"] = Int(st.st_gid)
|
||||
context["socket_file_type"] = Self.fileTypeDescription(mode: st.st_mode)
|
||||
} else {
|
||||
let code = errno
|
||||
context["socket_exists"] = false
|
||||
context["socket_errno"] = Int(code)
|
||||
context["socket_errno_description"] = String(cString: strerror(code))
|
||||
}
|
||||
|
||||
let tmpSockets = Self.discoverTmpCmuxSockets(limit: 10)
|
||||
if !tmpSockets.isEmpty {
|
||||
context["tmp_cmux_sockets"] = tmpSockets
|
||||
}
|
||||
let taggedSockets = tmpSockets.filter { $0 != "/tmp/cmux.sock" }
|
||||
if socketPath == "/tmp/cmux.sock",
|
||||
(envSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true),
|
||||
!taggedSockets.isEmpty {
|
||||
context["possible_root_cause"] = "CMUX_SOCKET_PATH missing while tagged sockets exist"
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
private static func fileTypeDescription(mode: mode_t) -> String {
|
||||
switch mode & mode_t(S_IFMT) {
|
||||
case mode_t(S_IFSOCK):
|
||||
return "socket"
|
||||
case mode_t(S_IFREG):
|
||||
return "regular"
|
||||
case mode_t(S_IFDIR):
|
||||
return "directory"
|
||||
case mode_t(S_IFLNK):
|
||||
return "symlink"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
private static func discoverTmpCmuxSockets(limit: Int) -> [String] {
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: "/tmp") else {
|
||||
return []
|
||||
}
|
||||
var sockets: [String] = []
|
||||
for name in entries.sorted() {
|
||||
guard name.hasPrefix("cmux"), name.hasSuffix(".sock") else { continue }
|
||||
let fullPath = "/tmp/\(name)"
|
||||
var st = stat()
|
||||
guard lstat(fullPath, &st) == 0 else { continue }
|
||||
guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue }
|
||||
sockets.append(fullPath)
|
||||
if sockets.count >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return sockets
|
||||
}
|
||||
|
||||
#if canImport(Sentry)
|
||||
private static func ensureStarted() {
|
||||
startupLock.lock()
|
||||
defer { startupLock.unlock() }
|
||||
guard !started else { return }
|
||||
SentrySDK.start { options in
|
||||
options.dsn = dsn
|
||||
#if DEBUG
|
||||
options.environment = "development-cli"
|
||||
#else
|
||||
options.environment = "production-cli"
|
||||
#endif
|
||||
options.debug = false
|
||||
options.sendDefaultPii = true
|
||||
options.attachStacktrace = true
|
||||
options.tracesSampleRate = 0.0
|
||||
}
|
||||
started = true
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
struct WindowInfo {
|
||||
let index: Int
|
||||
let id: String
|
||||
|
|
@ -555,6 +734,12 @@ struct CMUXCLI {
|
|||
|
||||
let command = args[index]
|
||||
let commandArgs = Array(args[(index + 1)...])
|
||||
let cliTelemetry = CLISocketSentryTelemetry(
|
||||
command: command,
|
||||
commandArgs: commandArgs,
|
||||
socketPath: socketPath,
|
||||
processEnv: ProcessInfo.processInfo.environment
|
||||
)
|
||||
|
||||
if command == "version" {
|
||||
print(versionSummary())
|
||||
|
|
@ -570,7 +755,18 @@ struct CMUXCLI {
|
|||
}
|
||||
|
||||
let client = SocketClient(path: socketPath)
|
||||
try client.connect()
|
||||
cliTelemetry.breadcrumb(
|
||||
"socket.connect.attempt",
|
||||
data: ["command": command]
|
||||
)
|
||||
do {
|
||||
try client.connect()
|
||||
cliTelemetry.breadcrumb("socket.connect.success")
|
||||
} catch {
|
||||
cliTelemetry.breadcrumb("socket.connect.failure")
|
||||
cliTelemetry.captureError(stage: "socket_connect", error: error)
|
||||
throw error
|
||||
}
|
||||
defer { client.close() }
|
||||
|
||||
if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg) {
|
||||
|
|
@ -1166,7 +1362,15 @@ struct CMUXCLI {
|
|||
print(response)
|
||||
|
||||
case "claude-hook":
|
||||
try runClaudeHook(commandArgs: commandArgs, client: client)
|
||||
cliTelemetry.breadcrumb("claude-hook.dispatch")
|
||||
do {
|
||||
try runClaudeHook(commandArgs: commandArgs, client: client, telemetry: cliTelemetry)
|
||||
cliTelemetry.breadcrumb("claude-hook.completed")
|
||||
} catch {
|
||||
cliTelemetry.breadcrumb("claude-hook.failure")
|
||||
cliTelemetry.captureError(stage: "claude_hook_dispatch", error: error)
|
||||
throw error
|
||||
}
|
||||
|
||||
case "set-app-focus":
|
||||
guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") }
|
||||
|
|
@ -4422,7 +4626,11 @@ fi
|
|||
}
|
||||
}
|
||||
|
||||
private func runClaudeHook(commandArgs: [String], client: SocketClient) throws {
|
||||
private func runClaudeHook(
|
||||
commandArgs: [String],
|
||||
client: SocketClient,
|
||||
telemetry: CLISocketSentryTelemetry
|
||||
) throws {
|
||||
let subcommand = commandArgs.first?.lowercased() ?? "help"
|
||||
let hookArgs = Array(commandArgs.dropFirst())
|
||||
let hookWsFlag = optionValue(hookArgs, name: "--workspace")
|
||||
|
|
@ -4431,11 +4639,21 @@ fi
|
|||
let rawInput = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
let parsedInput = parseClaudeHookInput(rawInput: rawInput)
|
||||
let sessionStore = ClaudeHookSessionStore()
|
||||
telemetry.breadcrumb(
|
||||
"claude-hook.input",
|
||||
data: [
|
||||
"subcommand": subcommand,
|
||||
"has_session_id": parsedInput.sessionId != nil,
|
||||
"has_workspace_flag": hookWsFlag != nil,
|
||||
"has_surface_flag": optionValue(hookArgs, name: "--surface") != nil
|
||||
]
|
||||
)
|
||||
let fallbackWorkspaceId = try resolveWorkspaceIdForClaudeHook(workspaceArg, client: client)
|
||||
let fallbackSurfaceId = try? resolveSurfaceId(surfaceArg, workspaceId: fallbackWorkspaceId, client: client)
|
||||
|
||||
switch subcommand {
|
||||
case "session-start", "active":
|
||||
telemetry.breadcrumb("claude-hook.session-start")
|
||||
let workspaceId = fallbackWorkspaceId
|
||||
let surfaceId = try resolveSurfaceIdForClaudeHook(
|
||||
surfaceArg,
|
||||
|
|
@ -4460,6 +4678,7 @@ fi
|
|||
print("OK")
|
||||
|
||||
case "stop", "idle":
|
||||
telemetry.breadcrumb("claude-hook.stop")
|
||||
let consumedSession = try? sessionStore.consume(
|
||||
sessionId: parsedInput.sessionId,
|
||||
workspaceId: fallbackWorkspaceId,
|
||||
|
|
@ -4488,6 +4707,7 @@ fi
|
|||
}
|
||||
|
||||
case "notification", "notify":
|
||||
telemetry.breadcrumb("claude-hook.notification")
|
||||
let summary = summarizeClaudeHookNotification(rawInput: rawInput)
|
||||
|
||||
var workspaceId = fallbackWorkspaceId
|
||||
|
|
@ -4532,6 +4752,7 @@ fi
|
|||
print(response)
|
||||
|
||||
case "help", "--help", "-h":
|
||||
telemetry.breadcrumb("claude-hook.help")
|
||||
print(
|
||||
"""
|
||||
cmux claude-hook <session-start|stop|notification> [--workspace <id|index>] [--surface <id|index>]
|
||||
|
|
@ -4820,39 +5041,63 @@ fi
|
|||
|
||||
private func versionSummary() -> String {
|
||||
let info = resolvedVersionInfo()
|
||||
let commit = info["CMUXCommit"].flatMap { normalizedCommitHash($0) }
|
||||
let baseSummary: String
|
||||
if let version = info["CFBundleShortVersionString"], let build = info["CFBundleVersion"] {
|
||||
return "cmux \(version) (\(build))"
|
||||
baseSummary = "cmux \(version) (\(build))"
|
||||
} else if let version = info["CFBundleShortVersionString"] {
|
||||
baseSummary = "cmux \(version)"
|
||||
} else if let build = info["CFBundleVersion"] {
|
||||
baseSummary = "cmux build \(build)"
|
||||
} else {
|
||||
baseSummary = "cmux version unknown"
|
||||
}
|
||||
if let version = info["CFBundleShortVersionString"] {
|
||||
return "cmux \(version)"
|
||||
}
|
||||
if let build = info["CFBundleVersion"] {
|
||||
return "cmux build \(build)"
|
||||
}
|
||||
return "cmux version unknown"
|
||||
guard let commit else { return baseSummary }
|
||||
return "\(baseSummary) [\(commit)]"
|
||||
}
|
||||
|
||||
private func resolvedVersionInfo() -> [String: String] {
|
||||
var info: [String: String] = [:]
|
||||
if let main = versionInfo(from: Bundle.main.infoDictionary) {
|
||||
return main
|
||||
info.merge(main, uniquingKeysWith: { current, _ in current })
|
||||
}
|
||||
|
||||
for plistURL in candidateInfoPlistURLs() {
|
||||
guard let data = try? Data(contentsOf: plistURL),
|
||||
let raw = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil),
|
||||
let dictionary = raw as? [String: Any],
|
||||
let parsed = versionInfo(from: dictionary)
|
||||
else {
|
||||
continue
|
||||
let needsPlistFallback =
|
||||
info["CFBundleShortVersionString"] == nil ||
|
||||
info["CFBundleVersion"] == nil ||
|
||||
info["CMUXCommit"] == nil
|
||||
if needsPlistFallback {
|
||||
for plistURL in candidateInfoPlistURLs() {
|
||||
guard let data = try? Data(contentsOf: plistURL),
|
||||
let raw = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil),
|
||||
let dictionary = raw as? [String: Any],
|
||||
let parsed = versionInfo(from: dictionary)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
info.merge(parsed, uniquingKeysWith: { current, _ in current })
|
||||
if info["CFBundleShortVersionString"] != nil,
|
||||
info["CFBundleVersion"] != nil,
|
||||
info["CMUXCommit"] != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
if let fromProject = versionInfoFromProjectFile() {
|
||||
return fromProject
|
||||
let needsProjectFallback =
|
||||
info["CFBundleShortVersionString"] == nil ||
|
||||
info["CFBundleVersion"] == nil ||
|
||||
info["CMUXCommit"] == nil
|
||||
if needsProjectFallback, let fromProject = versionInfoFromProjectFile() {
|
||||
info.merge(fromProject, uniquingKeysWith: { current, _ in current })
|
||||
}
|
||||
|
||||
return [:]
|
||||
if info["CMUXCommit"] == nil,
|
||||
let commit = normalizedCommitHash(ProcessInfo.processInfo.environment["CMUX_COMMIT"]) {
|
||||
info["CMUXCommit"] = commit
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
private func versionInfo(from dictionary: [String: Any]?) -> [String: String]? {
|
||||
|
|
@ -4871,6 +5116,10 @@ fi
|
|||
info["CFBundleVersion"] = trimmed
|
||||
}
|
||||
}
|
||||
if let commit = dictionary["CMUXCommit"] as? String,
|
||||
let normalizedCommit = normalizedCommitHash(commit) {
|
||||
info["CMUXCommit"] = normalizedCommit
|
||||
}
|
||||
return info.isEmpty ? nil : info
|
||||
}
|
||||
|
||||
|
|
@ -4896,6 +5145,9 @@ fi
|
|||
if let build = firstProjectSetting("CURRENT_PROJECT_VERSION", in: contents) {
|
||||
info["CFBundleVersion"] = build
|
||||
}
|
||||
if let commit = gitCommitHash(at: current) {
|
||||
info["CMUXCommit"] = commit
|
||||
}
|
||||
if !info.isEmpty {
|
||||
return info
|
||||
}
|
||||
|
|
@ -4932,6 +5184,45 @@ fi
|
|||
return value
|
||||
}
|
||||
|
||||
private func gitCommitHash(at directory: URL) -> String? {
|
||||
let process = Process()
|
||||
let stdout = Pipe()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
process.arguments = ["git", "-C", directory.path, "rev-parse", "--short=9", "HEAD"]
|
||||
process.standardOutput = stdout
|
||||
process.standardError = Pipe()
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
process.waitUntilExit()
|
||||
guard process.terminationStatus == 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let data = stdout.fileHandleForReading.readDataToEndOfFile()
|
||||
guard let output = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return normalizedCommitHash(output)
|
||||
}
|
||||
|
||||
private func normalizedCommitHash(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, !trimmed.contains("$(") else {
|
||||
return nil
|
||||
}
|
||||
let normalized = trimmed.lowercased()
|
||||
let allowed = CharacterSet(charactersIn: "0123456789abcdef")
|
||||
guard normalized.unicodeScalars.allSatisfy({ allowed.contains($0) }) else {
|
||||
return nil
|
||||
}
|
||||
return String(normalized.prefix(12))
|
||||
}
|
||||
|
||||
private func candidateInfoPlistURLs() -> [URL] {
|
||||
guard let executable = currentExecutablePath(), !executable.isEmpty else {
|
||||
return []
|
||||
|
|
@ -5145,6 +5436,8 @@ fi
|
|||
CMUX_SOCKET_PATH Override the default Unix socket path.
|
||||
Debug CLI defaults: /tmp/cmux-last-socket-path -> /tmp/cmux-debug.sock.
|
||||
Release CLI default: /tmp/cmux.sock.
|
||||
CMUX_CLI_SENTRY_DISABLED
|
||||
Set to 1 to disable CLI Sentry socket diagnostics.
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; };
|
||||
A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; };
|
||||
A5001250 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; };
|
||||
B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; };
|
||||
A5001270 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = A5001271 /* PostHog */; };
|
||||
A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; };
|
||||
A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; };
|
||||
|
|
@ -242,6 +243,7 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -465,6 +467,9 @@
|
|||
);
|
||||
dependencies = (
|
||||
);
|
||||
packageProductDependencies = (
|
||||
A5001251 /* Sentry */,
|
||||
);
|
||||
name = "cmux-cli";
|
||||
productName = cmux;
|
||||
productReference = B9000004A1B2C3D4E5F60719 /* cmux */;
|
||||
|
|
@ -801,6 +806,12 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path",
|
||||
"@executable_path/../Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
PRODUCT_NAME = cmux;
|
||||
PRODUCT_MODULE_NAME = cmux_cli;
|
||||
|
|
@ -814,6 +825,12 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path",
|
||||
"@executable_path/../Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
PRODUCT_NAME = cmux;
|
||||
PRODUCT_MODULE_NAME = cmux_cli;
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ Split a browser alongside your terminal with a scriptable API ported from <a hre
|
|||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Vertical + horizontal tabs</h3>
|
||||
Sidebar shows git branch, working directory, listening ports, and latest notification text. Split horizontally and vertically.
|
||||
Sidebar shows git branch, linked PR status/number, working directory, listening ports, and latest notification text. Split horizontally and vertically.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Vertical tabs and split panes" width="100%" />
|
||||
|
|
@ -96,7 +96,7 @@ I run a lot of Claude Code and Codex sessions in parallel. I was using Ghostty w
|
|||
|
||||
I tried a few coding orchestrators but most of them were Electron/Tauri apps and the performance bugged me. I also just prefer the terminal since GUI orchestrators lock you into their workflow. So I built cmux as a native macOS app in Swift/AppKit. It uses libghostty for terminal rendering and reads your existing Ghostty config for themes, fonts, and colors.
|
||||
|
||||
The main additions are the sidebar and notification system. The sidebar has vertical tabs that show git branch, working directory, listening ports, and the latest notification text for each workspace. The notification system picks up terminal sequences (OSC 9/99/777) and has a CLI (`cmux notify`) you can wire into agent hooks for Claude Code, OpenCode, etc. When an agent is waiting, its pane gets a blue ring and the tab lights up in the sidebar, so I can tell which one needs me across splits and tabs. Cmd+Shift+U jumps to the most recent unread.
|
||||
The main additions are the sidebar and notification system. The sidebar has vertical tabs that show git branch, linked PR status/number, working directory, listening ports, and the latest notification text for each workspace. The notification system picks up terminal sequences (OSC 9/99/777) and has a CLI (`cmux notify`) you can wire into agent hooks for Claude Code, OpenCode, etc. When an agent is waiting, its pane gets a blue ring and the tab lights up in the sidebar, so I can tell which one needs me across splits and tabs. Cmd+Shift+U jumps to the most recent unread.
|
||||
|
||||
The in-app browser has a scriptable API ported from [agent-browser](https://github.com/vercel-labs/agent-browser). Agents can snapshot the accessibility tree, get element refs, click, fill forms, and evaluate JS. You can split a browser pane next to your terminal and have Claude Code interact with your dev server directly.
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ _CMUX_PWD_LAST_PWD="${_CMUX_PWD_LAST_PWD:-}"
|
|||
_CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}"
|
||||
_CMUX_GIT_LAST_RUN="${_CMUX_GIT_LAST_RUN:-0}"
|
||||
_CMUX_GIT_JOB_PID="${_CMUX_GIT_JOB_PID:-}"
|
||||
_CMUX_PR_LAST_PWD="${_CMUX_PR_LAST_PWD:-}"
|
||||
_CMUX_PR_LAST_RUN="${_CMUX_PR_LAST_RUN:-0}"
|
||||
_CMUX_PR_JOB_PID="${_CMUX_PR_JOB_PID:-}"
|
||||
|
||||
_CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
|
||||
_CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}"
|
||||
|
|
@ -127,6 +130,48 @@ _cmux_prompt_command() {
|
|||
_CMUX_GIT_JOB_PID=$!
|
||||
fi
|
||||
|
||||
# Pull request metadata (number/state/url):
|
||||
# refresh on cwd change and periodically to avoid stale status.
|
||||
if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
|
||||
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then
|
||||
kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true
|
||||
_CMUX_PR_JOB_PID=""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then
|
||||
if [[ -z "$_CMUX_PR_JOB_PID" ]] || ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
|
||||
_CMUX_PR_LAST_PWD="$pwd"
|
||||
_CMUX_PR_LAST_RUN=$now
|
||||
{
|
||||
local branch pr_tsv number state url status_opt=""
|
||||
branch=$(git branch --show-current 2>/dev/null)
|
||||
if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
|
||||
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
pr_tsv="$(gh pr view --json number,state,url --jq '[.number, .state, .url] | @tsv' 2>/dev/null || true)"
|
||||
if [[ -z "$pr_tsv" ]]; then
|
||||
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
IFS=$'\t' read -r number state url <<< "$pr_tsv"
|
||||
if [[ -z "$number" || -z "$url" ]]; then
|
||||
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
case "$state" in
|
||||
MERGED) status_opt="--state=merged" ;;
|
||||
OPEN) status_opt="--state=open" ;;
|
||||
CLOSED) status_opt="--state=closed" ;;
|
||||
*) status_opt="" ;;
|
||||
esac
|
||||
_cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
} >/dev/null 2>&1 &
|
||||
_CMUX_PR_JOB_PID=$!
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ports: lightweight kick to the app's batched scanner every ~10s.
|
||||
if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then
|
||||
_cmux_ports_kick
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ typeset -g _CMUX_GIT_HEAD_LAST_PWD=""
|
|||
typeset -g _CMUX_GIT_HEAD_PATH=""
|
||||
typeset -g _CMUX_GIT_HEAD_MTIME=0
|
||||
typeset -g _CMUX_HAVE_ZSTAT=0
|
||||
typeset -g _CMUX_PR_LAST_PWD=""
|
||||
typeset -g _CMUX_PR_LAST_RUN=0
|
||||
typeset -g _CMUX_PR_JOB_PID=""
|
||||
typeset -g _CMUX_PR_FORCE=0
|
||||
|
||||
typeset -g _CMUX_PORTS_LAST_RUN=0
|
||||
typeset -g _CMUX_CMD_START=0
|
||||
|
|
@ -155,7 +159,8 @@ _cmux_preexec() {
|
|||
local cmd="${1## }"
|
||||
case "$cmd" in
|
||||
git\ *|git|gh\ *|lazygit|lazygit\ *|tig|tig\ *|gitui|gitui\ *|stg\ *|jj\ *)
|
||||
_CMUX_GIT_FORCE=1 ;;
|
||||
_CMUX_GIT_FORCE=1
|
||||
_CMUX_PR_FORCE=1 ;;
|
||||
esac
|
||||
|
||||
# Register TTY + kick batched port scan for foreground commands (servers).
|
||||
|
|
@ -212,6 +217,7 @@ _cmux_precmd() {
|
|||
# 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
|
||||
|
|
@ -261,6 +267,63 @@ _cmux_precmd() {
|
|||
fi
|
||||
fi
|
||||
|
||||
# Pull request metadata (number/state/url):
|
||||
# - refresh on cwd change, explicit git/gh commands, and occasionally for status drift
|
||||
# - keep this independent from the git probe cadence to avoid hitting GitHub too often
|
||||
local should_pr=0
|
||||
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then
|
||||
should_pr=1
|
||||
elif (( _CMUX_PR_FORCE )); then
|
||||
should_pr=1
|
||||
elif (( now - _CMUX_PR_LAST_RUN >= 60 )); then
|
||||
should_pr=1
|
||||
fi
|
||||
|
||||
if (( should_pr )); then
|
||||
local can_launch_pr=1
|
||||
if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
|
||||
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( _CMUX_PR_FORCE )); then
|
||||
kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true
|
||||
_CMUX_PR_JOB_PID=""
|
||||
else
|
||||
can_launch_pr=0
|
||||
fi
|
||||
fi
|
||||
|
||||
if (( can_launch_pr )); then
|
||||
_CMUX_PR_FORCE=0
|
||||
_CMUX_PR_LAST_PWD="$pwd"
|
||||
_CMUX_PR_LAST_RUN=$now
|
||||
{
|
||||
local branch pr_tsv number state url status_opt=""
|
||||
branch=$(git branch --show-current 2>/dev/null)
|
||||
if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
|
||||
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
pr_tsv="$(gh pr view --json number,state,url --jq '[.number, .state, .url] | @tsv' 2>/dev/null || true)"
|
||||
if [[ -z "$pr_tsv" ]]; then
|
||||
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
local IFS=$'\t'
|
||||
read -r number state url <<< "$pr_tsv"
|
||||
if [[ -z "$number" ]] || [[ -z "$url" ]]; then
|
||||
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
case "$state" in
|
||||
MERGED) status_opt="--state=merged" ;;
|
||||
OPEN) status_opt="--state=open" ;;
|
||||
CLOSED) status_opt="--state=closed" ;;
|
||||
*) status_opt="" ;;
|
||||
esac
|
||||
_cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
} >/dev/null 2>&1 &!
|
||||
_CMUX_PR_JOB_PID=$!
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ports: lightweight kick to the app's batched scanner.
|
||||
# - Periodic scan to avoid stale values.
|
||||
# - Forced scan when a long-running command returns to the prompt (common when stopping a server).
|
||||
|
|
|
|||
|
|
@ -3229,6 +3229,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
updateController.attemptUpdate()
|
||||
}
|
||||
|
||||
@objc func restartSocketListener(_ sender: Any?) {
|
||||
guard let tabManager else {
|
||||
NSSound.beep()
|
||||
return
|
||||
}
|
||||
|
||||
let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey)
|
||||
?? SocketControlSettings.defaultMode.rawValue
|
||||
let userMode = SocketControlSettings.migrateMode(raw)
|
||||
let mode = SocketControlSettings.effectiveMode(userMode: userMode)
|
||||
guard mode != .off else {
|
||||
TerminalController.shared.stop()
|
||||
NSSound.beep()
|
||||
return
|
||||
}
|
||||
|
||||
let socketPath = SocketControlSettings.socketPath()
|
||||
sentryBreadcrumb("socket.listener.restart", category: "socket", data: [
|
||||
"mode": mode.rawValue,
|
||||
"path": socketPath
|
||||
])
|
||||
TerminalController.shared.stop()
|
||||
TerminalController.shared.start(tabManager: tabManager, socketPath: socketPath, accessMode: mode)
|
||||
}
|
||||
|
||||
private func setupMenuBarExtra() {
|
||||
let store = TerminalNotificationStore.shared
|
||||
menuBarExtraController = MenuBarExtraController(
|
||||
|
|
@ -3250,7 +3275,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
self?.checkForUpdates(nil)
|
||||
},
|
||||
onOpenPreferences: { [weak self] in
|
||||
self?.openPreferencesWindow()
|
||||
self?.openPreferencesWindow(debugSource: "menuBarExtra")
|
||||
},
|
||||
onQuitApp: {
|
||||
NSApp.terminate(nil)
|
||||
|
|
@ -3258,9 +3283,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func presentPreferencesWindow(
|
||||
showFallbackSettingsWindow: @MainActor () -> Void = {
|
||||
SettingsWindowController.shared.show()
|
||||
},
|
||||
activateApplication: @MainActor () -> Void = {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
) {
|
||||
#if DEBUG
|
||||
dlog("settings.open.present path=customWindowDirect")
|
||||
#endif
|
||||
showFallbackSettingsWindow()
|
||||
activateApplication()
|
||||
#if DEBUG
|
||||
dlog("settings.open.present activate=1")
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func openPreferencesWindow(debugSource: String) {
|
||||
#if DEBUG
|
||||
dlog("settings.open.request source=\(debugSource)")
|
||||
#endif
|
||||
Self.presentPreferencesWindow()
|
||||
}
|
||||
|
||||
@objc func openPreferencesWindow() {
|
||||
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
openPreferencesWindow(debugSource: "appDelegate")
|
||||
}
|
||||
|
||||
func refreshMenuBarExtraForDebug() {
|
||||
|
|
@ -4512,6 +4563,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return true
|
||||
}
|
||||
|
||||
// Cmd+W must close the focused panel even if first-responder momentarily lags on a
|
||||
// browser NSTextView during split focus transitions.
|
||||
if normalizedFlags == [.command], (chars == "w" || event.keyCode == 13) {
|
||||
if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow,
|
||||
targetWindow.identifier?.rawValue == "cmux.settings" {
|
||||
targetWindow.performClose(nil)
|
||||
} else {
|
||||
let responder = event.window?.firstResponder
|
||||
?? NSApp.keyWindow?.firstResponder
|
||||
?? NSApp.mainWindow?.firstResponder
|
||||
if let ghosttyView = cmuxOwningGhosttyView(for: responder),
|
||||
let workspaceId = ghosttyView.tabId,
|
||||
let panelId = ghosttyView.terminalSurface?.id,
|
||||
let manager = tabManagerFor(tabId: workspaceId) ?? tabManager {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"shortcut.cmdW route=ghostty workspace=\(workspaceId.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) selected=\(manager.selectedTabId?.uuidString.prefix(5) ?? "nil")"
|
||||
)
|
||||
#endif
|
||||
manager.closePanelWithConfirmation(tabId: workspaceId, surfaceId: panelId)
|
||||
} else {
|
||||
#if DEBUG
|
||||
dlog("shortcut.cmdW route=focusedPanelFallback")
|
||||
#endif
|
||||
tabManager?.closeCurrentPanelWithConfirmation()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .closeWorkspace)) {
|
||||
tabManager?.closeCurrentWorkspaceWithConfirmation()
|
||||
return true
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2137,6 +2137,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
return ghostty_surface_has_selection(surface)
|
||||
case #selector(paste(_:)), #selector(pasteAsPlainText(_:)):
|
||||
return GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD)
|
||||
case #selector(splitHorizontally(_:)), #selector(splitVertically(_:)):
|
||||
return canSplitCurrentSurface()
|
||||
default:
|
||||
return true
|
||||
}
|
||||
|
|
@ -2763,9 +2765,63 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
}
|
||||
let pasteItem = menu.addItem(withTitle: "Paste", action: #selector(paste(_:)), keyEquivalent: "")
|
||||
pasteItem.target = self
|
||||
menu.addItem(.separator())
|
||||
let splitHorizontallyItem = menu.addItem(
|
||||
withTitle: "Split Horizontally",
|
||||
action: #selector(splitHorizontally(_:)),
|
||||
keyEquivalent: "d"
|
||||
)
|
||||
splitHorizontallyItem.target = self
|
||||
splitHorizontallyItem.keyEquivalentModifierMask = [.command, .shift]
|
||||
splitHorizontallyItem.image = NSImage(
|
||||
systemSymbolName: "rectangle.bottomhalf.inset.filled",
|
||||
accessibilityDescription: nil
|
||||
)
|
||||
|
||||
let splitVerticallyItem = menu.addItem(
|
||||
withTitle: "Split Vertically",
|
||||
action: #selector(splitVertically(_:)),
|
||||
keyEquivalent: "d"
|
||||
)
|
||||
splitVerticallyItem.target = self
|
||||
splitVerticallyItem.keyEquivalentModifierMask = [.command]
|
||||
splitVerticallyItem.image = NSImage(
|
||||
systemSymbolName: "rectangle.righthalf.inset.filled",
|
||||
accessibilityDescription: nil
|
||||
)
|
||||
return menu
|
||||
}
|
||||
|
||||
private func canSplitCurrentSurface() -> Bool {
|
||||
guard let tabId,
|
||||
let surfaceId = terminalSurface?.id,
|
||||
let app = AppDelegate.shared,
|
||||
let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager,
|
||||
let workspace = manager.tabs.first(where: { $0.id == tabId }) else {
|
||||
return false
|
||||
}
|
||||
return workspace.panels[surfaceId] != nil
|
||||
}
|
||||
|
||||
@objc private func splitHorizontally(_ sender: Any?) {
|
||||
_ = splitCurrentSurface(direction: .down)
|
||||
}
|
||||
|
||||
@objc private func splitVertically(_ sender: Any?) {
|
||||
_ = splitCurrentSurface(direction: .right)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func splitCurrentSurface(direction: SplitDirection) -> Bool {
|
||||
guard let tabId,
|
||||
let surfaceId = terminalSurface?.id,
|
||||
let app = AppDelegate.shared,
|
||||
let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else {
|
||||
return false
|
||||
}
|
||||
return manager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil
|
||||
}
|
||||
|
||||
@objc private func triggerFlash(_ sender: Any?) {
|
||||
onTriggerFlash?()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,6 +127,9 @@ enum BrowserLinkOpenSettings {
|
|||
static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser"
|
||||
static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true
|
||||
|
||||
static let openSidebarPullRequestLinksInCmuxBrowserKey = "browserOpenSidebarPullRequestLinksInCmuxBrowser"
|
||||
static let defaultOpenSidebarPullRequestLinksInCmuxBrowser: Bool = true
|
||||
|
||||
static let interceptTerminalOpenCommandInCmuxBrowserKey = "browserInterceptTerminalOpenCommandInCmuxBrowser"
|
||||
static let defaultInterceptTerminalOpenCommandInCmuxBrowser: Bool = true
|
||||
|
||||
|
|
@ -140,6 +143,13 @@ enum BrowserLinkOpenSettings {
|
|||
return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey)
|
||||
}
|
||||
|
||||
static func openSidebarPullRequestLinksInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool {
|
||||
if defaults.object(forKey: openSidebarPullRequestLinksInCmuxBrowserKey) == nil {
|
||||
return defaultOpenSidebarPullRequestLinksInCmuxBrowser
|
||||
}
|
||||
return defaults.bool(forKey: openSidebarPullRequestLinksInCmuxBrowserKey)
|
||||
}
|
||||
|
||||
static func interceptTerminalOpenCommandInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool {
|
||||
if defaults.object(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) != nil {
|
||||
return defaults.bool(forKey: interceptTerminalOpenCommandInCmuxBrowserKey)
|
||||
|
|
|
|||
|
|
@ -156,13 +156,10 @@ private struct OmnibarAddressButtonStyleBody: View {
|
|||
}
|
||||
|
||||
private extension View {
|
||||
@ViewBuilder
|
||||
func cmuxFlatSymbolColorRendering() -> some View {
|
||||
if #available(macOS 26.0, *) {
|
||||
self.symbolColorRenderingMode(.flat)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
// `symbolColorRenderingMode(.flat)` is not available in the current SDK
|
||||
// used by CI/local builds. Keep this modifier as a compatibility no-op.
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -139,8 +139,11 @@ final class TerminalPanel: Panel, ObservableObject {
|
|||
|
||||
func close() {
|
||||
// The surface will be cleaned up by its deinit
|
||||
// Just unfocus before closing
|
||||
// Detach from the window portal on real close so stale hosted views
|
||||
// cannot remain above browser panes after split close.
|
||||
unfocus()
|
||||
hostedView.setVisibleInUI(false)
|
||||
TerminalWindowPortalRegistry.detach(hostedView: hostedView)
|
||||
}
|
||||
|
||||
func requestViewReattach() {
|
||||
|
|
|
|||
|
|
@ -163,6 +163,8 @@ struct SocketControlSettings {
|
|||
static let legacyEnabledKey = "socketControlEnabled"
|
||||
static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE"
|
||||
static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD"
|
||||
static let launchTagEnvKey = "CMUX_TAG"
|
||||
static let baseDebugBundleIdentifier = "com.cmuxterm.app.debug"
|
||||
|
||||
private static func normalizeMode(_ raw: String) -> String {
|
||||
raw
|
||||
|
|
@ -211,6 +213,58 @@ struct SocketControlSettings {
|
|||
#endif
|
||||
}
|
||||
|
||||
static func launchTag(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
) -> String? {
|
||||
guard let raw = environment[launchTagEnvKey] else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func shouldBlockUntaggedDebugLaunch(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
|
||||
isDebugBuild: Bool = SocketControlSettings.isDebugBuild
|
||||
) -> Bool {
|
||||
guard isDebugBuild else { return false }
|
||||
if isRunningUnderXCTest(environment: environment) {
|
||||
return false
|
||||
}
|
||||
// XCUITest launches the app as a separate process without XCTest env vars,
|
||||
// so isRunningUnderXCTest() misses it. Check for any CMUX_UI_TEST_ env var.
|
||||
if environment.keys.contains(where: { $0.hasPrefix("CMUX_UI_TEST_") }) {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let bundleIdentifier = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!bundleIdentifier.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
if bundleIdentifier.hasPrefix("\(baseDebugBundleIdentifier).") {
|
||||
return false
|
||||
}
|
||||
|
||||
guard bundleIdentifier == baseDebugBundleIdentifier else {
|
||||
return false
|
||||
}
|
||||
|
||||
return launchTag(environment: environment) == nil
|
||||
}
|
||||
|
||||
static func isRunningUnderXCTest(environment: [String: String]) -> Bool {
|
||||
let indicators = [
|
||||
"XCTestConfigurationFilePath",
|
||||
"XCTestBundlePath",
|
||||
"XCTestSessionIdentifier",
|
||||
"XCInjectBundleInto",
|
||||
]
|
||||
return indicators.contains { key in
|
||||
guard let value = environment[key] else { return false }
|
||||
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
static func socketPath(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
|
||||
|
|
|
|||
|
|
@ -1160,10 +1160,28 @@ class TabManager: ObservableObject {
|
|||
}
|
||||
|
||||
private func closePanelWithConfirmation(tab: Workspace, panelId: UUID) {
|
||||
let bonsplitTabCount = tab.bonsplitController.allPaneIds.reduce(0) { partial, paneId in
|
||||
partial + tab.bonsplitController.tabs(inPane: paneId).count
|
||||
}
|
||||
let panelKind: String = {
|
||||
guard let panel = tab.panels[panelId] else { return "missing" }
|
||||
if panel is TerminalPanel { return "terminal" }
|
||||
if panel is BrowserPanel { return "browser" }
|
||||
return String(describing: type(of: panel))
|
||||
}()
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.close.shortcut.begin tab=\(tab.id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) kind=\(panelKind) " +
|
||||
"panelCount=\(tab.panels.count) bonsplitTabs=\(bonsplitTabCount)"
|
||||
)
|
||||
#endif
|
||||
|
||||
// Cmd+W closes the focused Bonsplit tab (a "tab" in the UI). When the workspace only has
|
||||
// a single tab left, closing it should close the workspace (and possibly the window),
|
||||
// rather than creating a replacement terminal.
|
||||
let isLastTabInWorkspace = tab.panels.count <= 1
|
||||
let effectiveSurfaceCount = max(tab.panels.count, bonsplitTabCount)
|
||||
let isLastTabInWorkspace = effectiveSurfaceCount <= 1
|
||||
if isLastTabInWorkspace {
|
||||
let willCloseWindow = tabs.count <= 1
|
||||
let needsConfirm = workspaceNeedsConfirmClose(tab)
|
||||
|
|
@ -1171,11 +1189,25 @@ class TabManager: ObservableObject {
|
|||
let message = willCloseWindow
|
||||
? "This will close the last tab and close the window."
|
||||
: "This will close the last tab and close its workspace."
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) reason=lastTab"
|
||||
)
|
||||
#endif
|
||||
guard confirmClose(
|
||||
title: "Close tab?",
|
||||
message: message,
|
||||
acceptCmdD: willCloseWindow
|
||||
) else { return }
|
||||
) else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.close.shortcut.cancel tab=\(tab.id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) reason=lastTabConfirmDismissed"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id)
|
||||
|
|
@ -1189,15 +1221,36 @@ class TabManager: ObservableObject {
|
|||
|
||||
if let terminalPanel = tab.terminalPanel(for: panelId),
|
||||
terminalPanel.needsConfirmClose() {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) reason=terminalNeedsConfirm"
|
||||
)
|
||||
#endif
|
||||
guard confirmClose(
|
||||
title: "Close tab?",
|
||||
message: "This will close the current tab.",
|
||||
acceptCmdD: false
|
||||
) else { return }
|
||||
) else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.close.shortcut.cancel tab=\(tab.id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) reason=terminalConfirmDismissed"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// We already confirmed (if needed); bypass Bonsplit's delegate gating.
|
||||
tab.closePanel(panelId, force: true)
|
||||
let closed = tab.closePanel(panelId, force: true)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.close.shortcut tab=\(tab.id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) closed=\(closed ? 1 : 0) " +
|
||||
"panelsAfterCall=\(tab.panels.count)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func closePanelWithConfirmation(tabId: UUID, surfaceId: UUID) {
|
||||
|
|
@ -1931,19 +1984,81 @@ class TabManager: ObservableObject {
|
|||
return tab.browserPanel(for: panelId)
|
||||
}
|
||||
|
||||
/// Open a browser in a specific workspace, optionally preferring a split-right layout.
|
||||
@discardableResult
|
||||
func openBrowser(
|
||||
inWorkspace tabId: UUID,
|
||||
url: URL? = nil,
|
||||
preferSplitRight: Bool = false,
|
||||
insertAtEnd: Bool = false
|
||||
) -> UUID? {
|
||||
guard let workspace = tabs.first(where: { $0.id == tabId }) else { return nil }
|
||||
if selectedTabId != tabId {
|
||||
selectedTabId = tabId
|
||||
}
|
||||
|
||||
if preferSplitRight {
|
||||
if let targetPaneId = workspace.topRightBrowserReusePane(),
|
||||
let browserPanel = workspace.newBrowserSurface(
|
||||
inPane: targetPaneId,
|
||||
url: url,
|
||||
focus: true,
|
||||
insertAtEnd: insertAtEnd
|
||||
) {
|
||||
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
|
||||
return browserPanel.id
|
||||
}
|
||||
|
||||
let splitSourcePanelId: UUID? = {
|
||||
if let focusedPanelId = workspace.focusedPanelId,
|
||||
workspace.panels[focusedPanelId] != nil {
|
||||
return focusedPanelId
|
||||
}
|
||||
if let rememberedPanelId = lastFocusedPanelByTab[tabId],
|
||||
workspace.panels[rememberedPanelId] != nil {
|
||||
return rememberedPanelId
|
||||
}
|
||||
if let orderedPanelId = workspace.sidebarOrderedPanelIds().first(where: { workspace.panels[$0] != nil }) {
|
||||
return orderedPanelId
|
||||
}
|
||||
return workspace.panels.keys.sorted { $0.uuidString < $1.uuidString }.first
|
||||
}()
|
||||
|
||||
if let splitSourcePanelId,
|
||||
let browserPanel = workspace.newBrowserSplit(
|
||||
from: splitSourcePanelId,
|
||||
orientation: .horizontal,
|
||||
url: url,
|
||||
focus: true
|
||||
) {
|
||||
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
|
||||
return browserPanel.id
|
||||
}
|
||||
}
|
||||
|
||||
guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first,
|
||||
let browserPanel = workspace.newBrowserSurface(
|
||||
inPane: paneId,
|
||||
url: url,
|
||||
focus: true,
|
||||
insertAtEnd: insertAtEnd
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
|
||||
return browserPanel.id
|
||||
}
|
||||
|
||||
/// Open a browser in the currently focused pane (as a new surface)
|
||||
@discardableResult
|
||||
func openBrowser(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? {
|
||||
guard let tabId = selectedTabId,
|
||||
let tab = tabs.first(where: { $0.id == tabId }),
|
||||
let focusedPaneId = tab.bonsplitController.focusedPaneId else { return nil }
|
||||
let panel = tab.newBrowserSurface(
|
||||
inPane: focusedPaneId,
|
||||
guard let tabId = selectedTabId else { return nil }
|
||||
return openBrowser(
|
||||
inWorkspace: tabId,
|
||||
url: url,
|
||||
focus: true,
|
||||
preferSplitRight: false,
|
||||
insertAtEnd: insertAtEnd
|
||||
)
|
||||
return panel?.id
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
|
|
|
|||
|
|
@ -187,10 +187,29 @@ class TerminalController {
|
|||
key: String,
|
||||
value: String,
|
||||
icon: String?,
|
||||
color: String?
|
||||
color: String?,
|
||||
url: URL?,
|
||||
priority: Int,
|
||||
format: SidebarMetadataFormat
|
||||
) -> Bool {
|
||||
guard let current else { return true }
|
||||
return current.key != key || current.value != value || current.icon != icon || current.color != color
|
||||
return current.key != key ||
|
||||
current.value != value ||
|
||||
current.icon != icon ||
|
||||
current.color != color ||
|
||||
current.url != url ||
|
||||
current.priority != priority ||
|
||||
current.format != format
|
||||
}
|
||||
|
||||
nonisolated static func shouldReplaceMetadataBlock(
|
||||
current: SidebarMetadataBlock?,
|
||||
key: String,
|
||||
markdown: String,
|
||||
priority: Int
|
||||
) -> Bool {
|
||||
guard let current else { return true }
|
||||
return current.key != key || current.markdown != markdown || current.priority != priority
|
||||
}
|
||||
|
||||
nonisolated static func shouldReplaceProgress(
|
||||
|
|
@ -211,6 +230,17 @@ class TerminalController {
|
|||
return current.branch != branch || current.isDirty != isDirty
|
||||
}
|
||||
|
||||
nonisolated static func shouldReplacePullRequest(
|
||||
current: SidebarPullRequestState?,
|
||||
number: Int,
|
||||
label: String,
|
||||
url: URL,
|
||||
status: SidebarPullRequestStatus
|
||||
) -> Bool {
|
||||
guard let current else { return true }
|
||||
return current.number != number || current.label != label || current.url != url || current.status != status
|
||||
}
|
||||
|
||||
nonisolated static func shouldReplacePorts(current: [Int]?, next: [Int]) -> Bool {
|
||||
let currentSorted = Array(Set(current ?? [])).sorted()
|
||||
let nextSorted = Array(Set(next)).sorted()
|
||||
|
|
@ -720,12 +750,30 @@ class TerminalController {
|
|||
case "set_status":
|
||||
return setStatus(args)
|
||||
|
||||
case "report_meta":
|
||||
return reportMeta(args)
|
||||
|
||||
case "report_meta_block":
|
||||
return reportMetaBlock(args)
|
||||
|
||||
case "clear_status":
|
||||
return clearStatus(args)
|
||||
|
||||
case "clear_meta":
|
||||
return clearMeta(args)
|
||||
|
||||
case "clear_meta_block":
|
||||
return clearMetaBlock(args)
|
||||
|
||||
case "list_status":
|
||||
return listStatus(args)
|
||||
|
||||
case "list_meta":
|
||||
return listMeta(args)
|
||||
|
||||
case "list_meta_blocks":
|
||||
return listMetaBlocks(args)
|
||||
|
||||
case "log":
|
||||
return appendLog(args)
|
||||
|
||||
|
|
@ -747,6 +795,15 @@ class TerminalController {
|
|||
case "clear_git_branch":
|
||||
return clearGitBranch(args)
|
||||
|
||||
case "report_pr":
|
||||
return reportPullRequest(args)
|
||||
|
||||
case "report_review":
|
||||
return reportPullRequest(args)
|
||||
|
||||
case "clear_pr":
|
||||
return clearPullRequest(args)
|
||||
|
||||
case "report_ports":
|
||||
return reportPorts(args)
|
||||
|
||||
|
|
@ -8432,16 +8489,25 @@ class TerminalController {
|
|||
clear_notifications - Clear all notifications
|
||||
set_app_focus <active|inactive|clear> - Override app focus state
|
||||
simulate_app_active - Trigger app active handler
|
||||
set_status <key> <value> [--icon=X] [--color=#hex] [--tab=X] - Set a status entry
|
||||
set_status <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set a status entry
|
||||
report_meta <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set sidebar metadata entry
|
||||
report_meta_block <key> [--priority=N] [--tab=X] -- <markdown> - Set freeform sidebar markdown block
|
||||
clear_status <key> [--tab=X] - Remove a status entry
|
||||
clear_meta <key> [--tab=X] - Remove sidebar metadata entry
|
||||
clear_meta_block <key> [--tab=X] - Remove sidebar markdown block
|
||||
list_status [--tab=X] - List all status entries
|
||||
list_meta [--tab=X] - List sidebar metadata entries
|
||||
list_meta_blocks [--tab=X] - List sidebar markdown blocks
|
||||
log [--level=X] [--source=X] [--tab=X] -- <message> - Append a log entry
|
||||
clear_log [--tab=X] - Clear log entries
|
||||
list_log [--limit=N] [--tab=X] - List log entries
|
||||
set_progress <0.0-1.0> [--label=X] [--tab=X] - Set progress bar
|
||||
clear_progress [--tab=X] - Clear progress bar
|
||||
report_git_branch <branch> [--status=dirty] [--tab=X] - Report git branch
|
||||
clear_git_branch [--tab=X] - Clear git branch
|
||||
report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y] - Report git branch
|
||||
clear_git_branch [--tab=X] [--panel=Y] - Clear git branch
|
||||
report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Report pull request / review item
|
||||
report_review <number> <url> [--label=MR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Alias for provider-specific review item
|
||||
clear_pr [--tab=X] [--panel=Y] - Clear pull request
|
||||
report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports
|
||||
report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning
|
||||
ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel
|
||||
|
|
@ -11329,21 +11395,104 @@ class TerminalController {
|
|||
return tabManager.tabs.first(where: { $0.id == selectedId })
|
||||
}
|
||||
|
||||
private func setStatus(_ args: String) -> String {
|
||||
private func resolveTabIdForSidebarMutation(
|
||||
reportArgs: String,
|
||||
options: [String: String]
|
||||
) -> (tabId: UUID?, error: String?) {
|
||||
var tabId: UUID?
|
||||
DispatchQueue.main.sync {
|
||||
if let tab = resolveTabForReport(reportArgs) {
|
||||
tabId = tab.id
|
||||
}
|
||||
}
|
||||
if let tabId {
|
||||
return (tabId, nil)
|
||||
}
|
||||
let error = options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
||||
return (nil, error)
|
||||
}
|
||||
|
||||
private func tabForSidebarMutation(id: UUID) -> Tab? {
|
||||
if let tab = tabManager?.tabs.first(where: { $0.id == id }) {
|
||||
return tab
|
||||
}
|
||||
if let otherManager = AppDelegate.shared?.tabManagerFor(tabId: id) {
|
||||
return otherManager.tabs.first(where: { $0.id == id })
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func parseSidebarMetadataFormat(_ raw: String) -> SidebarMetadataFormat? {
|
||||
switch raw.lowercased() {
|
||||
case "plain":
|
||||
return .plain
|
||||
case "markdown", "md":
|
||||
return .markdown
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func normalizedOptionValue(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func upsertSidebarMetadata(_ args: String, missingError: String) -> String {
|
||||
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
||||
let parsed = parseOptionsNoStop(args)
|
||||
guard parsed.positional.count >= 2 else {
|
||||
return "ERROR: Missing status key or value — usage: set_status <key> <value> [--icon=X] [--color=#hex] [--tab=X]"
|
||||
}
|
||||
guard parsed.positional.count >= 2 else { return missingError }
|
||||
|
||||
let key = parsed.positional[0]
|
||||
let value = parsed.positional[1...].joined(separator: " ")
|
||||
let icon = parsed.options["icon"]
|
||||
let color = parsed.options["color"]
|
||||
let icon = normalizedOptionValue(parsed.options["icon"])
|
||||
let color = normalizedOptionValue(parsed.options["color"])
|
||||
|
||||
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"
|
||||
let formatRaw = normalizedOptionValue(parsed.options["format"]) ?? SidebarMetadataFormat.plain.rawValue
|
||||
guard let format = parseSidebarMetadataFormat(formatRaw) else {
|
||||
return "ERROR: Invalid metadata format '\(formatRaw)' — use: plain, markdown"
|
||||
}
|
||||
|
||||
let priority: Int
|
||||
if let rawPriority = normalizedOptionValue(parsed.options["priority"]) {
|
||||
guard let parsedPriority = Int(rawPriority) else {
|
||||
return "ERROR: Invalid metadata priority '\(rawPriority)' — must be an integer"
|
||||
}
|
||||
priority = max(-9999, min(9999, parsedPriority))
|
||||
} else {
|
||||
priority = 0
|
||||
}
|
||||
|
||||
let parsedURL: URL?
|
||||
if let rawURL = normalizedOptionValue(parsed.options["url"] ?? parsed.options["link"]) {
|
||||
guard let candidate = URL(string: rawURL),
|
||||
let scheme = candidate.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https" else {
|
||||
return "ERROR: Invalid metadata URL '\(rawURL)' — expected http(s) URL"
|
||||
}
|
||||
parsedURL = candidate
|
||||
} else {
|
||||
parsedURL = nil
|
||||
}
|
||||
|
||||
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 }
|
||||
guard Self.shouldReplaceStatusEntry(
|
||||
current: tab.statusEntries[key],
|
||||
key: key,
|
||||
value: value,
|
||||
icon: icon,
|
||||
color: color,
|
||||
url: parsedURL,
|
||||
priority: priority,
|
||||
format: format
|
||||
) else {
|
||||
return
|
||||
}
|
||||
tab.statusEntries[key] = SidebarStatusEntry(
|
||||
|
|
@ -11351,15 +11500,19 @@ class TerminalController {
|
|||
value: value,
|
||||
icon: icon,
|
||||
color: color,
|
||||
timestamp: Date())
|
||||
url: parsedURL,
|
||||
priority: priority,
|
||||
format: format,
|
||||
timestamp: Date()
|
||||
)
|
||||
}
|
||||
return result
|
||||
return "OK"
|
||||
}
|
||||
|
||||
private func clearStatus(_ args: String) -> String {
|
||||
private func clearSidebarMetadata(_ args: String, usage: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
guard let key = parsed.positional.first, parsed.positional.count == 1 else {
|
||||
return "ERROR: Missing status key — usage: clear_status <key> [--tab=X]"
|
||||
return "ERROR: Missing metadata key — usage: \(usage)"
|
||||
}
|
||||
|
||||
var result = "OK"
|
||||
|
|
@ -11375,24 +11528,173 @@ class TerminalController {
|
|||
return result
|
||||
}
|
||||
|
||||
private func listStatus(_ args: String) -> String {
|
||||
private func sidebarMetadataLine(_ entry: SidebarStatusEntry) -> String {
|
||||
var line = "\(entry.key)=\(entry.value)"
|
||||
if let icon = entry.icon { line += " icon=\(icon)" }
|
||||
if let color = entry.color { line += " color=\(color)" }
|
||||
if let url = entry.url { line += " url=\(url.absoluteString)" }
|
||||
if entry.priority != 0 { line += " priority=\(entry.priority)" }
|
||||
if entry.format != .plain { line += " format=\(entry.format.rawValue)" }
|
||||
return line
|
||||
}
|
||||
|
||||
private func listSidebarMetadata(_ args: String, emptyMessage: String) -> String {
|
||||
var result = ""
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
result = "ERROR: Tab not found"
|
||||
return
|
||||
}
|
||||
if tab.statusEntries.isEmpty {
|
||||
result = "No status entries"
|
||||
let entries = tab.sidebarStatusEntriesInDisplayOrder()
|
||||
if entries.isEmpty {
|
||||
result = emptyMessage
|
||||
return
|
||||
}
|
||||
let lines = tab.statusEntries.values.sorted(by: { $0.key < $1.key }).map { entry in
|
||||
var line = "\(entry.key)=\(entry.value)"
|
||||
if let icon = entry.icon { line += " icon=\(icon)" }
|
||||
if let color = entry.color { line += " color=\(color)" }
|
||||
return line
|
||||
result = entries.map(sidebarMetadataLine).joined(separator: "\n")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func setStatus(_ args: String) -> String {
|
||||
upsertSidebarMetadata(
|
||||
args,
|
||||
missingError: "ERROR: Missing status key or value — usage: set_status <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X]"
|
||||
)
|
||||
}
|
||||
|
||||
private func reportMeta(_ args: String) -> String {
|
||||
upsertSidebarMetadata(
|
||||
args,
|
||||
missingError: "ERROR: Missing metadata key or value — usage: report_meta <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X]"
|
||||
)
|
||||
}
|
||||
|
||||
private func clearStatus(_ args: String) -> String {
|
||||
clearSidebarMetadata(args, usage: "clear_status <key> [--tab=X]")
|
||||
}
|
||||
|
||||
private func clearMeta(_ args: String) -> String {
|
||||
clearSidebarMetadata(args, usage: "clear_meta <key> [--tab=X]")
|
||||
}
|
||||
|
||||
private func listStatus(_ args: String) -> String {
|
||||
listSidebarMetadata(args, emptyMessage: "No status entries")
|
||||
}
|
||||
|
||||
private func listMeta(_ args: String) -> String {
|
||||
listSidebarMetadata(args, emptyMessage: "No metadata entries")
|
||||
}
|
||||
|
||||
private func splitMetadataBlockArgs(_ args: String) -> (optionsPart: String, markdownPart: String?) {
|
||||
guard let separatorRange = args.range(of: " -- ") else {
|
||||
return (args, nil)
|
||||
}
|
||||
let optionsPart = String(args[..<separatorRange.lowerBound])
|
||||
let markdownPart = String(args[separatorRange.upperBound...])
|
||||
return (optionsPart, markdownPart)
|
||||
}
|
||||
|
||||
private func sidebarMetadataBlockLine(_ block: SidebarMetadataBlock) -> String {
|
||||
var line = "\(block.key)=\(block.markdown.replacingOccurrences(of: "\n", with: "\\n"))"
|
||||
if block.priority != 0 { line += " priority=\(block.priority)" }
|
||||
return line
|
||||
}
|
||||
|
||||
private func reportMetaBlock(_ args: String) -> String {
|
||||
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
||||
|
||||
let parts = splitMetadataBlockArgs(args)
|
||||
let parsed = parseOptionsNoStop(parts.optionsPart)
|
||||
guard let key = parsed.positional.first, !key.isEmpty else {
|
||||
return "ERROR: Missing metadata block key — usage: report_meta_block <key> [--priority=N] [--tab=X] -- <markdown>"
|
||||
}
|
||||
|
||||
let markdown: String
|
||||
if let raw = parts.markdownPart {
|
||||
markdown = raw
|
||||
} else if parsed.positional.count >= 2 {
|
||||
markdown = parsed.positional.dropFirst().joined(separator: " ")
|
||||
} else {
|
||||
return "ERROR: Missing metadata markdown — usage: report_meta_block <key> [--priority=N] [--tab=X] -- <markdown>"
|
||||
}
|
||||
|
||||
let normalizedMarkdown = markdown
|
||||
.replacingOccurrences(of: "\\r\\n", with: "\n")
|
||||
.replacingOccurrences(of: "\\n", with: "\n")
|
||||
.replacingOccurrences(of: "\\t", with: "\t")
|
||||
|
||||
let trimmedMarkdown = normalizedMarkdown.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedMarkdown.isEmpty else {
|
||||
return "ERROR: Missing metadata markdown — usage: report_meta_block <key> [--priority=N] [--tab=X] -- <markdown>"
|
||||
}
|
||||
|
||||
let priority: Int
|
||||
if let rawPriority = normalizedOptionValue(parsed.options["priority"]) {
|
||||
guard let parsedPriority = Int(rawPriority) else {
|
||||
return "ERROR: Invalid metadata block priority '\(rawPriority)' — must be an integer"
|
||||
}
|
||||
result = lines.joined(separator: "\n")
|
||||
priority = max(-9999, min(9999, parsedPriority))
|
||||
} else {
|
||||
priority = 0
|
||||
}
|
||||
|
||||
let tabResolution = resolveTabIdForSidebarMutation(reportArgs: parts.optionsPart, 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 }
|
||||
guard Self.shouldReplaceMetadataBlock(
|
||||
current: tab.metadataBlocks[key],
|
||||
key: key,
|
||||
markdown: normalizedMarkdown,
|
||||
priority: priority
|
||||
) else {
|
||||
return
|
||||
}
|
||||
tab.metadataBlocks[key] = SidebarMetadataBlock(
|
||||
key: key,
|
||||
markdown: normalizedMarkdown,
|
||||
priority: priority,
|
||||
timestamp: Date()
|
||||
)
|
||||
}
|
||||
return "OK"
|
||||
}
|
||||
|
||||
private func clearMetaBlock(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
guard let key = parsed.positional.first, parsed.positional.count == 1 else {
|
||||
return "ERROR: Missing metadata block key — usage: clear_meta_block <key> [--tab=X]"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if tab.metadataBlocks.removeValue(forKey: key) == nil {
|
||||
result = "OK (key not found)"
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func listMetaBlocks(_ args: String) -> String {
|
||||
var result = ""
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
result = "ERROR: Tab not found"
|
||||
return
|
||||
}
|
||||
let blocks = tab.sidebarMetadataBlocksInDisplayOrder()
|
||||
if blocks.isEmpty {
|
||||
result = "No metadata blocks"
|
||||
return
|
||||
}
|
||||
result = blocks.map(sidebarMetadataBlockLine).joined(separator: "\n")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -11541,6 +11843,132 @@ class TerminalController {
|
|||
return result
|
||||
}
|
||||
|
||||
private func reportPullRequest(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
guard parsed.positional.count >= 2 else {
|
||||
return "ERROR: Missing pull request number or URL — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]"
|
||||
}
|
||||
|
||||
let rawNumber = parsed.positional[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let numberToken = rawNumber.hasPrefix("#") ? String(rawNumber.dropFirst()) : rawNumber
|
||||
guard let number = Int(numberToken), number > 0 else {
|
||||
return "ERROR: Invalid pull request number '\(rawNumber)'"
|
||||
}
|
||||
|
||||
let rawURL = parsed.positional[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let url = URL(string: rawURL),
|
||||
let scheme = url.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https" else {
|
||||
return "ERROR: Invalid pull request URL '\(rawURL)'"
|
||||
}
|
||||
|
||||
let statusRaw = (parsed.options["state"] ?? "open").lowercased()
|
||||
guard let status = SidebarPullRequestStatus(rawValue: statusRaw) else {
|
||||
return "ERROR: Invalid pull request state '\(statusRaw)' — use: open, merged, closed"
|
||||
}
|
||||
|
||||
let labelRaw = normalizedOptionValue(parsed.options["label"]) ?? "PR"
|
||||
guard !labelRaw.isEmpty else {
|
||||
return "ERROR: Invalid review label — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]"
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
guard Self.shouldReplacePullRequest(
|
||||
current: tab.panelPullRequests[surfaceId],
|
||||
number: number,
|
||||
label: label,
|
||||
url: url,
|
||||
status: status
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
tab.updatePanelPullRequest(
|
||||
panelId: surfaceId,
|
||||
number: number,
|
||||
label: label,
|
||||
url: url,
|
||||
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
|
||||
}
|
||||
|
||||
tab.clearPanelPullRequest(panelId: surfaceId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func reportPorts(_ args: String) -> String {
|
||||
let parsed = parseOptions(args)
|
||||
guard !parsed.positional.isEmpty else {
|
||||
|
|
@ -11785,6 +12213,14 @@ class TerminalController {
|
|||
lines.append("git_branch=none")
|
||||
}
|
||||
|
||||
if let pr = tab.pullRequest {
|
||||
lines.append("pr=#\(pr.number) \(pr.status.rawValue) \(pr.url.absoluteString)")
|
||||
lines.append("pr_label=\(pr.label)")
|
||||
} else {
|
||||
lines.append("pr=none")
|
||||
lines.append("pr_label=none")
|
||||
}
|
||||
|
||||
if tab.listeningPorts.isEmpty {
|
||||
lines.append("ports=none")
|
||||
} else {
|
||||
|
|
@ -11798,12 +12234,16 @@ class TerminalController {
|
|||
lines.append("progress=none")
|
||||
}
|
||||
|
||||
lines.append("status_count=\(tab.statusEntries.count)")
|
||||
for entry in tab.statusEntries.values.sorted(by: { $0.key < $1.key }) {
|
||||
var line = " \(entry.key)=\(entry.value)"
|
||||
if let icon = entry.icon { line += " icon=\(icon)" }
|
||||
if let color = entry.color { line += " color=\(color)" }
|
||||
lines.append(line)
|
||||
let statusEntries = tab.sidebarStatusEntriesInDisplayOrder()
|
||||
lines.append("status_count=\(statusEntries.count)")
|
||||
for entry in statusEntries {
|
||||
lines.append(" \(sidebarMetadataLine(entry))")
|
||||
}
|
||||
|
||||
let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder()
|
||||
lines.append("meta_block_count=\(metadataBlocks.count)")
|
||||
for block in metadataBlocks {
|
||||
lines.append(" \(sidebarMetadataBlockLine(block))")
|
||||
}
|
||||
|
||||
lines.append("log_count=\(tab.logEntries.count)")
|
||||
|
|
@ -11827,8 +12267,12 @@ class TerminalController {
|
|||
tab.logEntries.removeAll()
|
||||
tab.progress = nil
|
||||
tab.gitBranch = nil
|
||||
tab.panelGitBranches.removeAll()
|
||||
tab.pullRequest = nil
|
||||
tab.panelPullRequests.removeAll()
|
||||
tab.surfaceListeningPorts.removeAll()
|
||||
tab.listeningPorts.removeAll()
|
||||
tab.metadataBlocks.removeAll()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1463,6 +1463,14 @@ enum TerminalWindowPortalRegistry {
|
|||
portal.hideEntry(forHostedId: hostedId)
|
||||
}
|
||||
|
||||
/// Permanently detach a hosted terminal view from the window-level portal.
|
||||
/// Use this when a terminal panel is actually closing (not transient SwiftUI dismantle).
|
||||
static func detach(hostedView: GhosttySurfaceScrollView) {
|
||||
let hostedId = ObjectIdentifier(hostedView)
|
||||
guard let windowId = hostedToWindowId.removeValue(forKey: hostedId) else { return }
|
||||
portalsByWindowId[windowId]?.detachHostedView(withId: hostedId)
|
||||
}
|
||||
|
||||
/// Update the visibleInUI flag on an existing portal entry without rebinding.
|
||||
/// Called when a bind is deferred (host not yet in window) to prevent stale
|
||||
/// portal syncs from hiding a view that is about to become visible.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,42 @@ struct SidebarStatusEntry {
|
|||
let value: String
|
||||
let icon: String?
|
||||
let color: String?
|
||||
let url: URL?
|
||||
let priority: Int
|
||||
let format: SidebarMetadataFormat
|
||||
let timestamp: Date
|
||||
|
||||
init(
|
||||
key: String,
|
||||
value: String,
|
||||
icon: String? = nil,
|
||||
color: String? = nil,
|
||||
url: URL? = nil,
|
||||
priority: Int = 0,
|
||||
format: SidebarMetadataFormat = .plain,
|
||||
timestamp: Date = Date()
|
||||
) {
|
||||
self.key = key
|
||||
self.value = value
|
||||
self.icon = icon
|
||||
self.color = color
|
||||
self.url = url
|
||||
self.priority = priority
|
||||
self.format = format
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
struct SidebarMetadataBlock {
|
||||
let key: String
|
||||
let markdown: String
|
||||
let priority: Int
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
enum SidebarMetadataFormat: String {
|
||||
case plain
|
||||
case markdown
|
||||
}
|
||||
|
||||
private struct SessionPaneRestoreEntry {
|
||||
|
|
@ -1251,6 +1286,19 @@ struct SidebarGitBranchState {
|
|||
let isDirty: Bool
|
||||
}
|
||||
|
||||
enum SidebarPullRequestStatus: String {
|
||||
case open
|
||||
case merged
|
||||
case closed
|
||||
}
|
||||
|
||||
struct SidebarPullRequestState: Equatable {
|
||||
let number: Int
|
||||
let label: String
|
||||
let url: URL
|
||||
let status: SidebarPullRequestStatus
|
||||
}
|
||||
|
||||
enum SidebarBranchOrdering {
|
||||
struct BranchEntry: Equatable {
|
||||
let name: String
|
||||
|
|
@ -1330,6 +1378,63 @@ enum SidebarBranchOrdering {
|
|||
}
|
||||
}
|
||||
|
||||
static func orderedUniquePullRequests(
|
||||
orderedPanelIds: [UUID],
|
||||
panelPullRequests: [UUID: SidebarPullRequestState],
|
||||
fallbackPullRequest: SidebarPullRequestState?
|
||||
) -> [SidebarPullRequestState] {
|
||||
func statusPriority(_ status: SidebarPullRequestStatus) -> Int {
|
||||
switch status {
|
||||
case .merged: return 3
|
||||
case .open: return 2
|
||||
case .closed: return 1
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedReviewURLKey(for url: URL) -> String {
|
||||
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return url.absoluteString
|
||||
}
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
let scheme = components.scheme?.lowercased() ?? ""
|
||||
let host = components.host?.lowercased() ?? ""
|
||||
let port = components.port.map { ":\($0)" } ?? ""
|
||||
var path = components.path
|
||||
if path.hasSuffix("/"), path.count > 1 {
|
||||
path.removeLast()
|
||||
}
|
||||
return "\(scheme)://\(host)\(port)\(path)"
|
||||
}
|
||||
|
||||
func reviewKey(for state: SidebarPullRequestState) -> String {
|
||||
"\(state.label.lowercased())#\(state.number)|\(normalizedReviewURLKey(for: state.url))"
|
||||
}
|
||||
|
||||
var orderedKeys: [String] = []
|
||||
var pullRequestsByKey: [String: SidebarPullRequestState] = [:]
|
||||
|
||||
for panelId in orderedPanelIds {
|
||||
guard let state = panelPullRequests[panelId] else { continue }
|
||||
let key = reviewKey(for: state)
|
||||
if pullRequestsByKey[key] == nil {
|
||||
orderedKeys.append(key)
|
||||
pullRequestsByKey[key] = state
|
||||
continue
|
||||
}
|
||||
guard let existing = pullRequestsByKey[key] else { continue }
|
||||
if statusPriority(state.status) > statusPriority(existing.status) {
|
||||
pullRequestsByKey[key] = state
|
||||
}
|
||||
}
|
||||
|
||||
if orderedKeys.isEmpty, let fallbackPullRequest {
|
||||
return [fallbackPullRequest]
|
||||
}
|
||||
|
||||
return orderedKeys.compactMap { pullRequestsByKey[$0] }
|
||||
}
|
||||
|
||||
static func orderedUniqueBranchDirectoryEntries(
|
||||
orderedPanelIds: [UUID],
|
||||
panelBranches: [UUID: SidebarGitBranchState],
|
||||
|
|
@ -2023,11 +2128,14 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
@Published private(set) var panelCustomTitles: [UUID: String] = [:]
|
||||
@Published private(set) var pinnedPanelIds: Set<UUID> = []
|
||||
@Published private(set) var manualUnreadPanelIds: Set<UUID> = []
|
||||
@Published private(set) var panelGitBranches: [UUID: SidebarGitBranchState] = [:]
|
||||
@Published var panelGitBranches: [UUID: SidebarGitBranchState] = [:]
|
||||
@Published var statusEntries: [String: SidebarStatusEntry] = [:]
|
||||
@Published var metadataBlocks: [String: SidebarMetadataBlock] = [:]
|
||||
@Published var logEntries: [SidebarLogEntry] = []
|
||||
@Published var progress: SidebarProgressState?
|
||||
@Published var gitBranch: SidebarGitBranchState?
|
||||
@Published var pullRequest: SidebarPullRequestState?
|
||||
@Published var panelPullRequests: [UUID: SidebarPullRequestState] = [:]
|
||||
@Published var surfaceListeningPorts: [UUID: [Int]] = [:]
|
||||
@Published var remoteConfiguration: WorkspaceRemoteConfiguration?
|
||||
@Published var remoteConnectionState: WorkspaceRemoteConnectionState = .disconnected
|
||||
|
|
@ -2459,6 +2567,12 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
syncUnreadBadgeStateForPanel(panelId)
|
||||
}
|
||||
|
||||
func markPanelRead(_ panelId: UUID) {
|
||||
guard panels[panelId] != nil else { return }
|
||||
AppDelegate.shared?.notificationStore?.markRead(forTabId: id, surfaceId: panelId)
|
||||
clearManualUnread(panelId: panelId)
|
||||
}
|
||||
|
||||
func clearManualUnread(panelId: UUID) {
|
||||
guard manualUnreadPanelIds.remove(panelId) != nil else { return }
|
||||
syncUnreadBadgeStateForPanel(panelId)
|
||||
|
|
@ -2528,6 +2642,30 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func updatePanelPullRequest(
|
||||
panelId: UUID,
|
||||
number: Int,
|
||||
label: String,
|
||||
url: URL,
|
||||
status: SidebarPullRequestStatus
|
||||
) {
|
||||
let state = SidebarPullRequestState(number: number, label: label, url: url, status: status)
|
||||
let existing = panelPullRequests[panelId]
|
||||
if existing != state {
|
||||
panelPullRequests[panelId] = state
|
||||
}
|
||||
if panelId == focusedPanelId {
|
||||
pullRequest = state
|
||||
}
|
||||
}
|
||||
|
||||
func clearPanelPullRequest(panelId: UUID) {
|
||||
panelPullRequests.removeValue(forKey: panelId)
|
||||
if panelId == focusedPanelId {
|
||||
pullRequest = nil
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updatePanelTitle(panelId: UUID, title: String) -> Bool {
|
||||
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
|
@ -2575,6 +2713,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
panelGitBranches = panelGitBranches.filter { validSurfaceIds.contains($0.key) }
|
||||
surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) }
|
||||
surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) }
|
||||
panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) }
|
||||
recomputeListeningPorts()
|
||||
}
|
||||
|
||||
|
|
@ -2626,6 +2765,32 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
remoteConfiguration != nil
|
||||
}
|
||||
|
||||
func sidebarPullRequestsInDisplayOrder() -> [SidebarPullRequestState] {
|
||||
SidebarBranchOrdering.orderedUniquePullRequests(
|
||||
orderedPanelIds: sidebarOrderedPanelIds(),
|
||||
panelPullRequests: panelPullRequests,
|
||||
fallbackPullRequest: pullRequest
|
||||
)
|
||||
}
|
||||
|
||||
func sidebarStatusEntriesInDisplayOrder() -> [SidebarStatusEntry] {
|
||||
statusEntries.values.sorted { lhs, rhs in
|
||||
if lhs.priority != rhs.priority { return lhs.priority > rhs.priority }
|
||||
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
|
||||
return lhs.key < rhs.key
|
||||
}
|
||||
}
|
||||
|
||||
func sidebarMetadataBlocksInDisplayOrder() -> [SidebarMetadataBlock] {
|
||||
metadataBlocks.values.sorted { lhs, rhs in
|
||||
if lhs.priority != rhs.priority { return lhs.priority > rhs.priority }
|
||||
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
|
||||
return lhs.key < rhs.key
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Panel Operations
|
||||
|
||||
var remoteDisplayTarget: String? {
|
||||
remoteConfiguration?.displayTarget
|
||||
}
|
||||
|
|
@ -3163,17 +3328,38 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
|
||||
// Mapping can transiently drift during split-tree mutations. If the target panel is
|
||||
// currently focused, close whichever tab bonsplit marks selected in that focused pane.
|
||||
guard focusedPanelId == panelId,
|
||||
// currently focused (or is the active terminal first responder), close whichever tab
|
||||
// bonsplit marks selected in that focused pane.
|
||||
let firstResponderPanelId = cmuxOwningGhosttyView(
|
||||
for: NSApp.keyWindow?.firstResponder ?? NSApp.mainWindow?.firstResponder
|
||||
)?.terminalSurface?.id
|
||||
let targetIsActive = focusedPanelId == panelId || firstResponderPanelId == panelId
|
||||
guard targetIsActive,
|
||||
let focusedPane = bonsplitController.focusedPaneId,
|
||||
let selected = bonsplitController.selectedTab(inPane: focusedPane) else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.close.fallback.skip panel=\(panelId.uuidString.prefix(5)) " +
|
||||
"focusedPanel=\(focusedPanelId?.uuidString.prefix(5) ?? "nil") " +
|
||||
"firstResponderPanel=\(firstResponderPanelId?.uuidString.prefix(5) ?? "nil") " +
|
||||
"focusedPane=\(bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil")"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
if force {
|
||||
forceCloseTabIds.insert(selected.id)
|
||||
}
|
||||
return bonsplitController.closeTab(selected.id)
|
||||
let closed = bonsplitController.closeTab(selected.id)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.close.fallback panel=\(panelId.uuidString.prefix(5)) " +
|
||||
"selectedTab=\(String(describing: selected.id).prefix(5)) " +
|
||||
"closed=\(closed ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
return closed
|
||||
}
|
||||
|
||||
func paneId(forPanelId panelId: UUID) -> PaneID? {
|
||||
|
|
@ -3236,6 +3422,49 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
return nil
|
||||
}
|
||||
|
||||
/// Returns the top-right pane in the current split tree.
|
||||
/// When a workspace is already split, sidebar PR opens should reuse an existing pane
|
||||
/// instead of creating additional right splits.
|
||||
func topRightBrowserReusePane() -> PaneID? {
|
||||
let paneIds = bonsplitController.allPaneIds
|
||||
guard paneIds.count > 1 else { return nil }
|
||||
|
||||
let paneById = Dictionary(uniqueKeysWithValues: paneIds.map { ($0.id.uuidString, $0) })
|
||||
var paneBounds: [String: CGRect] = [:]
|
||||
browserCollectNormalizedPaneBounds(
|
||||
node: bonsplitController.treeSnapshot(),
|
||||
availableRect: CGRect(x: 0, y: 0, width: 1, height: 1),
|
||||
into: &paneBounds
|
||||
)
|
||||
|
||||
guard !paneBounds.isEmpty else {
|
||||
return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first
|
||||
}
|
||||
|
||||
let epsilon = 0.000_1
|
||||
let rightMostX = paneBounds.values.map(\.maxX).max() ?? 0
|
||||
|
||||
let sortedCandidates = paneBounds
|
||||
.filter { _, rect in abs(rect.maxX - rightMostX) <= epsilon }
|
||||
.sorted { lhs, rhs in
|
||||
if abs(lhs.value.minY - rhs.value.minY) > epsilon {
|
||||
return lhs.value.minY < rhs.value.minY
|
||||
}
|
||||
if abs(lhs.value.minX - rhs.value.minX) > epsilon {
|
||||
return lhs.value.minX > rhs.value.minX
|
||||
}
|
||||
return lhs.key < rhs.key
|
||||
}
|
||||
|
||||
for candidate in sortedCandidates {
|
||||
if let pane = paneById[candidate.key] {
|
||||
return pane
|
||||
}
|
||||
}
|
||||
|
||||
return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first
|
||||
}
|
||||
|
||||
private enum BrowserPaneBranch {
|
||||
case first
|
||||
case second
|
||||
|
|
@ -3273,6 +3502,54 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
private func browserCollectNormalizedPaneBounds(
|
||||
node: ExternalTreeNode,
|
||||
availableRect: CGRect,
|
||||
into output: inout [String: CGRect]
|
||||
) {
|
||||
switch node {
|
||||
case .pane(let paneNode):
|
||||
output[paneNode.id] = availableRect
|
||||
case .split(let splitNode):
|
||||
let divider = min(max(splitNode.dividerPosition, 0), 1)
|
||||
let firstRect: CGRect
|
||||
let secondRect: CGRect
|
||||
|
||||
if splitNode.orientation.lowercased() == "vertical" {
|
||||
// Stacked split: first = top, second = bottom
|
||||
firstRect = CGRect(
|
||||
x: availableRect.minX,
|
||||
y: availableRect.minY,
|
||||
width: availableRect.width,
|
||||
height: availableRect.height * divider
|
||||
)
|
||||
secondRect = CGRect(
|
||||
x: availableRect.minX,
|
||||
y: availableRect.minY + (availableRect.height * divider),
|
||||
width: availableRect.width,
|
||||
height: availableRect.height * (1 - divider)
|
||||
)
|
||||
} else {
|
||||
// Side-by-side split: first = left, second = right
|
||||
firstRect = CGRect(
|
||||
x: availableRect.minX,
|
||||
y: availableRect.minY,
|
||||
width: availableRect.width * divider,
|
||||
height: availableRect.height
|
||||
)
|
||||
secondRect = CGRect(
|
||||
x: availableRect.minX + (availableRect.width * divider),
|
||||
y: availableRect.minY,
|
||||
width: availableRect.width * (1 - divider),
|
||||
height: availableRect.height
|
||||
)
|
||||
}
|
||||
|
||||
browserCollectNormalizedPaneBounds(node: splitNode.first, availableRect: firstRect, into: &output)
|
||||
browserCollectNormalizedPaneBounds(node: splitNode.second, availableRect: secondRect, into: &output)
|
||||
}
|
||||
}
|
||||
|
||||
private struct BrowserCloseFallbackPlan {
|
||||
let orientation: SplitOrientation
|
||||
let insertFirst: Bool
|
||||
|
|
@ -3894,6 +4171,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
currentDirectory = dir
|
||||
}
|
||||
gitBranch = panelGitBranches[targetPanelId]
|
||||
pullRequest = panelPullRequests[targetPanelId]
|
||||
}
|
||||
|
||||
/// Reconcile focus/first-responder convergence.
|
||||
|
|
@ -4117,8 +4395,10 @@ extension Workspace: BonsplitDelegate {
|
|||
private func refreshFocusedGitBranchState() {
|
||||
if let focusedPanelId {
|
||||
gitBranch = panelGitBranches[focusedPanelId]
|
||||
pullRequest = panelPullRequests[focusedPanelId]
|
||||
} else {
|
||||
gitBranch = nil
|
||||
pullRequest = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4249,6 +4529,7 @@ extension Workspace: BonsplitDelegate {
|
|||
panels.removeValue(forKey: panelId)
|
||||
surfaceIdToPanelId.removeValue(forKey: tabId)
|
||||
panelDirectories.removeValue(forKey: panelId)
|
||||
panelPullRequests.removeValue(forKey: panelId)
|
||||
panelTitles.removeValue(forKey: panelId)
|
||||
panelCustomTitles.removeValue(forKey: panelId)
|
||||
pinnedPanelIds.remove(panelId)
|
||||
|
|
@ -4350,10 +4631,16 @@ extension Workspace: BonsplitDelegate {
|
|||
pinnedPanelIds.remove(stalePanelId)
|
||||
manualUnreadPanelIds.remove(stalePanelId)
|
||||
panelGitBranches.removeValue(forKey: stalePanelId)
|
||||
panelPullRequests.removeValue(forKey: stalePanelId)
|
||||
panelSubscriptions.removeValue(forKey: stalePanelId)
|
||||
surfaceTTYNames.removeValue(forKey: stalePanelId)
|
||||
surfaceListeningPorts.removeValue(forKey: stalePanelId)
|
||||
restoredTerminalScrollbackByPanelId.removeValue(forKey: stalePanelId)
|
||||
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: stalePanelId)
|
||||
}
|
||||
if !staleMappings.isEmpty {
|
||||
recomputeListeningPorts()
|
||||
}
|
||||
|
||||
refreshFocusedGitBranchState()
|
||||
scheduleTerminalGeometryReconcile()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import AppKit
|
||||
import SwiftUI
|
||||
import Darwin
|
||||
import Bonsplit
|
||||
|
||||
@main
|
||||
struct cmuxApp: App {
|
||||
|
|
@ -35,6 +36,10 @@ struct cmuxApp: App {
|
|||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
init() {
|
||||
if SocketControlSettings.shouldBlockUntaggedDebugLaunch() {
|
||||
Self.terminateForMissingLaunchTag()
|
||||
}
|
||||
|
||||
Self.configureGhosttyEnvironment()
|
||||
|
||||
let startupAppearance = AppearanceSettings.resolvedMode()
|
||||
|
|
@ -58,6 +63,14 @@ struct cmuxApp: App {
|
|||
appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState)
|
||||
}
|
||||
|
||||
private static func terminateForMissingLaunchTag() -> Never {
|
||||
let message = "error: refusing to launch untagged cmux DEV; start with ./scripts/reload.sh --tag <name> (or set CMUX_TAG for test harnesses)"
|
||||
fputs("\(message)\n", stderr)
|
||||
fflush(stderr)
|
||||
NSLog("%@", message)
|
||||
Darwin.exit(64)
|
||||
}
|
||||
|
||||
private static func configureGhosttyEnvironment() {
|
||||
let fileManager = FileManager.default
|
||||
let ghosttyAppResources = "/Applications/Ghostty.app/Contents/Resources/ghostty"
|
||||
|
|
@ -183,7 +196,7 @@ struct cmuxApp: App {
|
|||
applyAppearance()
|
||||
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_SHOW_SETTINGS"] == "1" {
|
||||
DispatchQueue.main.async {
|
||||
showSettingsPanel()
|
||||
appDelegate.openPreferencesWindow(debugSource: "uiTestShowSettings")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -198,7 +211,7 @@ struct cmuxApp: App {
|
|||
.commands {
|
||||
CommandGroup(replacing: .appSettings) {
|
||||
Button("Settings…") {
|
||||
showSettingsPanel()
|
||||
appDelegate.openPreferencesWindow(debugSource: "menu.cmdComma")
|
||||
}
|
||||
.keyboardShortcut(",", modifiers: .command)
|
||||
}
|
||||
|
|
@ -571,11 +584,6 @@ struct cmuxApp: App {
|
|||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
private func showSettingsPanel() {
|
||||
SettingsWindowController.shared.show()
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
private func applyAppearance() {
|
||||
let mode = AppearanceSettings.mode(for: appearanceMode)
|
||||
if appearanceMode != mode.rawValue {
|
||||
|
|
@ -1689,7 +1697,7 @@ private struct AcknowledgmentsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
||||
static let shared = SettingsWindowController()
|
||||
|
||||
private init() {
|
||||
|
|
@ -1716,11 +1724,17 @@ private final class SettingsWindowController: NSWindowController, NSWindowDelega
|
|||
|
||||
func show() {
|
||||
guard let window else { return }
|
||||
#if DEBUG
|
||||
dlog("settings.window.show requested isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)")
|
||||
#endif
|
||||
SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .settings)
|
||||
if !window.isVisible {
|
||||
window.center()
|
||||
}
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
#if DEBUG
|
||||
dlog("settings.window.show completed isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2617,6 +2631,14 @@ struct SettingsView: View {
|
|||
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
||||
@AppStorage(SidebarActiveTabIndicatorSettings.styleKey)
|
||||
private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
|
||||
@AppStorage("sidebarShowBranchDirectory") private var sidebarShowBranchDirectory = true
|
||||
@AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true
|
||||
@AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
|
||||
private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
|
||||
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
|
||||
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
|
||||
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
|
||||
@AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true
|
||||
@State private var shortcutResetToken = UUID()
|
||||
@State private var topBlurOpacity: Double = 0
|
||||
@State private var topBlurBaselineOffset: CGFloat?
|
||||
|
|
@ -2835,6 +2857,84 @@ struct SettingsView: View {
|
|||
.pickerStyle(.menu)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
"Show Branch + Directory in Sidebar",
|
||||
subtitle: "Display the built-in git branch and working-directory row."
|
||||
) {
|
||||
Toggle("", isOn: $sidebarShowBranchDirectory)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
"Show Pull Requests in Sidebar",
|
||||
subtitle: "Display review items (PR/MR/etc.) with status, number, and clickable link."
|
||||
) {
|
||||
Toggle("", isOn: $sidebarShowPullRequest)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
"Open Sidebar PR Links in cmux Browser",
|
||||
subtitle: openSidebarPullRequestLinksInCmuxBrowser
|
||||
? "Clicks open inside cmux browser."
|
||||
: "Clicks open in your default browser."
|
||||
) {
|
||||
Toggle("", isOn: $openSidebarPullRequestLinksInCmuxBrowser)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
"Show Listening Ports in Sidebar",
|
||||
subtitle: "Display detected listening ports for the active workspace."
|
||||
) {
|
||||
Toggle("", isOn: $sidebarShowPorts)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
"Show Latest Log in Sidebar",
|
||||
subtitle: "Display the latest imperative log/status message."
|
||||
) {
|
||||
Toggle("", isOn: $sidebarShowLog)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
"Show Progress in Sidebar",
|
||||
subtitle: "Display the built-in progress bar from set_progress."
|
||||
) {
|
||||
Toggle("", isOn: $sidebarShowProgress)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
"Show Custom Metadata in Sidebar",
|
||||
subtitle: "Display custom metadata from report_meta/set_status and report_meta_block."
|
||||
) {
|
||||
Toggle("", isOn: $sidebarShowMetadata)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSectionHeader(title: "Workspace Colors")
|
||||
|
|
@ -3375,6 +3475,13 @@ struct SettingsView: View {
|
|||
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
||||
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
||||
sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue
|
||||
sidebarShowBranchDirectory = true
|
||||
sidebarShowPullRequest = true
|
||||
openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
|
||||
sidebarShowPorts = true
|
||||
sidebarShowLog = true
|
||||
sidebarShowProgress = true
|
||||
sidebarShowMetadata = true
|
||||
showOpenAccessConfirmation = false
|
||||
pendingOpenAccessMode = nil
|
||||
socketPasswordDraft = ""
|
||||
|
|
|
|||
|
|
@ -417,6 +417,49 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
|
|||
XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager")
|
||||
}
|
||||
|
||||
func testPresentPreferencesWindowShowsCustomSettingsWindowAndActivates() {
|
||||
var showFallbackSettingsWindowCallCount = 0
|
||||
var activateApplicationCallCount = 0
|
||||
|
||||
AppDelegate.presentPreferencesWindow(
|
||||
showFallbackSettingsWindow: {
|
||||
showFallbackSettingsWindowCallCount += 1
|
||||
},
|
||||
activateApplication: {
|
||||
activateApplicationCallCount += 1
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertEqual(showFallbackSettingsWindowCallCount, 1)
|
||||
XCTAssertEqual(activateApplicationCallCount, 1)
|
||||
}
|
||||
|
||||
func testPresentPreferencesWindowSupportsRepeatedCalls() {
|
||||
var showFallbackSettingsWindowCallCount = 0
|
||||
var activateApplicationCallCount = 0
|
||||
|
||||
AppDelegate.presentPreferencesWindow(
|
||||
showFallbackSettingsWindow: {
|
||||
showFallbackSettingsWindowCallCount += 1
|
||||
},
|
||||
activateApplication: {
|
||||
activateApplicationCallCount += 1
|
||||
}
|
||||
)
|
||||
|
||||
AppDelegate.presentPreferencesWindow(
|
||||
showFallbackSettingsWindow: {
|
||||
showFallbackSettingsWindowCallCount += 1
|
||||
},
|
||||
activateApplication: {
|
||||
activateApplicationCallCount += 1
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertEqual(showFallbackSettingsWindowCallCount, 2)
|
||||
XCTAssertEqual(activateApplicationCallCount, 2)
|
||||
}
|
||||
|
||||
private func makeKeyDownEvent(
|
||||
key: String,
|
||||
modifiers: NSEvent.ModifierFlags,
|
||||
|
|
|
|||
|
|
@ -641,6 +641,40 @@ final class CmuxWebViewContextMenuTests: XCTestCase {
|
|||
|
||||
XCTAssertFalse(menu.items.contains { $0.title == "Open Link in Default Browser" })
|
||||
}
|
||||
|
||||
func testWillOpenMenuHooksDownloadImageToDiskMenuVariant() {
|
||||
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
||||
let menu = NSMenu()
|
||||
let originalTarget = NSObject()
|
||||
let originalAction = NSSelectorFromString("downloadImageToDisk:")
|
||||
let downloadItem = NSMenuItem(title: "Download Image As...", action: originalAction, keyEquivalent: "")
|
||||
downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadImageToDisk")
|
||||
downloadItem.target = originalTarget
|
||||
menu.addItem(downloadItem)
|
||||
|
||||
webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
|
||||
|
||||
XCTAssertTrue(downloadItem.target === webView)
|
||||
XCTAssertNotNil(downloadItem.action)
|
||||
XCTAssertNotEqual(downloadItem.action, originalAction)
|
||||
}
|
||||
|
||||
func testWillOpenMenuHooksDownloadLinkedFileToDiskMenuVariant() {
|
||||
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
||||
let menu = NSMenu()
|
||||
let originalTarget = NSObject()
|
||||
let originalAction = NSSelectorFromString("downloadLinkToDisk:")
|
||||
let downloadItem = NSMenuItem(title: "Download Linked File As...", action: originalAction, keyEquivalent: "")
|
||||
downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadLinkToDisk")
|
||||
downloadItem.target = originalTarget
|
||||
menu.addItem(downloadItem)
|
||||
|
||||
webView.willOpenMenu(menu, with: makeRightMouseDownEvent())
|
||||
|
||||
XCTAssertTrue(downloadItem.target === webView)
|
||||
XCTAssertNotNil(downloadItem.action)
|
||||
XCTAssertNotEqual(downloadItem.action, originalAction)
|
||||
}
|
||||
}
|
||||
|
||||
final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase {
|
||||
|
|
@ -2936,6 +2970,101 @@ final class TabManagerSurfaceCreationTests: XCTestCase {
|
|||
)
|
||||
XCTAssertEqual(workspace.focusedPanelId, browserPanelId, "Expected opened browser surface to be focused")
|
||||
}
|
||||
|
||||
func testOpenBrowserInWorkspaceSplitRightSelectsTargetWorkspaceAndCreatesSplit() {
|
||||
let manager = TabManager()
|
||||
guard let initialWorkspace = manager.selectedWorkspace else {
|
||||
XCTFail("Expected initial selected workspace")
|
||||
return
|
||||
}
|
||||
guard let url = URL(string: "https://example.com/pull/123") else {
|
||||
XCTFail("Expected test URL to be valid")
|
||||
return
|
||||
}
|
||||
|
||||
let targetWorkspace = manager.addWorkspace(select: false)
|
||||
manager.selectWorkspace(initialWorkspace)
|
||||
let initialPaneCount = targetWorkspace.bonsplitController.allPaneIds.count
|
||||
let initialPanelCount = targetWorkspace.panels.count
|
||||
|
||||
guard let browserPanelId = manager.openBrowser(
|
||||
inWorkspace: targetWorkspace.id,
|
||||
url: url,
|
||||
preferSplitRight: true,
|
||||
insertAtEnd: true
|
||||
) else {
|
||||
XCTFail("Expected browser panel to be created in target workspace")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(manager.selectedTabId, targetWorkspace.id, "Expected target workspace to become selected")
|
||||
XCTAssertEqual(
|
||||
targetWorkspace.bonsplitController.allPaneIds.count,
|
||||
initialPaneCount + 1,
|
||||
"Expected split-right browser open to create a new pane"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
targetWorkspace.panels.count,
|
||||
initialPanelCount + 1,
|
||||
"Expected browser panel count to increase by one"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
targetWorkspace.focusedPanelId,
|
||||
browserPanelId,
|
||||
"Expected created browser panel to be focused in target workspace"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
targetWorkspace.panels[browserPanelId] is BrowserPanel,
|
||||
"Expected created panel to be a browser panel"
|
||||
)
|
||||
}
|
||||
|
||||
func testOpenBrowserInWorkspaceSplitRightReusesTopRightPaneWhenAlreadySplit() {
|
||||
let manager = TabManager()
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let leftPanelId = workspace.focusedPanelId,
|
||||
let topRightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal),
|
||||
workspace.newTerminalSplit(from: topRightPanel.id, orientation: .vertical) != nil,
|
||||
let topRightPaneId = workspace.paneId(forPanelId: topRightPanel.id),
|
||||
let url = URL(string: "https://example.com/pull/456") else {
|
||||
XCTFail("Expected split setup to succeed")
|
||||
return
|
||||
}
|
||||
|
||||
let initialPaneCount = workspace.bonsplitController.allPaneIds.count
|
||||
|
||||
guard let browserPanelId = manager.openBrowser(
|
||||
inWorkspace: workspace.id,
|
||||
url: url,
|
||||
preferSplitRight: true,
|
||||
insertAtEnd: true
|
||||
) else {
|
||||
XCTFail("Expected browser panel to be created")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(
|
||||
workspace.bonsplitController.allPaneIds.count,
|
||||
initialPaneCount,
|
||||
"Expected split-right browser open to reuse existing panes"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
workspace.paneId(forPanelId: browserPanelId),
|
||||
topRightPaneId,
|
||||
"Expected browser to open in the top-right pane when multiple splits already exist"
|
||||
)
|
||||
|
||||
let targetPaneTabs = workspace.bonsplitController.tabs(inPane: topRightPaneId)
|
||||
guard let lastSurfaceId = targetPaneTabs.last?.id else {
|
||||
XCTFail("Expected top-right pane to contain tabs")
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(
|
||||
workspace.panelIdFromSurfaceId(lastSurfaceId),
|
||||
browserPanelId,
|
||||
"Expected browser surface to be appended at end in the reused top-right pane"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
@ -3714,6 +3843,149 @@ final class SidebarBranchOrderingTests: XCTestCase {
|
|||
[SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/default")]
|
||||
)
|
||||
}
|
||||
|
||||
func testOrderedUniquePullRequestsFollowsPanelOrderAcrossSplitsAndTabs() {
|
||||
let first = UUID()
|
||||
let second = UUID()
|
||||
let third = UUID()
|
||||
let fourth = UUID()
|
||||
|
||||
let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests(
|
||||
orderedPanelIds: [first, second, third, fourth],
|
||||
panelPullRequests: [
|
||||
first: pullRequestState(
|
||||
number: 337,
|
||||
label: "PR",
|
||||
url: "https://github.com/manaflow-ai/cmux/pull/337",
|
||||
status: .open
|
||||
),
|
||||
second: pullRequestState(
|
||||
number: 18,
|
||||
label: "MR",
|
||||
url: "https://gitlab.com/manaflow/cmux/-/merge_requests/18",
|
||||
status: .open
|
||||
),
|
||||
third: pullRequestState(
|
||||
number: 337,
|
||||
label: "PR",
|
||||
url: "https://github.com/manaflow-ai/cmux/pull/337",
|
||||
status: .merged
|
||||
),
|
||||
fourth: pullRequestState(
|
||||
number: 92,
|
||||
label: "PR",
|
||||
url: "https://bitbucket.org/manaflow/cmux/pull-requests/92",
|
||||
status: .closed
|
||||
)
|
||||
],
|
||||
fallbackPullRequest: pullRequestState(
|
||||
number: 1,
|
||||
label: "PR",
|
||||
url: "https://example.invalid/fallback/1",
|
||||
status: .open
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
pullRequests.map { "\($0.label)#\($0.number)" },
|
||||
["PR#337", "MR#18", "PR#92"]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
pullRequests.map(\.status),
|
||||
[.merged, .open, .closed]
|
||||
)
|
||||
}
|
||||
|
||||
func testOrderedUniquePullRequestsTreatsSameNumberDifferentLabelsAsDistinct() {
|
||||
let first = UUID()
|
||||
let second = UUID()
|
||||
|
||||
let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests(
|
||||
orderedPanelIds: [first, second],
|
||||
panelPullRequests: [
|
||||
first: pullRequestState(
|
||||
number: 42,
|
||||
label: "PR",
|
||||
url: "https://github.com/manaflow-ai/cmux/pull/42",
|
||||
status: .open
|
||||
),
|
||||
second: pullRequestState(
|
||||
number: 42,
|
||||
label: "MR",
|
||||
url: "https://gitlab.com/manaflow/cmux/-/merge_requests/42",
|
||||
status: .open
|
||||
)
|
||||
],
|
||||
fallbackPullRequest: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
pullRequests.map { "\($0.label)#\($0.number)" },
|
||||
["PR#42", "MR#42"]
|
||||
)
|
||||
}
|
||||
|
||||
func testOrderedUniquePullRequestsTreatsSameNumberAndLabelDifferentUrlsAsDistinct() {
|
||||
let first = UUID()
|
||||
let second = UUID()
|
||||
|
||||
let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests(
|
||||
orderedPanelIds: [first, second],
|
||||
panelPullRequests: [
|
||||
first: pullRequestState(
|
||||
number: 42,
|
||||
label: "PR",
|
||||
url: "https://github.com/manaflow-ai/cmux/pull/42",
|
||||
status: .open
|
||||
),
|
||||
second: pullRequestState(
|
||||
number: 42,
|
||||
label: "PR",
|
||||
url: "https://github.com/manaflow-ai/other-repo/pull/42",
|
||||
status: .open
|
||||
)
|
||||
],
|
||||
fallbackPullRequest: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
pullRequests.map(\.url.absoluteString),
|
||||
[
|
||||
"https://github.com/manaflow-ai/cmux/pull/42",
|
||||
"https://github.com/manaflow-ai/other-repo/pull/42"
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testOrderedUniquePullRequestsUsesFallbackWhenNoPanelPullRequestsExist() {
|
||||
let fallback = pullRequestState(
|
||||
number: 11,
|
||||
label: "PR",
|
||||
url: "https://github.com/manaflow-ai/cmux/pull/11",
|
||||
status: .open
|
||||
)
|
||||
let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests(
|
||||
orderedPanelIds: [],
|
||||
panelPullRequests: [:],
|
||||
fallbackPullRequest: fallback
|
||||
)
|
||||
|
||||
XCTAssertEqual(pullRequests, [fallback])
|
||||
}
|
||||
|
||||
private func pullRequestState(
|
||||
number: Int,
|
||||
label: String,
|
||||
url: String,
|
||||
status: SidebarPullRequestStatus
|
||||
) -> SidebarPullRequestState {
|
||||
SidebarPullRequestState(
|
||||
number: number,
|
||||
label: label,
|
||||
url: URL(string: url)!,
|
||||
status: status
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
@ -6309,6 +6581,18 @@ final class BrowserLinkOpenSettingsTests: XCTestCase {
|
|||
XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults))
|
||||
}
|
||||
|
||||
func testSidebarPullRequestLinksDefaultToCmuxBrowser() {
|
||||
XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults))
|
||||
}
|
||||
|
||||
func testSidebarPullRequestLinksPreferenceUsesStoredValue() {
|
||||
defaults.set(false, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
|
||||
XCTAssertFalse(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults))
|
||||
|
||||
defaults.set(true, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
|
||||
XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults))
|
||||
}
|
||||
|
||||
func testOpenCommandInterceptionDefaultsToCmuxBrowser() {
|
||||
XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults))
|
||||
}
|
||||
|
|
@ -6547,7 +6831,10 @@ final class TerminalControllerSidebarDedupeTests: XCTestCase {
|
|||
key: "agent",
|
||||
value: "idle",
|
||||
icon: "bolt",
|
||||
color: "#ffffff"
|
||||
color: "#ffffff",
|
||||
url: nil,
|
||||
priority: 0,
|
||||
format: .plain
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -6566,7 +6853,10 @@ final class TerminalControllerSidebarDedupeTests: XCTestCase {
|
|||
key: "agent",
|
||||
value: "running",
|
||||
icon: "bolt",
|
||||
color: "#ffffff"
|
||||
color: "#ffffff",
|
||||
url: nil,
|
||||
priority: 0,
|
||||
format: .plain
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -691,6 +691,68 @@ final class SocketControlSettingsTests: XCTestCase {
|
|||
"/tmp/cmux-staging.sock"
|
||||
)
|
||||
}
|
||||
|
||||
func testUntaggedDebugBundleBlockedWithoutLaunchTag() {
|
||||
XCTAssertTrue(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
environment: [:],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug",
|
||||
isDebugBuild: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testUntaggedDebugBundleAllowedWithLaunchTag() {
|
||||
XCTAssertFalse(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
environment: ["CMUX_TAG": "tests-v1"],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug",
|
||||
isDebugBuild: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testTaggedDebugBundleAllowedWithoutLaunchTag() {
|
||||
XCTAssertFalse(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
environment: [:],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug.tests-v1",
|
||||
isDebugBuild: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testReleaseBuildIgnoresLaunchTagGate() {
|
||||
XCTAssertFalse(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
environment: [:],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug",
|
||||
isDebugBuild: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testXCTestLaunchIgnoresLaunchTagGate() {
|
||||
XCTAssertFalse(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
environment: ["XCTestConfigurationFilePath": "/tmp/fake.xctestconfiguration"],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug",
|
||||
isDebugBuild: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testXCUITestLaunchEnvironmentIgnoresLaunchTagGate() {
|
||||
// XCUITest launches the app as a separate process without XCTest env vars.
|
||||
// The app receives CMUX_UI_TEST_* vars via XCUIApplication.launchEnvironment.
|
||||
XCTAssertFalse(
|
||||
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
|
||||
environment: ["CMUX_UI_TEST_MODE": "1"],
|
||||
bundleIdentifier: "com.cmuxterm.app.debug",
|
||||
isDebugBuild: true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class PostHogAnalyticsPropertiesTests: XCTestCase {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ cd "$(dirname "$0")/.."
|
|||
|
||||
DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v1"
|
||||
APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app"
|
||||
RUN_TAG="tests-v1"
|
||||
|
||||
echo "== build =="
|
||||
# Work around stale explicit-module cache artifacts (notably Sentry headers) that can
|
||||
|
|
@ -51,7 +52,7 @@ launch_and_wait() {
|
|||
defaults write com.cmuxterm.app.debug socketControlMode -string full >/dev/null 2>&1 || true
|
||||
|
||||
# Launch directly with UI test mode enabled so startup follows deterministic test codepaths.
|
||||
CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 &
|
||||
CMUX_TAG="$RUN_TAG" CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 &
|
||||
|
||||
SOCK=""
|
||||
for _ in {1..120}; do
|
||||
|
|
@ -70,7 +71,7 @@ launch_and_wait() {
|
|||
export CMUX_SOCKET="$SOCK"
|
||||
|
||||
# Ensure LaunchServices has a visible/main window attached for rendering checks.
|
||||
open "$APP" >/dev/null 2>&1 || true
|
||||
CMUX_TAG="$RUN_TAG" open "$APP" >/dev/null 2>&1 || true
|
||||
sleep 0.5
|
||||
|
||||
echo "== wait ready =="
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ cd "$(dirname "$0")/.."
|
|||
|
||||
DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v2"
|
||||
APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app"
|
||||
RUN_TAG="tests-v2"
|
||||
|
||||
echo "== build =="
|
||||
# Work around stale explicit-module cache artifacts (notably Sentry headers) that can
|
||||
|
|
@ -51,7 +52,7 @@ launch_and_wait() {
|
|||
defaults write com.cmuxterm.app.debug socketControlMode -string full >/dev/null 2>&1 || true
|
||||
|
||||
# Launch directly with UI test mode enabled so startup follows deterministic test codepaths.
|
||||
CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 &
|
||||
CMUX_TAG="$RUN_TAG" CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 &
|
||||
|
||||
SOCK=""
|
||||
for _ in {1..120}; do
|
||||
|
|
@ -70,7 +71,7 @@ launch_and_wait() {
|
|||
export CMUX_SOCKET="$SOCK"
|
||||
|
||||
# Ensure LaunchServices has a visible/main window attached for rendering checks.
|
||||
open "$APP" >/dev/null 2>&1 || true
|
||||
CMUX_TAG="$RUN_TAG" open "$APP" >/dev/null 2>&1 || true
|
||||
sleep 0.5
|
||||
|
||||
echo "== wait ready =="
|
||||
|
|
|
|||
155
tests/cmux.py
155
tests/cmux.py
|
|
@ -500,7 +500,17 @@ class cmux:
|
|||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def set_status(self, key: str, value: str, icon: str = None, color: str = None, tab: str = None) -> None:
|
||||
def set_status(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
icon: str = None,
|
||||
color: str = None,
|
||||
url: str = None,
|
||||
priority: int = None,
|
||||
format: str = None,
|
||||
tab: str = None,
|
||||
) -> None:
|
||||
"""Set a sidebar status entry."""
|
||||
# Put options before `--` so value can contain arbitrary tokens like `--tab`.
|
||||
cmd = f"set_status {key}"
|
||||
|
|
@ -508,6 +518,12 @@ class cmux:
|
|||
cmd += f" --icon={icon}"
|
||||
if color:
|
||||
cmd += f" --color={color}"
|
||||
if url:
|
||||
cmd += f" --url={_quote_option_value(url)}"
|
||||
if priority is not None:
|
||||
cmd += f" --priority={priority}"
|
||||
if format:
|
||||
cmd += f" --format={format}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
cmd += f" -- {_quote_option_value(value)}"
|
||||
|
|
@ -524,6 +540,86 @@ class cmux:
|
|||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def report_meta(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
icon: str = None,
|
||||
color: str = None,
|
||||
url: str = None,
|
||||
priority: int = None,
|
||||
format: str = None,
|
||||
tab: str = None,
|
||||
) -> None:
|
||||
"""Report a sidebar metadata entry."""
|
||||
cmd = f"report_meta {key}"
|
||||
if icon:
|
||||
cmd += f" --icon={icon}"
|
||||
if color:
|
||||
cmd += f" --color={color}"
|
||||
if url:
|
||||
cmd += f" --url={_quote_option_value(url)}"
|
||||
if priority is not None:
|
||||
cmd += f" --priority={priority}"
|
||||
if format:
|
||||
cmd += f" --format={format}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
cmd += f" -- {_quote_option_value(value)}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def clear_meta(self, key: str, tab: str = None) -> None:
|
||||
"""Remove a sidebar metadata entry."""
|
||||
cmd = f"clear_meta {key}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def list_meta(self, tab: str = None) -> str:
|
||||
"""List sidebar metadata entries."""
|
||||
cmd = "list_meta"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
response = self._send_command(cmd)
|
||||
if response.startswith("ERROR"):
|
||||
raise cmuxError(response)
|
||||
return response
|
||||
|
||||
def report_meta_block(self, key: str, markdown: str, priority: int = None, tab: str = None) -> None:
|
||||
"""Report a freeform sidebar markdown metadata block."""
|
||||
cmd = f"report_meta_block {key}"
|
||||
if priority is not None:
|
||||
cmd += f" --priority={priority}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
cmd += f" -- {_quote_option_value(markdown)}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def clear_meta_block(self, key: str, tab: str = None) -> None:
|
||||
"""Remove a sidebar markdown metadata block."""
|
||||
cmd = f"clear_meta_block {key}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def list_meta_blocks(self, tab: str = None) -> str:
|
||||
"""List sidebar markdown metadata blocks."""
|
||||
cmd = "list_meta_blocks"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
response = self._send_command(cmd)
|
||||
if response.startswith("ERROR"):
|
||||
raise cmuxError(response)
|
||||
return response
|
||||
|
||||
def log(self, message: str, level: str = None, source: str = None, tab: str = None) -> None:
|
||||
"""Append a sidebar log entry."""
|
||||
# TerminalController.parseOptions treats any --* token as an option until
|
||||
|
|
@ -572,6 +668,63 @@ class cmux:
|
|||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def report_pr(
|
||||
self,
|
||||
number: int,
|
||||
url: str,
|
||||
label: str = None,
|
||||
state: str = None,
|
||||
tab: str = None,
|
||||
panel: str = None,
|
||||
) -> None:
|
||||
"""Report pull-request metadata for sidebar display."""
|
||||
cmd = f"report_pr {number} {url}"
|
||||
if label:
|
||||
cmd += f" --label={_quote_option_value(label)}"
|
||||
if state:
|
||||
cmd += f" --state={state}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
if panel:
|
||||
cmd += f" --panel={panel}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def report_review(
|
||||
self,
|
||||
number: int,
|
||||
url: str,
|
||||
label: str = None,
|
||||
state: str = None,
|
||||
tab: str = None,
|
||||
panel: str = None,
|
||||
) -> None:
|
||||
"""Report provider-specific review metadata (GitLab MR, Bitbucket PR, etc.)."""
|
||||
cmd = f"report_review {number} {url}"
|
||||
if label:
|
||||
cmd += f" --label={_quote_option_value(label)}"
|
||||
if state:
|
||||
cmd += f" --state={state}"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
if panel:
|
||||
cmd += f" --panel={panel}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def clear_pr(self, tab: str = None, panel: str = None) -> None:
|
||||
"""Clear pull-request metadata for sidebar display."""
|
||||
cmd = "clear_pr"
|
||||
if tab:
|
||||
cmd += f" --tab={tab}"
|
||||
if panel:
|
||||
cmd += f" --panel={panel}"
|
||||
response = self._send_command(cmd)
|
||||
if not response.startswith("OK"):
|
||||
raise cmuxError(response)
|
||||
|
||||
def report_ports(self, *ports: int, tab: str = None) -> None:
|
||||
"""Report listening ports for sidebar display."""
|
||||
port_str = " ".join(str(p) for p in ports)
|
||||
|
|
|
|||
84
tests/test_claude_hook_missing_socket_error.py
Normal file
84
tests/test_claude_hook_missing_socket_error.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: claude-hook stop surfaces a clear socket-connect error when target socket is missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
def resolve_cmux_cli() -> str:
|
||||
explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI")
|
||||
if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK):
|
||||
return explicit
|
||||
|
||||
candidates: list[str] = []
|
||||
candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux")))
|
||||
candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux"))
|
||||
candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)]
|
||||
if candidates:
|
||||
candidates.sort(key=os.path.getmtime, reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
in_path = shutil.which("cmux")
|
||||
if in_path:
|
||||
return in_path
|
||||
|
||||
raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
cli_path = resolve_cmux_cli()
|
||||
except Exception as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
return 1
|
||||
|
||||
missing_socket = os.path.join(tempfile.gettempdir(), f"cmux-missing-{os.getpid()}.sock")
|
||||
try:
|
||||
if os.path.exists(missing_socket):
|
||||
os.remove(missing_socket)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
env = os.environ.copy()
|
||||
env["CMUX_CLI_SENTRY_DISABLED"] = "1"
|
||||
env["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1"
|
||||
env.pop("CMUX_SOCKET_PATH", None)
|
||||
|
||||
proc = subprocess.run(
|
||||
[cli_path, "--socket", missing_socket, "claude-hook", "stop"],
|
||||
input="{}",
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
|
||||
if proc.returncode == 0:
|
||||
print("FAIL: expected non-zero exit when socket is missing")
|
||||
print(f"stdout={proc.stdout}")
|
||||
print(f"stderr={proc.stderr}")
|
||||
return 1
|
||||
|
||||
expected_prefixes = [
|
||||
f"Error: Socket not found at {missing_socket}",
|
||||
f"Error: Failed to connect to socket at {missing_socket}",
|
||||
]
|
||||
if not any(prefix in proc.stderr for prefix in expected_prefixes):
|
||||
print("FAIL: missing expected socket error text")
|
||||
print(f"expected one of: {expected_prefixes!r}")
|
||||
print(f"stderr: {proc.stderr!r}")
|
||||
return 1
|
||||
|
||||
print("PASS: claude-hook stop missing-socket error is explicit")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
115
tests/test_cli_socket_sentry_scope.py
Normal file
115
tests/test_cli_socket_sentry_scope.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test: CLI socket Sentry telemetry must apply to all commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle not in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def reject(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
cli_path = repo_root / "CLI" / "cmux.swift"
|
||||
if not cli_path.exists():
|
||||
print(f"FAIL: missing expected file: {cli_path}")
|
||||
return 1
|
||||
|
||||
content = cli_path.read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
|
||||
require(
|
||||
content,
|
||||
"private final class CLISocketSentryTelemetry {",
|
||||
"Missing CLISocketSentryTelemetry definition",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" ||',
|
||||
"Missing CMUX_CLI_SENTRY_DISABLED kill switch",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1"',
|
||||
"Missing backwards-compatible CMUX_CLAUDE_HOOK_SENTRY_DISABLED kill switch",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"private var shouldEmit: Bool {\n !disabledByEnv\n }",
|
||||
"Telemetry scope should be command-agnostic (only disabled by env kill switch)",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'let crumb = Breadcrumb(level: .info, category: "cmux.cli")',
|
||||
"Telemetry breadcrumb category should be cmux.cli",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'"command": command,',
|
||||
"Base telemetry context must include command name",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"let cliTelemetry = CLISocketSentryTelemetry(",
|
||||
"CLI should initialize generic socket telemetry",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'cliTelemetry.breadcrumb(\n "socket.connect.attempt",',
|
||||
"CLI should emit socket.connect.attempt breadcrumb for commands",
|
||||
failures,
|
||||
)
|
||||
|
||||
reject(
|
||||
content,
|
||||
"self.enabled = command == \"claude-hook\"",
|
||||
"Telemetry regressed to claude-hook-only scope",
|
||||
failures,
|
||||
)
|
||||
reject(
|
||||
content,
|
||||
"enabled && !disabledByEnv",
|
||||
"Telemetry still depends on legacy enabled flag",
|
||||
failures,
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: CLI socket telemetry scope regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: CLI socket telemetry scope is command-agnostic")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
85
tests/test_cli_version_commit_metadata.py
Normal file
85
tests/test_cli_version_commit_metadata.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test: CLI version output wiring keeps commit metadata support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle not in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
cli_path = repo_root / "CLI" / "cmux.swift"
|
||||
if not cli_path.exists():
|
||||
print(f"FAIL: missing expected file: {cli_path}")
|
||||
return 1
|
||||
|
||||
content = cli_path.read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
|
||||
require(
|
||||
content,
|
||||
'let commit = info["CMUXCommit"].flatMap { normalizedCommitHash($0) }',
|
||||
"versionSummary no longer reads CMUXCommit metadata",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'return "\\(baseSummary) [\\(commit)]"',
|
||||
"versionSummary no longer appends commit metadata",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'if let commit = dictionary["CMUXCommit"] as? String,',
|
||||
"Info.plist parsing no longer reads CMUXCommit",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"if let commit = gitCommitHash(at: current) {",
|
||||
"Project fallback no longer probes git commit hash",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'["git", "-C", directory.path, "rev-parse", "--short=9", "HEAD"]',
|
||||
"Git commit probe command changed unexpectedly",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'normalizedCommitHash(ProcessInfo.processInfo.environment["CMUX_COMMIT"])',
|
||||
"Environment commit fallback (CMUX_COMMIT) is missing",
|
||||
failures,
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: CLI version commit metadata regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: CLI version commit metadata wiring is intact")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
118
tests/test_command_palette_socket_restart_command.py
Normal file
118
tests/test_command_palette_socket_restart_command.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test for command-palette socket-listener restart command wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle not in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
content_view_path = repo_root / "Sources" / "ContentView.swift"
|
||||
app_delegate_path = repo_root / "Sources" / "AppDelegate.swift"
|
||||
|
||||
missing_paths = [
|
||||
str(path)
|
||||
for path in [content_view_path, app_delegate_path]
|
||||
if not path.exists()
|
||||
]
|
||||
if missing_paths:
|
||||
print("Missing expected files:")
|
||||
for path in missing_paths:
|
||||
print(f" - {path}")
|
||||
return 1
|
||||
|
||||
content_view = read_text(content_view_path)
|
||||
app_delegate = read_text(app_delegate_path)
|
||||
|
||||
failures: list[str] = []
|
||||
|
||||
require(
|
||||
content_view,
|
||||
'commandId: "palette.restartSocketListener"',
|
||||
"Missing `palette.restartSocketListener` command contribution",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content_view,
|
||||
'title: constant("Restart CLI Listener")',
|
||||
"Missing `Restart CLI Listener` command title",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content_view,
|
||||
'registry.register(commandId: "palette.restartSocketListener") {',
|
||||
"Missing command handler registration for `palette.restartSocketListener`",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content_view,
|
||||
"AppDelegate.shared?.restartSocketListener(nil)",
|
||||
"Socket restart command handler does not call `AppDelegate.restartSocketListener`",
|
||||
failures,
|
||||
)
|
||||
|
||||
require(
|
||||
app_delegate,
|
||||
"@objc func restartSocketListener(_ sender: Any?) {",
|
||||
"Missing `AppDelegate.restartSocketListener` action",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
app_delegate,
|
||||
"let mode = SocketControlSettings.effectiveMode(userMode: userMode)",
|
||||
"`restartSocketListener` no longer uses effective socket control mode",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
app_delegate,
|
||||
"let socketPath = SocketControlSettings.socketPath()",
|
||||
"`restartSocketListener` no longer uses configured socket path",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
app_delegate,
|
||||
"TerminalController.shared.stop()",
|
||||
"`restartSocketListener` no longer stops current listener before restart",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
app_delegate,
|
||||
"TerminalController.shared.start(tabManager: tabManager, socketPath: socketPath, accessMode: mode)",
|
||||
"`restartSocketListener` no longer starts listener with current settings",
|
||||
failures,
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: command-palette socket restart command regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: command-palette socket restart command wiring is intact")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
214
tests/test_issue_464_cmdw_close_terminal_browser_split.py
Normal file
214
tests/test_issue_464_cmdw_close_terminal_browser_split.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test for issue #464:
|
||||
|
||||
Scenario:
|
||||
- One workspace with exactly two panes:
|
||||
left: terminal
|
||||
right: browser (cnn.com)
|
||||
- Focus the terminal and press Cmd+W.
|
||||
|
||||
Expected:
|
||||
- Terminal closes.
|
||||
- Browser remains and fills the workspace (no stale terminal content/pane).
|
||||
|
||||
This test uses debug socket commands (`simulate_shortcut`, `layout_debug`,
|
||||
`surface_health`, `drag_hit_chain`).
|
||||
Run against a Debug app socket (typically with CMUX_SOCKET_MODE=allowAll).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05) -> bool:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(interval_s)
|
||||
return False
|
||||
|
||||
|
||||
def _wait_url_contains(client: cmux, panel_id: str, needle: str, timeout_s: float = 20.0) -> None:
|
||||
def _matches() -> bool:
|
||||
response = client._send_command(f"get_url {panel_id}").strip().lower()
|
||||
return not response.startswith("error") and needle.lower() in response
|
||||
|
||||
if not _wait_until(_matches, timeout_s=timeout_s, interval_s=0.1):
|
||||
current = client._send_command(f"get_url {panel_id}")
|
||||
raise cmuxError(f"Timed out waiting for browser URL containing '{needle}', got: {current}")
|
||||
|
||||
|
||||
def _capture_screenshot(client: cmux, label: str) -> str:
|
||||
response = client._send_command(f"screenshot {label}").strip()
|
||||
if not response.startswith("OK "):
|
||||
return f"<unavailable: {response}>"
|
||||
parts = response.split(" ", 2)
|
||||
if len(parts) < 3:
|
||||
return f"<unavailable: malformed response {response}>"
|
||||
return parts[2]
|
||||
|
||||
|
||||
def _focused_terminal_ready(client: cmux, panel_id: str) -> bool:
|
||||
try:
|
||||
return client.is_terminal_focused(panel_id)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _drag_hit_chain(client: cmux, nx: float, ny: float) -> str:
|
||||
return client._send_command(f"drag_hit_chain {nx:.3f} {ny:.3f}").strip()
|
||||
|
||||
|
||||
def _top_hit_view_class(hit_chain: str) -> str:
|
||||
if not hit_chain or hit_chain == "none" or hit_chain.startswith("ERROR"):
|
||||
return hit_chain
|
||||
first = hit_chain.split("->", 1)[0]
|
||||
return first.split("@", 1)[0]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
# Quick sanity check: fail early with actionable info if socket is not in allow mode.
|
||||
ping_ok = client.ping()
|
||||
if not ping_ok:
|
||||
raise cmuxError(
|
||||
f"Socket ping failed on {SOCKET_PATH}. "
|
||||
"Launch Debug app with CMUX_SOCKET_MODE=allowAll for this test."
|
||||
)
|
||||
|
||||
workspace_id = client.new_workspace()
|
||||
try:
|
||||
client.select_workspace(workspace_id)
|
||||
time.sleep(0.25)
|
||||
client.activate_app()
|
||||
time.sleep(0.15)
|
||||
|
||||
browser_id = client.new_pane(
|
||||
direction="right",
|
||||
panel_type="browser",
|
||||
url="https://cnn.com",
|
||||
)
|
||||
_wait_url_contains(client, browser_id, "cnn", timeout_s=20.0)
|
||||
|
||||
health_before = client.surface_health()
|
||||
terminal_rows = [row for row in health_before if row.get("type") == "terminal"]
|
||||
browser_rows = [row for row in health_before if row.get("type") == "browser"]
|
||||
if len(terminal_rows) != 1 or len(browser_rows) != 1:
|
||||
raise cmuxError(
|
||||
f"Expected exactly one terminal and one browser before close; "
|
||||
f"health={health_before}"
|
||||
)
|
||||
|
||||
terminal_id = terminal_rows[0]["id"]
|
||||
client.focus_surface(terminal_id)
|
||||
if not _wait_until(lambda: _focused_terminal_ready(client, terminal_id), timeout_s=4.0):
|
||||
raise cmuxError(f"Terminal did not become first responder before Cmd+W: {terminal_id}")
|
||||
|
||||
before_surfaces = client.list_surfaces()
|
||||
before_panes = client.list_panes()
|
||||
before_layout = client.layout_debug()
|
||||
before_shot = _capture_screenshot(client, "issue464_cmdw_before")
|
||||
|
||||
client.simulate_shortcut("cmd+w")
|
||||
|
||||
# Give close animations/routing time to settle.
|
||||
_wait_until(lambda: len(client.list_surfaces()) == 1, timeout_s=4.0, interval_s=0.05)
|
||||
time.sleep(0.25)
|
||||
|
||||
after_surfaces = client.list_surfaces()
|
||||
after_panes = client.list_panes()
|
||||
after_health = client.surface_health()
|
||||
after_layout = client.layout_debug()
|
||||
after_shot = _capture_screenshot(client, "issue464_cmdw_after")
|
||||
after_hit_chain = _drag_hit_chain(client, 0.42, 0.50)
|
||||
after_top_hit_class = _top_hit_view_class(after_hit_chain)
|
||||
|
||||
failures: list[str] = []
|
||||
|
||||
if len(after_surfaces) != 1:
|
||||
failures.append(f"Expected 1 surface after Cmd+W, got {len(after_surfaces)}: {after_surfaces}")
|
||||
|
||||
if len(after_panes) != 1:
|
||||
failures.append(f"Expected 1 pane after Cmd+W, got {len(after_panes)}: {after_panes}")
|
||||
|
||||
visible_terminals = [
|
||||
row for row in after_health
|
||||
if row.get("type") == "terminal" and row.get("in_window") is True
|
||||
]
|
||||
if visible_terminals:
|
||||
failures.append(f"Terminal still visible in_window after Cmd+W: {visible_terminals}")
|
||||
|
||||
remaining_browsers = [row for row in after_health if row.get("type") == "browser"]
|
||||
if len(remaining_browsers) != 1:
|
||||
failures.append(f"Expected one remaining browser in health, got: {remaining_browsers}")
|
||||
else:
|
||||
rb = remaining_browsers[0]
|
||||
if str(rb.get("id", "")).lower() != browser_id.lower():
|
||||
failures.append(
|
||||
f"Remaining browser id mismatch: expected {browser_id}, got {rb.get('id')}"
|
||||
)
|
||||
if rb.get("in_window") is not True:
|
||||
failures.append(f"Remaining browser not in window: {rb}")
|
||||
|
||||
selected_panels = after_layout.get("selectedPanels") or []
|
||||
if len(selected_panels) != 1:
|
||||
failures.append(f"Expected one selected panel after close, got {selected_panels}")
|
||||
else:
|
||||
selected_id = str(selected_panels[0].get("panelId", "")).lower()
|
||||
if selected_id != browser_id.lower():
|
||||
failures.append(
|
||||
f"Selected panel mismatch after close: expected browser {browser_id}, got {selected_id}"
|
||||
)
|
||||
|
||||
if after_top_hit_class == "GhosttyNSView":
|
||||
failures.append(
|
||||
"Stale terminal overlay still hit-testable after close "
|
||||
f"(top_hit={after_top_hit_class}, chain={after_hit_chain})"
|
||||
)
|
||||
|
||||
if failures:
|
||||
details = [
|
||||
"Cmd+W close regression reproduced (issue #464).",
|
||||
f"workspace={workspace_id}",
|
||||
f"browser={browser_id}",
|
||||
f"terminal={terminal_id}",
|
||||
f"before_screenshot={before_shot}",
|
||||
f"after_screenshot={after_shot}",
|
||||
f"before_surfaces={before_surfaces}",
|
||||
f"before_panes={before_panes}",
|
||||
f"before_layout={before_layout}",
|
||||
f"after_surfaces={after_surfaces}",
|
||||
f"after_panes={after_panes}",
|
||||
f"after_health={after_health}",
|
||||
f"after_layout={after_layout}",
|
||||
f"after_hit_chain={after_hit_chain}",
|
||||
f"after_top_hit_class={after_top_hit_class}",
|
||||
]
|
||||
details.extend(f"failure={msg}" for msg in failures)
|
||||
raise cmuxError("\n".join(details))
|
||||
|
||||
print(
|
||||
"PASS: Cmd+W closed terminal in terminal+browser split and left browser as sole visible pane."
|
||||
)
|
||||
print(f"before_screenshot={before_shot}")
|
||||
print(f"after_screenshot={after_shot}")
|
||||
return 0
|
||||
finally:
|
||||
try:
|
||||
client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
79
tests/test_sidebar_copy_ssh_error_context_menu.py
Normal file
79
tests/test_sidebar_copy_ssh_error_context_menu.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression test: sidebar context menu shows Copy SSH Error only when an SSH error exists."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
|
||||
if needle not in content:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = get_repo_root()
|
||||
content_view_path = repo_root / "Sources" / "ContentView.swift"
|
||||
if not content_view_path.exists():
|
||||
print(f"FAIL: missing expected file: {content_view_path}")
|
||||
return 1
|
||||
|
||||
content = content_view_path.read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
|
||||
require(
|
||||
content,
|
||||
"private var copyableSidebarSSHError: String?",
|
||||
"Missing sidebar SSH error extraction helper",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'tab.statusEntries["remote.error"]?.value',
|
||||
"Missing remote.error status fallback for copyable SSH error text",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"if let copyableSidebarSSHError {",
|
||||
"Copy SSH Error menu entry is no longer conditionally gated",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
'Button("Copy SSH Error")',
|
||||
"Missing Copy SSH Error context menu button",
|
||||
failures,
|
||||
)
|
||||
require(
|
||||
content,
|
||||
"copyTextToPasteboard(copyableSidebarSSHError)",
|
||||
"Copy SSH Error button no longer writes the resolved error text",
|
||||
failures,
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: sidebar copy SSH error context-menu regression(s) detected")
|
||||
for failure in failures:
|
||||
print(f"- {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS: sidebar Copy SSH Error context menu wiring is intact")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
126
tests/test_sidebar_meta.py
Normal file
126
tests/test_sidebar_meta.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
End-to-end test for generic sidebar metadata commands.
|
||||
|
||||
Validates:
|
||||
1) report_meta stores icon/url/priority/format metadata
|
||||
2) metadata list ordering follows priority
|
||||
3) set_status remains compatible as an alias-style metadata writer
|
||||
4) clear_meta removes metadata entries
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Add the directory containing cmux.py to the path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from cmux import cmux, cmuxError # noqa: E402
|
||||
|
||||
|
||||
def _parse_sidebar_state(text: str) -> dict[str, str]:
|
||||
data: dict[str, str] = {}
|
||||
for raw in (text or "").splitlines():
|
||||
line = raw.rstrip("\n")
|
||||
if not line or line.startswith(" "):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
data[k.strip()] = v.strip()
|
||||
return data
|
||||
|
||||
|
||||
def _wait_for_state_field(
|
||||
client: cmux,
|
||||
key: str,
|
||||
expected: str,
|
||||
timeout: float = 8.0,
|
||||
interval: float = 0.1,
|
||||
) -> dict[str, str]:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
if state.get(key) == expected:
|
||||
return state
|
||||
time.sleep(interval)
|
||||
raise AssertionError(f"Timed out waiting for {key}={expected!r}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tag = os.environ.get("CMUX_TAG") or ""
|
||||
if not tag:
|
||||
print("Tip: set CMUX_TAG=<tag> when running this test to avoid socket conflicts.")
|
||||
|
||||
pr_url = "https://github.com/manaflow-ai/cmux/pull/337"
|
||||
|
||||
try:
|
||||
with cmux() as client:
|
||||
new_tab_id = client.new_tab()
|
||||
client.select_tab(new_tab_id)
|
||||
time.sleep(0.6)
|
||||
|
||||
tab_id = client.current_workspace()
|
||||
|
||||
client.report_meta(
|
||||
"task",
|
||||
"**Review** PR 337",
|
||||
icon="sf:doc.text.magnifyingglass",
|
||||
url=pr_url,
|
||||
priority=50,
|
||||
format="markdown",
|
||||
tab=tab_id,
|
||||
)
|
||||
client.report_meta(
|
||||
"context",
|
||||
"issue-336-sidebar-pr-metadata",
|
||||
icon="text:CTX",
|
||||
priority=10,
|
||||
tab=tab_id,
|
||||
)
|
||||
_wait_for_state_field(client, "status_count", "2")
|
||||
|
||||
listed = client.list_meta(tab=tab_id).splitlines()
|
||||
if len(listed) != 2:
|
||||
raise AssertionError(f"Expected 2 metadata entries, got {len(listed)}: {listed}")
|
||||
|
||||
if not listed[0].startswith("task="):
|
||||
raise AssertionError(f"Expected first entry to be task metadata. Got: {listed[0]}")
|
||||
if "priority=50" not in listed[0]:
|
||||
raise AssertionError(f"Expected task entry to include priority. Got: {listed[0]}")
|
||||
if "format=markdown" not in listed[0]:
|
||||
raise AssertionError(f"Expected markdown format in task entry. Got: {listed[0]}")
|
||||
if f"url={pr_url}" not in listed[0]:
|
||||
raise AssertionError(f"Expected URL in task entry. Got: {listed[0]}")
|
||||
|
||||
client.set_status("agent", "in progress", icon="text:AI", priority=80, tab=tab_id)
|
||||
_wait_for_state_field(client, "status_count", "3")
|
||||
|
||||
listed = client.list_meta(tab=tab_id).splitlines()
|
||||
if not listed[0].startswith("agent="):
|
||||
raise AssertionError(f"Expected highest-priority agent entry first. Got: {listed[0]}")
|
||||
|
||||
client.clear_meta("task", tab=tab_id)
|
||||
_wait_for_state_field(client, "status_count", "2")
|
||||
|
||||
listed = client.list_meta(tab=tab_id).splitlines()
|
||||
if any(line.startswith("task=") for line in listed):
|
||||
raise AssertionError(f"Task metadata should be cleared. Got: {listed}")
|
||||
|
||||
try:
|
||||
client.close_tab(new_tab_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("Sidebar metadata test passed.")
|
||||
return 0
|
||||
except (cmuxError, AssertionError) as e:
|
||||
print(f"Sidebar metadata test failed: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
100
tests/test_sidebar_meta_block.py
Normal file
100
tests/test_sidebar_meta_block.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
End-to-end test for sidebar markdown metadata block commands.
|
||||
|
||||
Validates:
|
||||
1) report_meta_block stores markdown payload and priority
|
||||
2) metadata block list ordering follows priority
|
||||
3) clear_meta_block removes block metadata
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Add the directory containing cmux.py to the path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from cmux import cmux, cmuxError # noqa: E402
|
||||
|
||||
|
||||
def _parse_sidebar_state(text: str) -> dict[str, str]:
|
||||
data: dict[str, str] = {}
|
||||
for raw in (text or "").splitlines():
|
||||
line = raw.rstrip("\n")
|
||||
if not line or line.startswith(" "):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
data[k.strip()] = v.strip()
|
||||
return data
|
||||
|
||||
|
||||
def _wait_for_state_field(
|
||||
client: cmux,
|
||||
key: str,
|
||||
expected: str,
|
||||
timeout: float = 8.0,
|
||||
interval: float = 0.1,
|
||||
) -> dict[str, str]:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
if state.get(key) == expected:
|
||||
return state
|
||||
time.sleep(interval)
|
||||
raise AssertionError(f"Timed out waiting for {key}={expected!r}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tag = os.environ.get("CMUX_TAG") or ""
|
||||
if not tag:
|
||||
print("Tip: set CMUX_TAG=<tag> when running this test to avoid socket conflicts.")
|
||||
|
||||
try:
|
||||
with cmux() as client:
|
||||
new_tab_id = client.new_tab()
|
||||
client.select_tab(new_tab_id)
|
||||
time.sleep(0.6)
|
||||
|
||||
tab_id = client.current_workspace()
|
||||
|
||||
summary_md = "### Agent\\n- status: in progress\\n- pr: #337"
|
||||
footer_md = "_last update: now_"
|
||||
|
||||
client.report_meta_block("summary", summary_md, priority=50, tab=tab_id)
|
||||
client.report_meta_block("footer", footer_md, priority=10, tab=tab_id)
|
||||
_wait_for_state_field(client, "meta_block_count", "2")
|
||||
|
||||
listed = client.list_meta_blocks(tab=tab_id).splitlines()
|
||||
if len(listed) != 2:
|
||||
raise AssertionError(f"Expected 2 metadata blocks, got {len(listed)}: {listed}")
|
||||
if not listed[0].startswith("summary="):
|
||||
raise AssertionError(f"Expected highest-priority block first. Got: {listed[0]}")
|
||||
if "priority=50" not in listed[0]:
|
||||
raise AssertionError(f"Expected summary block priority in listing. Got: {listed[0]}")
|
||||
|
||||
client.clear_meta_block("summary", tab=tab_id)
|
||||
_wait_for_state_field(client, "meta_block_count", "1")
|
||||
|
||||
listed = client.list_meta_blocks(tab=tab_id).splitlines()
|
||||
if any(line.startswith("summary=") for line in listed):
|
||||
raise AssertionError(f"Summary block should be cleared. Got: {listed}")
|
||||
|
||||
try:
|
||||
client.close_tab(new_tab_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("Sidebar markdown metadata block test passed.")
|
||||
return 0
|
||||
except (cmuxError, AssertionError) as e:
|
||||
print(f"Sidebar markdown metadata block test failed: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
102
tests/test_sidebar_pr.py
Normal file
102
tests/test_sidebar_pr.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
End-to-end test for sidebar pull-request metadata.
|
||||
|
||||
Validates:
|
||||
1) report_pr writes sidebar PR state
|
||||
2) state transition open -> merged is reflected
|
||||
3) provider labels can be set via report_review/report_pr --label
|
||||
4) clear_pr removes PR metadata
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Add the directory containing cmux.py to the path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from cmux import cmux, cmuxError # noqa: E402
|
||||
|
||||
|
||||
def _parse_sidebar_state(text: str) -> dict[str, str]:
|
||||
data: dict[str, str] = {}
|
||||
for raw in (text or "").splitlines():
|
||||
line = raw.rstrip("\n")
|
||||
if not line or line.startswith(" "):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
data[k.strip()] = v.strip()
|
||||
return data
|
||||
|
||||
|
||||
def _wait_for_state_field(
|
||||
client: cmux,
|
||||
key: str,
|
||||
expected: str,
|
||||
timeout: float = 8.0,
|
||||
interval: float = 0.1,
|
||||
) -> dict[str, str]:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
if state.get(key) == expected:
|
||||
return state
|
||||
time.sleep(interval)
|
||||
raise AssertionError(f"Timed out waiting for {key}={expected!r}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tag = os.environ.get("CMUX_TAG") or ""
|
||||
if not tag:
|
||||
print("Tip: set CMUX_TAG=<tag> when running this test to avoid socket conflicts.")
|
||||
|
||||
pr_number = 123
|
||||
pr_url = f"https://github.com/manaflow-ai/cmux/pull/{pr_number}"
|
||||
|
||||
try:
|
||||
with cmux() as client:
|
||||
new_tab_id = client.new_tab()
|
||||
client.select_tab(new_tab_id)
|
||||
time.sleep(0.6)
|
||||
|
||||
tab_id = client.current_workspace()
|
||||
surfaces = client.list_surfaces()
|
||||
if not surfaces:
|
||||
raise AssertionError("No surfaces found in selected workspace")
|
||||
panel_id = surfaces[0][1]
|
||||
|
||||
client.report_pr(pr_number, pr_url, state="open", tab=tab_id, panel=panel_id)
|
||||
_wait_for_state_field(client, "pr", f"#{pr_number} open {pr_url}")
|
||||
_wait_for_state_field(client, "pr_label", "PR")
|
||||
|
||||
client.report_review(pr_number, pr_url, label="MR", state="open", tab=tab_id, panel=panel_id)
|
||||
_wait_for_state_field(client, "pr", f"#{pr_number} open {pr_url}")
|
||||
_wait_for_state_field(client, "pr_label", "MR")
|
||||
|
||||
client.report_pr(pr_number, pr_url, state="merged", tab=tab_id, panel=panel_id)
|
||||
_wait_for_state_field(client, "pr", f"#{pr_number} merged {pr_url}")
|
||||
_wait_for_state_field(client, "pr_label", "PR")
|
||||
|
||||
client.clear_pr(tab=tab_id, panel=panel_id)
|
||||
_wait_for_state_field(client, "pr", "none")
|
||||
_wait_for_state_field(client, "pr_label", "none")
|
||||
|
||||
try:
|
||||
client.close_tab(new_tab_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("Sidebar PR metadata test passed.")
|
||||
return 0
|
||||
except (cmuxError, AssertionError) as e:
|
||||
print(f"Sidebar PR metadata test failed: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue