diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 9220afd8..b0740d9f 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -365,11 +365,19 @@ final class SocketClient { } let raw = try send(command: requestLine) + + // The server may return plain-text errors (e.g., "ERROR: Access denied ...") + // before the JSON protocol starts. Surface these directly instead of letting + // JSONSerialization throw a confusing parse error. + if raw.hasPrefix("ERROR:") { + throw CLIError(message: raw) + } + guard let responseData = raw.data(using: .utf8) else { throw CLIError(message: "Invalid UTF-8 v2 response") } guard let response = try JSONSerialization.jsonObject(with: responseData, options: []) as? [String: Any] else { - throw CLIError(message: "Invalid v2 response") + throw CLIError(message: "Invalid v2 response: \(raw)") } if let ok = response["ok"] as? Bool, ok { @@ -852,6 +860,19 @@ struct CMUXCLI { let payload = try client.sendV2(method: "workspace.select", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) + case "rename-workspace", "rename-window": + let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") + let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let titleArgs = rem0.dropFirst(rem0.first == "--" ? 1 : 0) + let title = titleArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { + throw CLIError(message: "\(command) requires a title") + } + let wsId = try resolveWorkspaceId(workspaceArg, client: client) + let params: [String: Any] = ["title": title, "workspace_id": wsId] + let payload = try client.sendV2(method: "workspace.rename", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) + case "current-workspace": let response = try client.send(command: "current_workspace") if jsonOutput { @@ -860,6 +881,43 @@ struct CMUXCLI { print(response) } + case "read-screen": + let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") + let (sfArg, rem1) = parseOption(rem0, name: "--surface") + let (linesArg, rem2) = parseOption(rem1, name: "--lines") + let trailing = rem2.filter { $0 != "--scrollback" } + if !trailing.isEmpty { + throw CLIError(message: "read-screen: unexpected arguments: \(trailing.joined(separator: " "))") + } + + let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let surfaceArg = sfArg ?? (wsArg == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) + + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) + if let sfId { params["surface_id"] = sfId } + + let includeScrollback = rem2.contains("--scrollback") + if includeScrollback { + params["scrollback"] = true + } + if let linesArg { + guard let lineCount = Int(linesArg), lineCount > 0 else { + throw CLIError(message: "--lines must be greater than 0") + } + params["lines"] = lineCount + params["scrollback"] = true + } + + let payload = try client.sendV2(method: "surface.read_text", params: params) + if jsonOutput { + print(jsonString(payload)) + } else { + print((payload["text"] as? String) ?? "") + } + case "send": let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") let (sfArg, rem1) = parseOption(rem0, name: "--surface") @@ -980,6 +1038,38 @@ struct CMUXCLI { let response = try client.send(command: "simulate_app_active") print(response) + case "capture-pane", + "resize-pane", + "pipe-pane", + "wait-for", + "swap-pane", + "break-pane", + "join-pane", + "last-window", + "last-pane", + "next-window", + "previous-window", + "find-window", + "clear-history", + "set-hook", + "popup", + "bind-key", + "unbind-key", + "copy-mode", + "set-buffer", + "paste-buffer", + "list-buffers", + "respawn-pane", + "display-message": + try runTmuxCompatCommand( + command: command, + commandArgs: commandArgs, + client: client, + jsonOutput: jsonOutput, + idFormat: idFormat, + windowOverride: windowId + ) + case "help": print(usage()) @@ -2795,6 +2885,70 @@ struct CMUXCLI { cmux select-workspace --workspace workspace:2 cmux select-workspace --workspace 0 """ + case "rename-workspace", "rename-window": + return """ + Usage: cmux rename-workspace [--workspace ] [--] + + Rename a workspace. Defaults to the current workspace. + tmux-compatible alias: rename-window + + Flags: + --workspace <id|ref> Workspace to rename (default: current workspace) + + Example: + cmux rename-workspace "backend logs" + cmux rename-window --workspace workspace:2 "agent run" + """ + case "capture-pane": + return """ + Usage: cmux capture-pane [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>] + + tmux-compatible alias for reading terminal text from a pane. + + Example: + cmux capture-pane --workspace workspace:2 --surface surface:1 --scrollback --lines 200 + """ + case "resize-pane": + return """ + Usage: cmux resize-pane --pane <id|ref> [--workspace <id|ref>] (-L|-R|-U|-D) [--amount <n>] + + tmux-compatible pane resize command. + Note: currently returns not_supported until programmable divider resize is implemented. + """ + case "pipe-pane": + return """ + Usage: cmux pipe-pane --command <shell-command> [--workspace <id|ref>] [--surface <id|ref>] + + Capture pane text and pipe it to a shell command via stdin. + """ + case "wait-for": + return """ + Usage: cmux wait-for [-S|--signal] <name> [--timeout <seconds>] + + Wait for or signal a named synchronization token. + """ + case "swap-pane", "break-pane", "join-pane", "next-window", "previous-window", "last-window", "last-pane", "find-window", "clear-history", "set-hook", "popup", "bind-key", "unbind-key", "copy-mode", "set-buffer", "paste-buffer", "list-buffers", "respawn-pane", "display-message": + return """ + Usage: cmux \(command) --help + + tmux compatibility command. See `cmux --help` for exact syntax. + """ + case "read-screen": + return """ + Usage: cmux read-screen [flags] + + Read terminal text from a surface as plain text. + + Flags: + --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) + --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) + --scrollback Include scrollback (not just visible viewport) + --lines <n> Limit to the last n lines (implies --scrollback) + + Example: + cmux read-screen + cmux read-screen --surface surface:2 --scrollback --lines 200 + """ case "send": return """ Usage: cmux send [flags] [--] <text> @@ -3017,6 +3171,438 @@ struct CMUXCLI { return output } + private struct TmuxCompatStore: Codable { + var buffers: [String: String] = [:] + var hooks: [String: String] = [:] + } + + private func tmuxCompatStoreURL() -> URL { + let root = NSString(string: "~/.cmuxterm").expandingTildeInPath + return URL(fileURLWithPath: root).appendingPathComponent("tmux-compat-store.json") + } + + private func loadTmuxCompatStore() -> TmuxCompatStore { + let url = tmuxCompatStoreURL() + guard let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(TmuxCompatStore.self, from: data) else { + return TmuxCompatStore() + } + return decoded + } + + private func saveTmuxCompatStore(_ store: TmuxCompatStore) throws { + let url = tmuxCompatStoreURL() + let parent = url.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true, attributes: nil) + let data = try JSONEncoder().encode(store) + try data.write(to: url, options: .atomic) + } + + private func runShellCommand(_ command: String, stdinText: String) throws -> (status: Int32, stdout: String, stderr: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-lc", command] + + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + try process.run() + if let data = stdinText.data(using: .utf8) { + stdinPipe.fileHandleForWriting.write(data) + } + stdinPipe.fileHandleForWriting.closeFile() + process.waitUntilExit() + + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return (process.terminationStatus, stdout, stderr) + } + + private func tmuxWaitForSignalURL(name: String) -> URL { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + let sanitized = name.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" } + return URL(fileURLWithPath: "/tmp/cmux-wait-for-\(String(sanitized)).sig") + } + + private func runTmuxCompatCommand( + command: String, + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat, + windowOverride: String? + ) throws { + switch command { + case "capture-pane": + let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") + let (sfArg, rem1) = parseOption(rem0, name: "--surface") + let (linesArg, rem2) = parseOption(rem1, name: "--lines") + let workspaceArg = wsArg ?? (windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) + let surfaceArg = sfArg ?? (wsArg == nil && windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) + + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) + if let sfId { params["surface_id"] = sfId } + + let includeScrollback = rem2.contains("--scrollback") + if includeScrollback { + params["scrollback"] = true + } + if let linesArg { + guard let lineCount = Int(linesArg), lineCount > 0 else { + throw CLIError(message: "--lines must be greater than 0") + } + params["lines"] = lineCount + params["scrollback"] = true + } + + let payload = try client.sendV2(method: "surface.read_text", params: params) + if jsonOutput { + print(jsonString(payload)) + } else { + print((payload["text"] as? String) ?? "") + } + + case "resize-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let paneArg = optionValue(commandArgs, name: "--pane") + let amountArg = optionValue(commandArgs, name: "--amount") + let amount = Int(amountArg ?? "1") ?? 1 + if amount <= 0 { + throw CLIError(message: "--amount must be greater than 0") + } + + let direction: String = { + if commandArgs.contains("-L") { return "left" } + if commandArgs.contains("-R") { return "right" } + if commandArgs.contains("-U") { return "up" } + if commandArgs.contains("-D") { return "down" } + return "right" + }() + + var params: [String: Any] = ["direction": direction, "amount": amount] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let paneId = try normalizePaneHandle(paneArg, client: client, workspaceHandle: wsId, allowFocused: true) + if let paneId { params["pane_id"] = paneId } + let payload = try client.sendV2(method: "pane.resize", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["pane"])) + + case "pipe-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let surfaceArg = optionValue(commandArgs, name: "--surface") + let (cmdOpt, rem0) = parseOption(commandArgs, name: "--command") + let commandText: String = { + if let cmdOpt { return cmdOpt } + let trimmed = rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed + }() + guard !commandText.isEmpty else { + throw CLIError(message: "pipe-pane requires --command <shell-command>") + } + + var params: [String: Any] = ["scrollback": true] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true) + if let sfId { params["surface_id"] = sfId } + let payload = try client.sendV2(method: "surface.read_text", params: params) + let text = (payload["text"] as? String) ?? "" + let shell = try runShellCommand(commandText, stdinText: text) + if shell.status != 0 { + throw CLIError(message: "pipe-pane command failed (\(shell.status)): \(shell.stderr)") + } + if jsonOutput { + print(jsonString([ + "ok": true, + "status": shell.status, + "stdout": shell.stdout, + "stderr": shell.stderr + ])) + } else { + if !shell.stdout.isEmpty { + print(shell.stdout, terminator: "") + } + print("OK") + } + + case "wait-for": + let signal = commandArgs.contains("-S") || commandArgs.contains("--signal") + let timeoutRaw = optionValue(commandArgs, name: "--timeout") + let timeout = timeoutRaw.flatMap { Double($0) } ?? 30.0 + let name = commandArgs.first(where: { !$0.hasPrefix("-") }) ?? "" + guard !name.isEmpty else { + throw CLIError(message: "wait-for requires a name") + } + let signalURL = tmuxWaitForSignalURL(name: name) + if signal { + FileManager.default.createFile(atPath: signalURL.path, contents: Data()) + print("OK") + return + } + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if FileManager.default.fileExists(atPath: signalURL.path) { + try? FileManager.default.removeItem(at: signalURL) + print("OK") + return + } + Thread.sleep(forTimeInterval: 0.05) + } + throw CLIError(message: "wait-for timed out waiting for '\(name)'") + + case "swap-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + guard let sourcePaneRaw = optionValue(commandArgs, name: "--pane") else { + throw CLIError(message: "swap-pane requires --pane") + } + guard let targetPaneRaw = optionValue(commandArgs, name: "--target-pane") else { + throw CLIError(message: "swap-pane requires --target-pane") + } + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sourcePane = try normalizePaneHandle(sourcePaneRaw, client: client, workspaceHandle: wsId) + let targetPane = try normalizePaneHandle(targetPaneRaw, client: client, workspaceHandle: wsId) + if let sourcePane { params["pane_id"] = sourcePane } + if let targetPane { params["target_pane_id"] = targetPane } + let payload = try client.sendV2(method: "pane.swap", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") + + case "break-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let paneArg = optionValue(commandArgs, name: "--pane") + let surfaceArg = optionValue(commandArgs, name: "--surface") + var params: [String: Any] = ["focus": !commandArgs.contains("--no-focus")] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let paneId = try normalizePaneHandle(paneArg, client: client, workspaceHandle: wsId) + if let paneId { params["pane_id"] = paneId } + let surfaceId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) + if let surfaceId { params["surface_id"] = surfaceId } + let payload = try client.sendV2(method: "pane.break", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") + + case "join-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let sourcePaneArg = optionValue(commandArgs, name: "--pane") + let surfaceArg = optionValue(commandArgs, name: "--surface") + guard let targetPaneArg = optionValue(commandArgs, name: "--target-pane") else { + throw CLIError(message: "join-pane requires --target-pane") + } + var params: [String: Any] = ["focus": !commandArgs.contains("--no-focus")] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sourcePaneId = try normalizePaneHandle(sourcePaneArg, client: client, workspaceHandle: wsId) + if let sourcePaneId { params["pane_id"] = sourcePaneId } + let targetPaneId = try normalizePaneHandle(targetPaneArg, client: client, workspaceHandle: wsId) + if let targetPaneId { params["target_pane_id"] = targetPaneId } + let surfaceId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) + if let surfaceId { params["surface_id"] = surfaceId } + let payload = try client.sendV2(method: "pane.join", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") + + case "last-window": + let payload = try client.sendV2(method: "workspace.last") + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) + + case "next-window": + let payload = try client.sendV2(method: "workspace.next") + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) + + case "previous-window": + let payload = try client.sendV2(method: "workspace.previous") + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) + + case "last-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let payload = try client.sendV2(method: "pane.last", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["pane"])) + + case "find-window": + let includeContent = commandArgs.contains("--content") + let shouldSelect = commandArgs.contains("--select") + let query = commandArgs + .filter { !$0.hasPrefix("-") } + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + + let listPayload = try client.sendV2(method: "workspace.list") + let workspaces = listPayload["workspaces"] as? [[String: Any]] ?? [] + + var matches: [[String: Any]] = [] + for ws in workspaces { + let title = (ws["title"] as? String) ?? "" + let titleMatch = query.isEmpty || title.localizedCaseInsensitiveContains(query) + var contentMatch = false + if includeContent && !query.isEmpty, let wsId = ws["id"] as? String { + let textPayload = try? client.sendV2(method: "surface.read_text", params: ["workspace_id": wsId]) + let text = (textPayload?["text"] as? String) ?? "" + contentMatch = text.localizedCaseInsensitiveContains(query) + } + if titleMatch || contentMatch { + matches.append(ws) + } + } + + if shouldSelect, let first = matches.first, let wsId = first["id"] as? String { + _ = try client.sendV2(method: "workspace.select", params: ["workspace_id": wsId]) + } + + if jsonOutput { + let formatted = formatIDs(["matches": matches], mode: idFormat) as? [String: Any] + print(jsonString(["matches": formatted?["matches"] ?? []])) + } else if matches.isEmpty { + print("No matches") + } else { + for item in matches { + let handle = textHandle(item, idFormat: idFormat) + let title = (item["title"] as? String) ?? "" + print("\(handle) \"\(title)\"") + } + } + + case "clear-history": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let surfaceArg = optionValue(commandArgs, name: "--surface") + var params: [String: Any] = [:] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true) + if let sfId { params["surface_id"] = sfId } + let payload = try client.sendV2(method: "surface.clear_history", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) + + case "set-hook": + var store = loadTmuxCompatStore() + if commandArgs.contains("--list") { + if jsonOutput { + print(jsonString(["hooks": store.hooks])) + } else if store.hooks.isEmpty { + print("No hooks configured") + } else { + for (event, hookCmd) in store.hooks.sorted(by: { $0.key < $1.key }) { + print("\(event) -> \(hookCmd)") + } + } + return + } + if commandArgs.contains("--unset") { + guard let event = commandArgs.last else { + throw CLIError(message: "set-hook --unset requires an event name") + } + store.hooks.removeValue(forKey: event) + try saveTmuxCompatStore(store) + print("OK") + return + } + guard let event = commandArgs.first(where: { !$0.hasPrefix("-") }) else { + throw CLIError(message: "set-hook requires <event> <command>") + } + let commandText = commandArgs.drop(while: { $0 != event }).dropFirst().joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + guard !commandText.isEmpty else { + throw CLIError(message: "set-hook requires <event> <command>") + } + store.hooks[event] = commandText + try saveTmuxCompatStore(store) + print("OK") + + case "popup": + throw CLIError(message: "popup is not supported yet in cmux CLI parity mode") + + case "bind-key", "unbind-key", "copy-mode": + throw CLIError(message: "\(command) is not supported yet in cmux CLI parity mode") + + case "set-buffer": + let (nameArg, rem0) = parseOption(commandArgs, name: "--name") + let name = (nameArg?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? nameArg! : "default" + let content = rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + guard !content.isEmpty else { + throw CLIError(message: "set-buffer requires text") + } + var store = loadTmuxCompatStore() + store.buffers[name] = content + try saveTmuxCompatStore(store) + print("OK") + + case "list-buffers": + let store = loadTmuxCompatStore() + if jsonOutput { + let payload = store.buffers.map { key, value in ["name": key, "size": value.count] } + print(jsonString(["buffers": payload.sorted { ($0["name"] as? String ?? "") < ($1["name"] as? String ?? "") }])) + } else if store.buffers.isEmpty { + print("No buffers") + } else { + for key in store.buffers.keys.sorted() { + let size = store.buffers[key]?.count ?? 0 + print("\(key)\t\(size)") + } + } + + case "paste-buffer": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let surfaceArg = optionValue(commandArgs, name: "--surface") + let name = optionValue(commandArgs, name: "--name") ?? "default" + let store = loadTmuxCompatStore() + guard let buffer = store.buffers[name] else { + throw CLIError(message: "Buffer not found: \(name)") + } + var params: [String: Any] = ["text": buffer] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true) + if let sfId { params["surface_id"] = sfId } + let payload = try client.sendV2(method: "surface.send_text", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") + + case "respawn-pane": + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) + let surfaceArg = optionValue(commandArgs, name: "--surface") + let (commandOpt, rem0) = parseOption(commandArgs, name: "--command") + let commandText = (commandOpt ?? rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ")).trimmingCharacters(in: .whitespacesAndNewlines) + let finalCommand = commandText.isEmpty ? "exec ${SHELL:-/bin/zsh} -l" : commandText + var params: [String: Any] = ["text": finalCommand + "\n"] + let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) + if let wsId { params["workspace_id"] = wsId } + let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true) + if let sfId { params["surface_id"] = sfId } + let payload = try client.sendV2(method: "surface.send_text", params: params) + printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") + + case "display-message": + let printOnly = commandArgs.contains("-p") || commandArgs.contains("--print") + let message = commandArgs + .filter { !$0.hasPrefix("-") } + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !message.isEmpty else { + throw CLIError(message: "display-message requires text") + } + if printOnly { + print(message) + return + } + let payload = try client.sendV2(method: "notification.create", params: ["title": "cmux", "body": message]) + if jsonOutput { + print(jsonString(payload)) + } else { + print(message) + } + + default: + throw CLIError(message: "Unsupported tmux compatibility command: \(command)") + } + } + private func runClaudeHook(commandArgs: [String], client: SocketClient) throws { let subcommand = commandArgs.first?.lowercased() ?? "help" let hookArgs = Array(commandArgs.dropFirst()) @@ -3454,7 +4040,10 @@ struct CMUXCLI { focus-panel --panel <id|ref> [--workspace <id|ref>] close-workspace --workspace <id|ref> select-workspace --workspace <id|ref> + rename-workspace [--workspace <id|ref>] <title> + rename-window [--workspace <id|ref>] <title> current-workspace + read-screen [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>] send [--workspace <id|ref>] [--surface <id|ref>] <text> send-key [--workspace <id|ref>] [--surface <id|ref>] <key> send-panel --panel <id|ref> [--workspace <id|ref>] <text> @@ -3466,6 +4055,27 @@ struct CMUXCLI { set-app-focus <active|inactive|clear> simulate-app-active + # tmux compatibility commands + capture-pane [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>] + resize-pane --pane <id|ref> [--workspace <id|ref>] (-L|-R|-U|-D) [--amount <n>] + pipe-pane --command <shell-command> [--workspace <id|ref>] [--surface <id|ref>] + wait-for [-S|--signal] <name> [--timeout <seconds>] + swap-pane --pane <id|ref> --target-pane <id|ref> [--workspace <id|ref>] + break-pane [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus] + join-pane --target-pane <id|ref> [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus] + next-window | previous-window | last-window + last-pane [--workspace <id|ref>] + find-window [--content] [--select] <query> + clear-history [--workspace <id|ref>] [--surface <id|ref>] + set-hook [--list] [--unset <event>] | <event> <command> + popup + bind-key | unbind-key | copy-mode + set-buffer [--name <name>] <text> + list-buffers + paste-buffer [--name <name>] [--workspace <id|ref>] [--surface <id|ref>] + respawn-pane [--workspace <id|ref>] [--surface <id|ref>] [--command <cmd>] + display-message [-p|--print] <text> + browser [--surface <id|ref|index> | <surface>] <subcommand> ... browser open [url] (create browser split in caller's workspace; if surface supplied, behaves like navigate) browser open-split [url] diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 1c5e71ea..7f675483 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; }; A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; }; A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; }; + A5001534 /* BrowserWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001533 /* BrowserWindowPortal.swift */; }; A5001540 /* PortScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001541 /* PortScanner.swift */; }; A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; @@ -137,6 +138,7 @@ A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = "<group>"; }; A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = "<group>"; }; A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = "<group>"; }; + A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = "<group>"; }; A5001541 /* PortScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanner.swift; sourceTree = "<group>"; }; A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; }; A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; }; @@ -313,6 +315,7 @@ A5001014 /* GhosttyConfig.swift */, A5001015 /* GhosttyTerminalView.swift */, A5001531 /* TerminalWindowPortal.swift */, + A5001533 /* BrowserWindowPortal.swift */, A5001019 /* TerminalController.swift */, A5001541 /* PortScanner.swift */, A5001225 /* SocketControlSettings.swift */, @@ -541,6 +544,7 @@ A5001004 /* GhosttyConfig.swift in Sources */, A5001005 /* GhosttyTerminalView.swift in Sources */, A5001532 /* TerminalWindowPortal.swift in Sources */, + A5001534 /* BrowserWindowPortal.swift in Sources */, A5001007 /* TerminalController.swift in Sources */, A5001540 /* PortScanner.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */, diff --git a/README.md b/README.md index 51688431..08c4582a 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,8 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta ### Browser +Browser developer-tool shortcuts follow Safari defaults and are customizable in `Settings → Keyboard Shortcuts`. + | Shortcut | Action | |----------|--------| | ⌘ ⇧ L | Open browser in split | @@ -143,7 +145,8 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta | ⌘ [ | Back | | ⌘ ] | Forward | | ⌘ R | Reload page | -| ⌥ ⌘ I | Open Developer Tools | +| ⌥ ⌘ I | Toggle Developer Tools (Safari default) | +| ⌥ ⌘ C | Show JavaScript Console (Safari default) | ### Notifications @@ -186,6 +189,14 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta cmux NIGHTLY is a separate app with its own bundle ID, so it runs alongside the stable version. Built automatically from the latest `main` commit and auto-updates via its own Sparkle feed. +## Community + +- [Discord](https://discord.com/invite/QRxkhZgY) +- [GitHub](https://github.com/manaflow-ai/cmux) +- [X / Twitter](https://twitter.com/manaflowai) +- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw) +- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/) + ## License This project is licensed under the GNU Affero General Public License v3.0 or later (`AGPL-3.0-or-later`). diff --git a/Resources/Info.plist b/Resources/Info.plist index da978c67..8e323ec1 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -92,6 +92,11 @@ </array> </dict> </array> + <key>NSAppTransportSecurity</key> + <dict> + <key>NSAllowsArbitraryLoadsInWebContent</key> + <true/> + </dict> <key>SUFeedURL</key> <string>https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml</string> <key>SUPublicEDKey</key> diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 8ad8d2fa..1e110f91 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -88,9 +88,16 @@ _cmux_prompt_command() { # Git branch/dirty can change without a directory change (e.g. `git checkout`), # so update on every prompt (still async + de-duped by the running-job check). + # When pwd changes (cd into a different repo), kill the old probe and start fresh + # so the sidebar picks up the new branch immediately. if [[ -n "$_CMUX_GIT_JOB_PID" ]] && kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then - : - else + if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]]; then + kill "$_CMUX_GIT_JOB_PID" >/dev/null 2>&1 || true + _CMUX_GIT_JOB_PID="" + fi + fi + + if [[ -z "$_CMUX_GIT_JOB_PID" ]] || ! kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then _CMUX_GIT_LAST_PWD="$pwd" _CMUX_GIT_LAST_RUN=$now { diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 802e3cb3..3b5d00cc 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -139,11 +139,12 @@ _cmux_preexec() { _CMUX_CMD_START=$EPOCHSECONDS - # Heuristic: git commands can change branch/dirty state without changing $PWD. + # Heuristic: commands that may change git branch/dirty state without changing $PWD. local cmd="${1## }" - if [[ "$cmd" == git\ * || "$cmd" == git ]]; then - _CMUX_GIT_FORCE=1 - fi + case "$cmd" in + git\ *|git|gh\ *|lazygit|lazygit\ *|tig|tig\ *|gitui|gitui\ *|stg\ *|jj\ *) + _CMUX_GIT_FORCE=1 ;; + esac # Register TTY + kick batched port scan for foreground commands (servers). _cmux_report_tty_once @@ -196,6 +197,9 @@ _cmux_precmd() { head_mtime="$(_cmux_git_head_mtime "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || echo 0)" if [[ -n "$head_mtime" && "$head_mtime" != 0 && "$head_mtime" != "$_CMUX_GIT_HEAD_MTIME" ]]; then _CMUX_GIT_HEAD_MTIME="$head_mtime" + # 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 should_git=1 fi fi diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d353ee7d..408123f4 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -96,6 +96,13 @@ func browserOmnibarSelectionDeltaForArrowNavigation( } } +func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + return normalizedFlags == [] || normalizedFlags == [.shift] +} + enum BrowserZoomShortcutAction: Equatable { case zoomIn case zoomOut @@ -187,6 +194,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var workspaceObserver: NSObjectProtocol? private var windowKeyObserver: NSObjectProtocol? private var shortcutMonitor: Any? + private var shortcutDefaultsObserver: NSObjectProtocol? private var ghosttyConfigObserver: NSObjectProtocol? private var ghosttyGotoSplitLeftShortcut: StoredShortcut? private var ghosttyGotoSplitRightShortcut: StoredShortcut? @@ -336,6 +344,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent installWindowKeyEquivalentSwizzle() installBrowserAddressBarFocusObservers() installShortcutMonitor() + installShortcutDefaultsObserver() NSApp.servicesProvider = self #if DEBUG UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) @@ -694,7 +703,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 460, height: 360), - styleMask: [.titled, .closable, .miniaturizable, .resizable], + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false ) @@ -1442,6 +1451,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" dlog("monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")") + if let probeKind = self.developerToolsShortcutProbeKind(event: event) { + self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event) + } #endif if self.handleCustomShortcut(event: event) { #if DEBUG @@ -1460,6 +1472,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func installShortcutDefaultsObserver() { + guard shortcutDefaultsObserver == nil else { return } + shortcutDefaultsObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshSplitButtonTooltipsAcrossWorkspaces() + } + } + + private func refreshSplitButtonTooltipsAcrossWorkspaces() { + var refreshedManagers: Set<ObjectIdentifier> = [] + if let manager = tabManager { + manager.refreshSplitButtonTooltips() + refreshedManagers.insert(ObjectIdentifier(manager)) + } + for context in mainWindowContexts.values { + let manager = context.tabManager + let identifier = ObjectIdentifier(manager) + guard refreshedManagers.insert(identifier).inserted else { continue } + manager.refreshSplitButtonTooltips() + } + } + private func installGhosttyConfigObserver() { guard ghosttyConfigObserver == nil else { return } ghosttyConfigObserver = NotificationCenter.default.addObserver( @@ -1861,6 +1898,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserRight)) { + _ = performBrowserSplitShortcut(direction: .right) + return true + } + + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserDown)) { + _ = performBrowserSplitShortcut(direction: .down) + return true + } + // Surface navigation (legacy Ctrl+Tab support) if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: false, option: false, control: true)) { tabManager?.selectNextSurface() @@ -1885,6 +1932,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + // Safari defaults: + // - Option+Command+I => Show/Toggle Web Inspector + // - Option+Command+C => Show JavaScript Console + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) { +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "toggle.pre", event: event) +#endif + let didHandle = tabManager?.toggleDeveloperToolsFocusedBrowser() ?? false +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "toggle.post", event: event, didHandle: didHandle) + DispatchQueue.main.async { [weak self] in + self?.logDeveloperToolsShortcutSnapshot(phase: "toggle.tick", didHandle: didHandle) + } +#endif + if !didHandle { NSSound.beep() } + return true + } + + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) { +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "console.pre", event: event) +#endif + let didHandle = tabManager?.showJavaScriptConsoleFocusedBrowser() ?? false +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "console.post", event: event, didHandle: didHandle) + DispatchQueue.main.async { [weak self] in + self?.logDeveloperToolsShortcutSnapshot(phase: "console.tick", didHandle: didHandle) + } +#endif + if !didHandle { NSSound.beep() } + return true + } + // Focus browser address bar: Cmd+L if flags == [.command] && chars == "l" { if let focusedPanel = tabManager?.focusedBrowserPanel { @@ -2032,15 +2112,174 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func isLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + let responderType = String(describing: type(of: responder)) + if responderType.contains("WKInspector") { + return true + } + guard let view = responder as? NSView else { return false } + var node: NSView? = view + var hops = 0 + while let current = node, hops < 64 { + if String(describing: type(of: current)).contains("WKInspector") { + return true + } + node = current.superview + hops += 1 + } + return false + } + +#if DEBUG + private func developerToolsShortcutProbeKind(event: NSEvent) -> String? { + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) { + return "toggle.configured" + } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) { + return "console.configured" + } + + let chars = (event.charactersIgnoringModifiers ?? "").lowercased() + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags == [.command, .option] { + if chars == "i" || event.keyCode == 34 { + return "toggle.literal" + } + if chars == "c" || event.keyCode == 8 { + return "console.literal" + } + } + return nil + } + + private func logDeveloperToolsShortcutSnapshot( + phase: String, + event: NSEvent? = nil, + didHandle: Bool? = nil + ) { + let keyWindow = NSApp.keyWindow + let firstResponder = keyWindow?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let eventDescription = event.map(NSWindow.keyDescription) ?? "none" + if let browser = tabManager?.focusedBrowserPanel { + var line = + "browser.devtools shortcut=\(phase) panel=\(browser.id.uuidString.prefix(5)) " + + "\(browser.debugDeveloperToolsStateSummary()) \(browser.debugDeveloperToolsGeometrySummary()) " + + "keyWin=\(keyWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)" + if let didHandle { + line += " handled=\(didHandle ? 1 : 0)" + } + dlog(line) + return + } + var line = + "browser.devtools shortcut=\(phase) panel=nil keyWin=\(keyWindow?.windowNumber ?? -1) " + + "fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)" + if let didHandle { + line += " handled=\(didHandle ? 1 : 0)" + } + dlog(line) + } +#endif + + private func prepareFocusedBrowserDevToolsForSplit(directionLabel: String) { + guard let browser = tabManager?.focusedBrowserPanel else { return } + guard browser.shouldPreserveWebViewAttachmentDuringTransientHide() else { return } + guard let keyWindow = NSApp.keyWindow else { return } + guard isLikelyWebInspectorResponder(keyWindow.firstResponder) else { return } + + let beforeResponder = keyWindow.firstResponder + let movedToWebView = keyWindow.makeFirstResponder(browser.webView) + let movedToNil = movedToWebView ? false : keyWindow.makeFirstResponder(nil) + + #if DEBUG + let beforeType = beforeResponder.map { String(describing: type(of: $0)) } ?? "nil" + let beforePtr = beforeResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let afterResponder = keyWindow.firstResponder + let afterType = afterResponder.map { String(describing: type(of: $0)) } ?? "nil" + let afterPtr = afterResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + dlog( + "split.shortcut inspector.preflight dir=\(directionLabel) panel=\(browser.id.uuidString.prefix(5)) " + + "before=\(beforeType)@\(beforePtr) after=\(afterType)@\(afterPtr) " + + "moveWeb=\(movedToWebView ? 1 : 0) moveNil=\(movedToNil ? 1 : 0) \(browser.debugDeveloperToolsStateSummary())" + ) + #endif + } + @discardableResult func performSplitShortcut(direction: SplitDirection) -> Bool { + let directionLabel: String + switch direction { + case .left: directionLabel = "left" + case .right: directionLabel = "right" + case .up: directionLabel = "up" + case .down: directionLabel = "down" + } + + #if DEBUG + let keyWindow = NSApp.keyWindow + let firstResponder = keyWindow?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let firstResponderWindow: Int = { + if let v = firstResponder as? NSView { + return v.window?.windowNumber ?? -1 + } + if let w = firstResponder as? NSWindow { + return w.windowNumber + } + return -1 + }() + let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)" + if let browser = tabManager?.focusedBrowserPanel { + let webWindow = browser.webView.window?.windowNumber ?? -1 + let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") + } else { + dlog("split.shortcut dir=\(directionLabel) pre panel=nil \(splitContext)") + } + #endif + + prepareFocusedBrowserDevToolsForSplit(directionLabel: directionLabel) tabManager?.createSplit(direction: direction) #if DEBUG + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in + let keyWindow = NSApp.keyWindow + let firstResponder = keyWindow?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let firstResponderWindow: Int = { + if let v = firstResponder as? NSView { + return v.window?.windowNumber ?? -1 + } + if let w = firstResponder as? NSWindow { + return w.windowNumber + } + return -1 + }() + let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)" + if let browser = self?.tabManager?.focusedBrowserPanel { + let webWindow = browser.webView.window?.windowNumber ?? -1 + let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") + } else { + dlog("split.shortcut dir=\(directionLabel) post panel=nil \(splitContext)") + } + } recordGotoSplitSplitIfNeeded(direction: direction) #endif return true } + @discardableResult + func performBrowserSplitShortcut(direction: SplitDirection) -> Bool { + guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { return false } + _ = focusBrowserAddressBar(panelId: panelId) + return true + } + /// Allow AppKit-backed browser surfaces (WKWebView) to route non-menu shortcuts /// through the same app-level shortcut handler used by the local key monitor. @discardableResult diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift new file mode 100644 index 00000000..4578fdcc --- /dev/null +++ b/Sources/BrowserWindowPortal.swift @@ -0,0 +1,886 @@ +import AppKit +import ObjectiveC +import WebKit +#if DEBUG +import Bonsplit +#endif + +private var cmuxWindowBrowserPortalKey: UInt8 = 0 +private var cmuxWindowBrowserPortalCloseObserverKey: UInt8 = 0 + +#if DEBUG +private func browserPortalDebugToken(_ view: NSView?) -> String { + guard let view else { return "nil" } + let ptr = Unmanaged.passUnretained(view).toOpaque() + return String(describing: ptr) +} + +private func browserPortalDebugFrame(_ rect: NSRect) -> String { + String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height) +} +#endif + +final class WindowBrowserHostView: NSView { + override var isOpaque: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + if shouldPassThroughToSplitDivider(at: point) { + return nil + } + let hitView = super.hitTest(point) + return hitView === self ? nil : hitView + } + + private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool { + guard let window else { return false } + let windowPoint = convert(point, to: nil) + guard let rootView = window.contentView else { return false } + return Self.containsSplitDivider(at: windowPoint, in: rootView) + } + + private static func containsSplitDivider(at windowPoint: NSPoint, in view: NSView) -> Bool { + guard !view.isHidden else { return false } + + if let splitView = view as? NSSplitView { + let pointInSplit = splitView.convert(windowPoint, from: nil) + if splitView.bounds.contains(pointInSplit) { + let expansion: CGFloat = 5 + let dividerCount = max(0, splitView.arrangedSubviews.count - 1) + for dividerIndex in 0..<dividerCount { + let first = splitView.arrangedSubviews[dividerIndex].frame + let second = splitView.arrangedSubviews[dividerIndex + 1].frame + let thickness = splitView.dividerThickness + let dividerRect: NSRect + if splitView.isVertical { + guard first.width > 1, second.width > 1 else { continue } + let x = max(0, first.maxX) + dividerRect = NSRect( + x: x, + y: 0, + width: thickness, + height: splitView.bounds.height + ) + } else { + guard first.height > 1, second.height > 1 else { continue } + let y = max(0, first.maxY) + dividerRect = NSRect( + x: 0, + y: y, + width: splitView.bounds.width, + height: thickness + ) + } + let expanded = dividerRect.insetBy(dx: -expansion, dy: -expansion) + if expanded.contains(pointInSplit) { + return true + } + } + } + } + + for subview in view.subviews.reversed() { + if containsSplitDivider(at: windowPoint, in: subview) { + return true + } + } + + return false + } +} + +final class WindowBrowserSlotView: NSView { + override var isOpaque: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.masksToBounds = true + translatesAutoresizingMaskIntoConstraints = true + autoresizingMask = [] + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + nil + } +} + +@MainActor +final class WindowBrowserPortal: NSObject { + private weak var window: NSWindow? + private let hostView = WindowBrowserHostView(frame: .zero) + private weak var installedContainerView: NSView? + private weak var installedReferenceView: NSView? + private var hasDeferredFullSyncScheduled = false + + private struct Entry { + weak var webView: WKWebView? + weak var containerView: WindowBrowserSlotView? + weak var anchorView: NSView? + var visibleInUI: Bool + var zPriority: Int + } + + private var entriesByWebViewId: [ObjectIdentifier: Entry] = [:] + private var webViewByAnchorId: [ObjectIdentifier: ObjectIdentifier] = [:] + + init(window: NSWindow) { + self.window = window + super.init() + hostView.wantsLayer = true + hostView.layer?.masksToBounds = true + hostView.translatesAutoresizingMaskIntoConstraints = true + hostView.autoresizingMask = [] + _ = ensureInstalled() + } + + @discardableResult + private func ensureInstalled() -> Bool { + guard let window else { return false } + guard let (container, reference) = installationTarget(for: window) else { return false } + + if hostView.superview !== container || + installedContainerView !== container || + installedReferenceView !== reference { + hostView.removeFromSuperview() + container.addSubview(hostView, positioned: .above, relativeTo: reference) + installedContainerView = container + installedReferenceView = reference + } else if !Self.isView(hostView, above: reference, in: container) { + container.addSubview(hostView, positioned: .above, relativeTo: reference) + } + + synchronizeHostFrameToReference() + return true + } + + @discardableResult + private func synchronizeHostFrameToReference() -> Bool { + guard let container = installedContainerView, + let reference = installedReferenceView else { + return false + } + let frameInContainer = container.convert(reference.bounds, from: reference) + let hasFiniteFrame = + frameInContainer.origin.x.isFinite && + frameInContainer.origin.y.isFinite && + frameInContainer.size.width.isFinite && + frameInContainer.size.height.isFinite + guard hasFiniteFrame else { return false } + + if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostView.frame = frameInContainer + CATransaction.commit() +#if DEBUG + dlog( + "browser.portal.hostFrame.update host=\(browserPortalDebugToken(hostView)) " + + "frame=\(browserPortalDebugFrame(frameInContainer))" + ) +#endif + } + return frameInContainer.width > 1 && frameInContainer.height > 1 + } + + private func installationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? { + guard let contentView = window.contentView else { return nil } + + if contentView.className == "NSGlassEffectView", + let foreground = contentView.subviews.first(where: { $0 !== hostView }) { + return (contentView, foreground) + } + + guard let themeFrame = contentView.superview else { return nil } + return (themeFrame, contentView) + } + + private static func isHiddenOrAncestorHidden(_ view: NSView) -> Bool { + if view.isHidden { return true } + var current = view.superview + while let v = current { + if v.isHidden { return true } + current = v.superview + } + return false + } + + private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool { + abs(lhs.origin.x - rhs.origin.x) <= epsilon && + abs(lhs.origin.y - rhs.origin.y) <= epsilon && + abs(lhs.size.width - rhs.size.width) <= epsilon && + abs(lhs.size.height - rhs.size.height) <= epsilon + } + + private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool { + frame.minX < bounds.minX - epsilon || + frame.minY < bounds.minY - epsilon || + frame.maxX > bounds.maxX + epsilon || + frame.maxY > bounds.maxY + epsilon + } + +#if DEBUG + private static func inspectorSubviewCount(in root: NSView) -> Int { + var stack: [NSView] = [root] + var count = 0 + while let current = stack.popLast() { + for subview in current.subviews { + if String(describing: type(of: subview)).contains("WKInspector") { + count += 1 + } + stack.append(subview) + } + } + return count + } +#endif + + private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool { + guard let viewIndex = container.subviews.firstIndex(of: view), + let referenceIndex = container.subviews.firstIndex(of: reference) else { + return false + } + return viewIndex > referenceIndex + } + + private func ensureContainerView(for entry: Entry, webView: WKWebView) -> WindowBrowserSlotView { + if let existing = entry.containerView { + return existing + } + let created = WindowBrowserSlotView(frame: .zero) +#if DEBUG + dlog( + "browser.portal.container.create web=\(browserPortalDebugToken(webView)) " + + "container=\(browserPortalDebugToken(created))" + ) +#endif + return created + } + + private func moveWebKitRelatedSubviewsIfNeeded( + from sourceSuperview: NSView, + to containerView: WindowBrowserSlotView, + primaryWebView: WKWebView, + reason: String + ) { + guard sourceSuperview !== containerView else { return } + // When Web Inspector is docked, WebKit can inject companion WK* subviews + // next to the primary WKWebView. Move those with the web view so inspector + // UI state does not get orphaned in the old host during split churn. + let relatedSubviews = sourceSuperview.subviews.filter { view in + if view === primaryWebView { return true } + return String(describing: type(of: view)).contains("WK") + } + guard !relatedSubviews.isEmpty else { return } +#if DEBUG + dlog( + "browser.portal.reparent.batch reason=\(reason) source=\(browserPortalDebugToken(sourceSuperview)) " + + "container=\(browserPortalDebugToken(containerView)) count=\(relatedSubviews.count) " + + "sourceType=\(String(describing: type(of: sourceSuperview))) targetType=\(String(describing: type(of: containerView))) " + + "sourceFlipped=\(sourceSuperview.isFlipped ? 1 : 0) targetFlipped=\(containerView.isFlipped ? 1 : 0) " + + "sourceBounds=\(browserPortalDebugFrame(sourceSuperview.bounds)) targetBounds=\(browserPortalDebugFrame(containerView.bounds))" + ) +#endif + for view in relatedSubviews { + let frameInWindow = sourceSuperview.convert(view.frame, to: nil) + let className = String(describing: type(of: view)) + view.removeFromSuperview() + containerView.addSubview(view, positioned: .above, relativeTo: nil) + let convertedFrame = containerView.convert(frameInWindow, from: nil) + view.frame = convertedFrame +#if DEBUG + dlog( + "browser.portal.reparent.batch.item reason=\(reason) class=\(className) " + + "view=\(browserPortalDebugToken(view)) frameInWindow=\(browserPortalDebugFrame(frameInWindow)) " + + "converted=\(browserPortalDebugFrame(convertedFrame))" + ) +#endif + } + } + + func detachWebView(withId webViewId: ObjectIdentifier) { + guard let entry = entriesByWebViewId.removeValue(forKey: webViewId) else { return } + if let anchor = entry.anchorView { + webViewByAnchorId.removeValue(forKey: ObjectIdentifier(anchor)) + } +#if DEBUG + let hadContainerSuperview = (entry.containerView?.superview === hostView) ? 1 : 0 + let hadWebSuperview = entry.webView?.superview == nil ? 0 : 1 + dlog( + "browser.portal.detach web=\(browserPortalDebugToken(entry.webView)) " + + "container=\(browserPortalDebugToken(entry.containerView)) " + + "anchor=\(browserPortalDebugToken(entry.anchorView)) " + + "hadContainerSuperview=\(hadContainerSuperview) hadWebSuperview=\(hadWebSuperview)" + ) +#endif + entry.webView?.removeFromSuperview() + entry.containerView?.removeFromSuperview() + } + + /// Update the visibleInUI/zPriority state on an existing entry without rebinding. + /// Used when a bind is deferred (host not yet in window) so stale portal syncs + /// do not keep an old anchor visible. + func updateEntryVisibility(forWebViewId webViewId: ObjectIdentifier, visibleInUI: Bool, zPriority: Int) { + guard var entry = entriesByWebViewId[webViewId] else { return } + entry.visibleInUI = visibleInUI + entry.zPriority = zPriority + entriesByWebViewId[webViewId] = entry + } + + func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { + guard ensureInstalled() else { return } + + let webViewId = ObjectIdentifier(webView) + let anchorId = ObjectIdentifier(anchorView) + let previousEntry = entriesByWebViewId[webViewId] + let containerView = ensureContainerView( + for: previousEntry ?? Entry(webView: nil, containerView: nil, anchorView: nil, visibleInUI: false, zPriority: 0), + webView: webView + ) + + if let previousWebViewId = webViewByAnchorId[anchorId], previousWebViewId != webViewId { +#if DEBUG + let previousToken = entriesByWebViewId[previousWebViewId] + .map { browserPortalDebugToken($0.webView) } + ?? String(describing: previousWebViewId) + dlog( + "browser.portal.bind.replace anchor=\(browserPortalDebugToken(anchorView)) " + + "oldWeb=\(previousToken) newWeb=\(browserPortalDebugToken(webView))" + ) +#endif + detachWebView(withId: previousWebViewId) + } + + if let oldEntry = entriesByWebViewId[webViewId], + let oldAnchor = oldEntry.anchorView, + oldAnchor !== anchorView { + webViewByAnchorId.removeValue(forKey: ObjectIdentifier(oldAnchor)) + } + + webViewByAnchorId[anchorId] = webViewId + entriesByWebViewId[webViewId] = Entry( + webView: webView, + containerView: containerView, + anchorView: anchorView, + visibleInUI: visibleInUI, + zPriority: zPriority + ) + + let didChangeAnchor: Bool = { + guard let previousAnchor = previousEntry?.anchorView else { return true } + return previousAnchor !== anchorView + }() + let becameVisible = (previousEntry?.visibleInUI ?? false) == false && visibleInUI + let priorityIncreased = zPriority > (previousEntry?.zPriority ?? Int.min) +#if DEBUG + if previousEntry == nil || + didChangeAnchor || + becameVisible || + priorityIncreased || + webView.superview !== containerView || + containerView.superview !== hostView { + dlog( + "browser.portal.bind web=\(browserPortalDebugToken(webView)) " + + "container=\(browserPortalDebugToken(containerView)) " + + "anchor=\(browserPortalDebugToken(anchorView)) prevAnchor=\(browserPortalDebugToken(previousEntry?.anchorView)) " + + "visible=\(visibleInUI ? 1 : 0) prevVisible=\((previousEntry?.visibleInUI ?? false) ? 1 : 0) " + + "z=\(zPriority) prevZ=\(previousEntry?.zPriority ?? Int.min)" + ) + } +#endif + + if webView.superview !== containerView { +#if DEBUG + dlog( + "browser.portal.reparent web=\(browserPortalDebugToken(webView)) " + + "reason=attachContainer super=\(browserPortalDebugToken(webView.superview)) " + + "container=\(browserPortalDebugToken(containerView))" + ) +#endif + if let sourceSuperview = webView.superview { + moveWebKitRelatedSubviewsIfNeeded( + from: sourceSuperview, + to: containerView, + primaryWebView: webView, + reason: "bind.attachContainer" + ) + } else { + containerView.addSubview(webView, positioned: .above, relativeTo: nil) + } + webView.translatesAutoresizingMaskIntoConstraints = true + webView.autoresizingMask = [.width, .height] + webView.frame = containerView.bounds + webView.needsLayout = true + webView.layoutSubtreeIfNeeded() + } + + if containerView.superview !== hostView { +#if DEBUG + dlog( + "browser.portal.reparent container=\(browserPortalDebugToken(containerView)) " + + "reason=attach super=\(browserPortalDebugToken(containerView.superview))" + ) +#endif + hostView.addSubview(containerView, positioned: .above, relativeTo: nil) + } else if (becameVisible || priorityIncreased), hostView.subviews.last !== containerView { +#if DEBUG + dlog( + "browser.portal.reparent container=\(browserPortalDebugToken(containerView)) reason=raise " + + "didChangeAnchor=\(didChangeAnchor ? 1 : 0) becameVisible=\(becameVisible ? 1 : 0) " + + "priorityIncreased=\(priorityIncreased ? 1 : 0)" + ) +#endif + hostView.addSubview(containerView, positioned: .above, relativeTo: nil) + } + + synchronizeWebView(withId: webViewId, source: "bind") + pruneDeadEntries() + } + + func synchronizeWebViewForAnchor(_ anchorView: NSView) { + pruneDeadEntries() + let anchorId = ObjectIdentifier(anchorView) + let primaryWebViewId = webViewByAnchorId[anchorId] + if let primaryWebViewId { + synchronizeWebView(withId: primaryWebViewId, source: "anchorPrimary") + } + + synchronizeAllWebViews(excluding: primaryWebViewId, source: "anchorSecondary") + scheduleDeferredFullSynchronizeAll() + } + + private func scheduleDeferredFullSynchronizeAll() { + guard !hasDeferredFullSyncScheduled else { return } + hasDeferredFullSyncScheduled = true +#if DEBUG + dlog("browser.portal.sync.defer.schedule entries=\(entriesByWebViewId.count)") +#endif + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.hasDeferredFullSyncScheduled = false +#if DEBUG + dlog("browser.portal.sync.defer.tick entries=\(self.entriesByWebViewId.count)") +#endif + self.synchronizeAllWebViews(excluding: nil, source: "deferredTick") + } + } + + private func synchronizeAllWebViews(excluding webViewIdToSkip: ObjectIdentifier?, source: String) { + guard ensureInstalled() else { return } + pruneDeadEntries() + let webViewIds = Array(entriesByWebViewId.keys) + for webViewId in webViewIds { + if webViewId == webViewIdToSkip { continue } + synchronizeWebView(withId: webViewId, source: source) + } + } + + private func synchronizeWebView(withId webViewId: ObjectIdentifier, source: String) { + guard ensureInstalled() else { return } + guard let entry = entriesByWebViewId[webViewId] else { return } + guard let webView = entry.webView else { + entriesByWebViewId.removeValue(forKey: webViewId) + return + } + guard let containerView = entry.containerView else { + entriesByWebViewId.removeValue(forKey: webViewId) + if let anchor = entry.anchorView { + webViewByAnchorId.removeValue(forKey: ObjectIdentifier(anchor)) + } + return + } + guard let anchorView = entry.anchorView, let window else { +#if DEBUG + if !containerView.isHidden { + dlog( + "browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) value=1 reason=missingAnchorOrWindow" + ) + } +#endif + containerView.isHidden = true + return + } + guard anchorView.window === window else { +#if DEBUG + if !containerView.isHidden { + dlog( + "browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) value=1 " + + "reason=anchorWindowMismatch anchorWindow=\(browserPortalDebugToken(anchorView.window?.contentView))" + ) + } +#endif + containerView.isHidden = true + return + } + + if containerView.superview !== hostView { +#if DEBUG + dlog( + "browser.portal.reparent container=\(browserPortalDebugToken(containerView)) " + + "reason=syncAttach super=\(browserPortalDebugToken(containerView.superview))" + ) +#endif + hostView.addSubview(containerView, positioned: .above, relativeTo: nil) + } + if webView.superview !== containerView { +#if DEBUG + dlog( + "browser.portal.reparent web=\(browserPortalDebugToken(webView)) " + + "reason=syncAttachContainer super=\(browserPortalDebugToken(webView.superview)) " + + "container=\(browserPortalDebugToken(containerView))" + ) +#endif + if let sourceSuperview = webView.superview { + moveWebKitRelatedSubviewsIfNeeded( + from: sourceSuperview, + to: containerView, + primaryWebView: webView, + reason: "sync.attachContainer" + ) + } else { + containerView.addSubview(webView, positioned: .above, relativeTo: nil) + } + webView.translatesAutoresizingMaskIntoConstraints = true + webView.autoresizingMask = [.width, .height] + webView.frame = containerView.bounds + webView.needsLayout = true + webView.layoutSubtreeIfNeeded() + } + + _ = synchronizeHostFrameToReference() + let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) + let frameInHost = hostView.convert(frameInWindow, from: nil) + let hostBounds = hostView.bounds + let hasFiniteHostBounds = + hostBounds.origin.x.isFinite && + hostBounds.origin.y.isFinite && + hostBounds.size.width.isFinite && + hostBounds.size.height.isFinite + let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1 + if !hostBoundsReady { +#if DEBUG + dlog( + "browser.portal.sync.defer container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) " + + "reason=hostBoundsNotReady host=\(browserPortalDebugFrame(hostBounds)) " + + "anchor=\(browserPortalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)" + ) +#endif + containerView.isHidden = true + scheduleDeferredFullSynchronizeAll() + return + } + let oldFrame = containerView.frame + let hasFiniteFrame = + frameInHost.origin.x.isFinite && + frameInHost.origin.y.isFinite && + frameInHost.size.width.isFinite && + frameInHost.size.height.isFinite + let clampedFrame = frameInHost.intersection(hostBounds) + let hasVisibleIntersection = + !clampedFrame.isNull && + clampedFrame.width > 1 && + clampedFrame.height > 1 + let targetFrame = hasVisibleIntersection ? clampedFrame : frameInHost + let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView) + let tinyFrame = targetFrame.width <= 1 || targetFrame.height <= 1 + let outsideHostBounds = !hasVisibleIntersection + let shouldHide = + !entry.visibleInUI || + anchorHidden || + tinyFrame || + !hasFiniteFrame || + outsideHostBounds +#if DEBUG + let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame) + if frameWasClamped { + dlog( + "browser.portal.frame.clamp container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " + + "raw=\(browserPortalDebugFrame(frameInHost)) clamped=\(browserPortalDebugFrame(targetFrame)) " + + "host=\(browserPortalDebugFrame(hostBounds))" + ) + } + let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame + let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame + if collapsedToTiny { + dlog( + "browser.portal.frame.collapse container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " + + "old=\(browserPortalDebugFrame(oldFrame)) new=\(browserPortalDebugFrame(targetFrame))" + ) + } else if restoredFromTiny { + dlog( + "browser.portal.frame.restore container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " + + "old=\(browserPortalDebugFrame(oldFrame)) new=\(browserPortalDebugFrame(targetFrame))" + ) + } +#endif + if !Self.rectApproximatelyEqual(oldFrame, targetFrame) { + CATransaction.begin() + CATransaction.setDisableActions(true) + containerView.frame = targetFrame + CATransaction.commit() + } + + let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size) + if !Self.rectApproximatelyEqual(containerView.bounds, expectedContainerBounds) { + let oldContainerBounds = containerView.bounds + CATransaction.begin() + CATransaction.setDisableActions(true) + containerView.bounds = expectedContainerBounds + CATransaction.commit() +#if DEBUG + dlog( + "browser.portal.bounds.normalize container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) old=\(browserPortalDebugFrame(oldContainerBounds)) " + + "target=\(browserPortalDebugFrame(expectedContainerBounds))" + ) +#endif + } + + let containerBounds = containerView.bounds + let preNormalizeWebFrame = webView.frame + let inspectorHeightFromInsets = max(0, containerBounds.height - preNormalizeWebFrame.height) + let inspectorHeightFromOverflow = max(0, preNormalizeWebFrame.maxY - containerBounds.maxY) + let inspectorHeightApprox = max(inspectorHeightFromInsets, inspectorHeightFromOverflow) +#if DEBUG + let inspectorSubviews = Self.inspectorSubviewCount(in: containerView) +#endif + if Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) { + let oldWebFrame = preNormalizeWebFrame + CATransaction.begin() + CATransaction.setDisableActions(true) + webView.frame = containerBounds + CATransaction.commit() +#if DEBUG + dlog( + "browser.portal.webframe.normalize web=\(browserPortalDebugToken(webView)) " + + "container=\(browserPortalDebugToken(containerView)) old=\(browserPortalDebugFrame(oldWebFrame)) " + + "new=\(browserPortalDebugFrame(webView.frame)) bounds=\(browserPortalDebugFrame(containerBounds)) " + + "inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) " + + "inspectorInsets=\(String(format: "%.1f", inspectorHeightFromInsets)) " + + "inspectorOverflow=\(String(format: "%.1f", inspectorHeightFromOverflow)) " + + "inspectorSubviews=\(inspectorSubviews) " + + "source=\(source)" + ) +#endif + } + + if containerView.isHidden != shouldHide { +#if DEBUG + dlog( + "browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " + + "web=\(browserPortalDebugToken(webView)) value=\(shouldHide ? 1 : 0) " + + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + + "tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + + "outside=\(outsideHostBounds ? 1 : 0) frame=\(browserPortalDebugFrame(targetFrame)) " + + "host=\(browserPortalDebugFrame(hostBounds))" + ) +#endif + containerView.isHidden = shouldHide + } +#if DEBUG + dlog( + "browser.portal.sync.result web=\(browserPortalDebugToken(webView)) source=\(source) " + + "container=\(browserPortalDebugToken(containerView)) " + + "anchor=\(browserPortalDebugToken(anchorView)) host=\(browserPortalDebugToken(hostView)) " + + "hostWin=\(hostView.window?.windowNumber ?? -1) " + + "old=\(browserPortalDebugFrame(oldFrame)) raw=\(browserPortalDebugFrame(frameInHost)) " + + "target=\(browserPortalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " + + "entryVisible=\(entry.visibleInUI ? 1 : 0) " + + "containerHidden=\(containerView.isHidden ? 1 : 0) webHidden=\(webView.isHidden ? 1 : 0) " + + "containerBounds=\(browserPortalDebugFrame(containerView.bounds)) " + + "preWebFrame=\(browserPortalDebugFrame(preNormalizeWebFrame)) " + + "webFrame=\(browserPortalDebugFrame(webView.frame)) webBounds=\(browserPortalDebugFrame(webView.bounds)) " + + "inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) " + + "inspectorInsets=\(String(format: "%.1f", inspectorHeightFromInsets)) " + + "inspectorOverflow=\(String(format: "%.1f", inspectorHeightFromOverflow)) " + + "inspectorSubviews=\(inspectorSubviews)" + ) +#endif + } + + private func pruneDeadEntries() { + let currentWindow = window + let deadWebViewIds = entriesByWebViewId.compactMap { webViewId, entry -> ObjectIdentifier? in + guard entry.webView != nil else { return webViewId } + guard let container = entry.containerView else { return webViewId } + guard let anchor = entry.anchorView else { return webViewId } + if container.superview == nil || !container.isDescendant(of: hostView) { + return webViewId + } + if anchor.window !== currentWindow || anchor.superview == nil { + return webViewId + } + if let reference = installedReferenceView, + !anchor.isDescendant(of: reference) { + return webViewId + } + return nil + } + + for webViewId in deadWebViewIds { + detachWebView(withId: webViewId) + } + + let validAnchorIds = Set(entriesByWebViewId.compactMap { _, entry in + entry.anchorView.map { ObjectIdentifier($0) } + }) + webViewByAnchorId = webViewByAnchorId.filter { validAnchorIds.contains($0.key) } + } + + func webViewIds() -> Set<ObjectIdentifier> { + Set(entriesByWebViewId.keys) + } + + func tearDown() { + for webViewId in Array(entriesByWebViewId.keys) { + detachWebView(withId: webViewId) + } + hostView.removeFromSuperview() + installedContainerView = nil + installedReferenceView = nil + } + +#if DEBUG + func debugEntryCount() -> Int { + entriesByWebViewId.count + } + + func debugHostedSubviewCount() -> Int { + hostView.subviews.count + } +#endif + + func webViewAtWindowPoint(_ windowPoint: NSPoint) -> WKWebView? { + guard ensureInstalled() else { return nil } + let point = hostView.convert(windowPoint, from: nil) + for subview in hostView.subviews.reversed() { + guard let container = subview as? WindowBrowserSlotView else { continue } + guard !container.isHidden else { continue } + guard container.frame.contains(point) else { continue } + guard let webView = entriesByWebViewId + .first(where: { _, entry in entry.containerView === container })? + .value + .webView else { continue } + return webView + } + return nil + } +} + +@MainActor +enum BrowserWindowPortalRegistry { + private static var portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:] + private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] + + private static func installWindowCloseObserverIfNeeded(for window: NSWindow) { + guard objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) == nil else { return } + let windowId = ObjectIdentifier(window) + let observer = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak window] _ in + MainActor.assumeIsolated { + if let window { + removePortal(for: window) + } else { + removePortal(windowId: windowId, window: nil) + } + } + } + objc_setAssociatedObject( + window, + &cmuxWindowBrowserPortalCloseObserverKey, + observer, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + + private static func removePortal(for window: NSWindow) { + removePortal(windowId: ObjectIdentifier(window), window: window) + } + + private static func removePortal(windowId: ObjectIdentifier, window: NSWindow?) { + if let portal = portalsByWindowId.removeValue(forKey: windowId) { + portal.tearDown() + } + webViewToWindowId = webViewToWindowId.filter { $0.value != windowId } + + guard let window else { return } + if let observer = objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) { + NotificationCenter.default.removeObserver(observer) + } + objc_setAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + objc_setAssociatedObject(window, &cmuxWindowBrowserPortalKey, nil, .OBJC_ASSOCIATION_RETAIN) + } + + private static func pruneWebViewMappings(for windowId: ObjectIdentifier, validWebViewIds: Set<ObjectIdentifier>) { + webViewToWindowId = webViewToWindowId.filter { webViewId, mappedWindowId in + mappedWindowId != windowId || validWebViewIds.contains(webViewId) + } + } + + private static func portal(for window: NSWindow) -> WindowBrowserPortal { + if let existing = objc_getAssociatedObject(window, &cmuxWindowBrowserPortalKey) as? WindowBrowserPortal { + portalsByWindowId[ObjectIdentifier(window)] = existing + installWindowCloseObserverIfNeeded(for: window) + return existing + } + + let portal = WindowBrowserPortal(window: window) + objc_setAssociatedObject(window, &cmuxWindowBrowserPortalKey, portal, .OBJC_ASSOCIATION_RETAIN) + portalsByWindowId[ObjectIdentifier(window)] = portal + installWindowCloseObserverIfNeeded(for: window) + return portal + } + + static func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { + guard let window = anchorView.window else { return } + + let windowId = ObjectIdentifier(window) + let webViewId = ObjectIdentifier(webView) + let nextPortal = portal(for: window) + + if let oldWindowId = webViewToWindowId[webViewId], + oldWindowId != windowId { + portalsByWindowId[oldWindowId]?.detachWebView(withId: webViewId) + } + + nextPortal.bind(webView: webView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority) + webViewToWindowId[webViewId] = windowId + pruneWebViewMappings(for: windowId, validWebViewIds: nextPortal.webViewIds()) + } + + static func synchronizeForAnchor(_ anchorView: NSView) { + guard let window = anchorView.window else { return } + let portal = portal(for: window) + portal.synchronizeWebViewForAnchor(anchorView) + } + + /// Update visibleInUI/zPriority on an existing portal entry without rebinding. + /// Called when a bind is deferred because the new host is temporarily off-window. + static func updateEntryVisibility(for webView: WKWebView, visibleInUI: Bool, zPriority: Int) { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId[webViewId], + let portal = portalsByWindowId[windowId] else { return } + portal.updateEntryVisibility(forWebViewId: webViewId, visibleInUI: visibleInUI, zPriority: zPriority) + } + + static func detach(webView: WKWebView) { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return } + portalsByWindowId[windowId]?.detachWebView(withId: webViewId) + } + +#if DEBUG + static func debugPortalCount() -> Int { + portalsByWindowId.count + } +#endif +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index da91ac9e..ec6512bb 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3,6 +3,7 @@ import Bonsplit import SwiftUI import ObjectiveC import UniformTypeIdentifiers +import WebKit struct ShortcutHintPillBackground: View { var emphasis: Double = 1.0 @@ -171,6 +172,10 @@ final class SidebarState: ObservableObject { final class FileDropOverlayView: NSView { /// Fallback handler when no terminal is found under the drop point. var onDrop: (([URL]) -> Bool)? + private var isForwardingMouseEvent = false + /// The WKWebView currently receiving forwarded drag events, so we can + /// synthesize draggingExited/draggingEntered as the cursor moves. + private weak var activeDragWebView: WKWebView? override var acceptsFirstResponder: Bool { false } @@ -207,12 +212,19 @@ final class FileDropOverlayView: NSView { // window.sendEvent(), which caches the mouse target and causes infinite recursion. private func forwardEvent(_ event: NSEvent) { + guard !isForwardingMouseEvent else { return } guard let window, let contentView = window.contentView else { return } + + isForwardingMouseEvent = true isHidden = true + defer { + isHidden = false + isForwardingMouseEvent = false + } + let point = contentView.convert(event.locationInWindow, from: nil) let target = contentView.hitTest(point) - isHidden = false - guard let target else { return } + guard let target, target !== self else { return } switch event.type { case .leftMouseDown: target.mouseDown(with: event) @@ -240,30 +252,82 @@ final class FileDropOverlayView: NSView { override func otherMouseDragged(with event: NSEvent) { forwardEvent(event) } override func scrollWheel(with event: NSEvent) { forwardEvent(event) } - // MARK: NSDraggingDestination – only accept file drops over terminal views. + // MARK: NSDraggingDestination – accept file drops over terminal and browser views. + // + // AppKit sends draggingEntered once when the drag enters this overlay, then + // draggingUpdated as the cursor moves within it. We track which WKWebView (if + // any) is under the cursor and synthesize enter/exit calls so the browser's + // HTML5 drag events (dragenter, dragleave, drop) fire correctly. override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { - return dragOperationForSender(sender) + return updateDragTarget(sender) } override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { - return dragOperationForSender(sender) + return updateDragTarget(sender) + } + + override func draggingExited(_ sender: (any NSDraggingInfo)?) { + if let prev = activeDragWebView { + prev.draggingExited(sender) + activeDragWebView = nil + } } override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + let webView = activeDragWebView + activeDragWebView = nil + if let webView { + return webView.performDragOperation(sender) + } guard let terminal = terminalUnderPoint(sender.draggingLocation) else { return false } return terminal.performDragOperation(sender) } - private func dragOperationForSender(_ sender: any NSDraggingInfo) -> NSDragOperation { + private func updateDragTarget(_ sender: any NSDraggingInfo) -> NSDragOperation { + let loc = sender.draggingLocation + let webView = webViewUnderPoint(loc) + + // Cursor moved away from the previous web view. + if let prev = activeDragWebView, prev !== webView { + prev.draggingExited(sender) + activeDragWebView = nil + } + + if let webView { + if activeDragWebView !== webView { + // Cursor entered a (new) web view — send draggingEntered. + activeDragWebView = webView + return webView.draggingEntered(sender) + } + return webView.draggingUpdated(sender) + } + + // Over a terminal (or nothing). guard let types = sender.draggingPasteboard.types, types.contains(.fileURL), - terminalUnderPoint(sender.draggingLocation) != nil else { + terminalUnderPoint(loc) != nil else { return [] } return .copy } + /// Hit-tests the window to find a WKWebView (browser panel) under the cursor. + private func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? { + guard let window, let contentView = window.contentView else { return nil } + isHidden = true + defer { isHidden = false } + let point = contentView.convert(windowPoint, from: nil) + let hitView = contentView.hitTest(point) + + var current: NSView? = hitView + while let view = current { + if let webView = view as? WKWebView { return webView } + current = view.superview + } + return nil + } + /// Hit-tests the window to find the GhosttyNSView under the cursor. func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { if let window, @@ -273,9 +337,9 @@ final class FileDropOverlayView: NSView { guard let window, let contentView = window.contentView else { return nil } isHidden = true + defer { isHidden = false } let point = contentView.convert(windowPoint, from: nil) let hitView = contentView.hitTest(point) - isHidden = false var current: NSView? = hitView while let view = current { @@ -411,6 +475,7 @@ struct ContentView: View { @State private var workspaceHandoffGeneration: UInt64 = 0 @State private var workspaceHandoffFallbackTask: Task<Void, Never>? @State private var titlebarThemeGeneration: UInt64 = 0 + @State private var sidebarDraggedTabId: UUID? private var sidebarView: some View { VerticalTabsSidebar( @@ -462,7 +527,8 @@ struct ContentView: View { isResizerHovering = true } } - let nextWidth = max(186, min(360, value.location.x - sidebarMinX + sidebarHandleWidth / 2)) + let maxSidebarWidth = (NSApp.keyWindow?.screen?.frame.width ?? NSScreen.main?.frame.width ?? 1920) * 2 / 3 + let nextWidth = max(186, min(maxSidebarWidth, value.location.x - sidebarMinX + sidebarHandleWidth / 2)) withTransaction(Transaction(animation: nil)) { sidebarWidth = nextWidth } @@ -522,6 +588,13 @@ struct ContentView: View { } } + private var terminalContentWithSidebarDropOverlay: some View { + terminalContent + .overlay { + SidebarExternalDropOverlay(draggedTabId: sidebarDraggedTabId) + } + } + @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue // Background glass settings @@ -650,7 +723,7 @@ struct ContentView: View { // Overlay mode: terminal extends full width, sidebar on top // This allows withinWindow blur to see the terminal content ZStack(alignment: .leading) { - terminalContent + terminalContentWithSidebarDropOverlay .padding(.leading, sidebarState.isVisible ? sidebarWidth : 0) if sidebarState.isVisible { sidebarView @@ -662,7 +735,7 @@ struct ContentView: View { if sidebarState.isVisible { sidebarView } - terminalContent + terminalContentWithSidebarDropOverlay } } } @@ -771,6 +844,16 @@ struct ContentView: View { } } } + .onReceive(NotificationCenter.default.publisher(for: SidebarDragLifecycleNotification.stateDidChange)) { notification in + let tabId = SidebarDragLifecycleNotification.tabId(from: notification) + sidebarDraggedTabId = tabId +#if DEBUG + dlog( + "sidebar.dragState.content tab=\(debugShortWorkspaceId(tabId)) " + + "reason=\(SidebarDragLifecycleNotification.reason(from: notification))" + ) +#endif + } .onPreferenceChange(SidebarFramePreferenceKey.self) { frame in sidebarMinX = frame.minX } @@ -1030,6 +1113,7 @@ struct VerticalTabsSidebar: View { @Binding var lastSidebarSelectionIndex: Int? @StateObject private var commandKeyMonitor = SidebarCommandKeyMonitor() @StateObject private var dragAutoScrollController = SidebarDragAutoScrollController() + @StateObject private var dragFailsafeMonitor = SidebarDragFailsafeMonitor() @State private var draggedTabId: UUID? @State private var dropIndicator: SidebarDropIndicator? @@ -1109,22 +1193,63 @@ struct VerticalTabsSidebar: View { .accessibilityIdentifier("Sidebar") .ignoresSafeArea() .background(SidebarBackdrop().ignoresSafeArea()) + .background( + WindowAccessor { window in + commandKeyMonitor.setHostWindow(window) + } + .frame(width: 0, height: 0) + ) .onAppear { commandKeyMonitor.start() draggedTabId = nil dropIndicator = nil + SidebarDragLifecycleNotification.postStateDidChange( + tabId: nil, + reason: "sidebar_appear" + ) } .onDisappear { commandKeyMonitor.stop() dragAutoScrollController.stop() + dragFailsafeMonitor.stop() draggedTabId = nil dropIndicator = nil + SidebarDragLifecycleNotification.postStateDidChange( + tabId: nil, + reason: "sidebar_disappear" + ) } .onChange(of: draggedTabId) { newDraggedTabId in - guard newDraggedTabId == nil else { return } + SidebarDragLifecycleNotification.postStateDidChange( + tabId: newDraggedTabId, + reason: "drag_state_change" + ) +#if DEBUG + dlog("sidebar.dragState.sidebar tab=\(debugShortSidebarTabId(newDraggedTabId))") +#endif + if newDraggedTabId != nil { + dragFailsafeMonitor.start { + SidebarDragLifecycleNotification.postClearRequest(reason: $0) + } + return + } + dragFailsafeMonitor.stop() dragAutoScrollController.stop() dropIndicator = nil } + .onReceive(NotificationCenter.default.publisher(for: SidebarDragLifecycleNotification.requestClear)) { notification in + guard draggedTabId != nil else { return } + let reason = SidebarDragLifecycleNotification.reason(from: notification) +#if DEBUG + dlog("sidebar.dragClear tab=\(debugShortSidebarTabId(draggedTabId)) reason=\(reason)") +#endif + draggedTabId = nil + } + } + + private func debugShortSidebarTabId(_ id: UUID?) -> String { + guard let id else { return "nil" } + return String(id.uuidString.prefix(5)) } } @@ -1134,6 +1259,35 @@ enum SidebarCommandHintPolicy { static func shouldShowHints(for modifierFlags: NSEvent.ModifierFlags) -> Bool { modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command] } + + static func isCurrentWindow( + hostWindowNumber: Int?, + hostWindowIsKey: Bool, + eventWindowNumber: Int?, + keyWindowNumber: Int? + ) -> Bool { + guard let hostWindowNumber, hostWindowIsKey else { return false } + if let eventWindowNumber { + return eventWindowNumber == hostWindowNumber + } + return keyWindowNumber == hostWindowNumber + } + + static func shouldShowHints( + for modifierFlags: NSEvent.ModifierFlags, + hostWindowNumber: Int?, + hostWindowIsKey: Bool, + eventWindowNumber: Int?, + keyWindowNumber: Int? + ) -> Bool { + shouldShowHints(for: modifierFlags) && + isCurrentWindow( + hostWindowNumber: hostWindowNumber, + hostWindowIsKey: hostWindowIsKey, + eventWindowNumber: eventWindowNumber, + keyWindowNumber: keyWindowNumber + ) + } } enum ShortcutHintDebugSettings { @@ -1160,32 +1314,268 @@ enum ShortcutHintDebugSettings { } } +enum SidebarDragLifecycleNotification { + static let stateDidChange = Notification.Name("cmux.sidebarDragStateDidChange") + static let requestClear = Notification.Name("cmux.sidebarDragRequestClear") + static let tabIdKey = "tabId" + static let reasonKey = "reason" + + static func postStateDidChange(tabId: UUID?, reason: String) { + var userInfo: [AnyHashable: Any] = [reasonKey: reason] + if let tabId { + userInfo[tabIdKey] = tabId + } + NotificationCenter.default.post( + name: stateDidChange, + object: nil, + userInfo: userInfo + ) + } + + static func postClearRequest(reason: String) { + NotificationCenter.default.post( + name: requestClear, + object: nil, + userInfo: [reasonKey: reason] + ) + } + + static func tabId(from notification: Notification) -> UUID? { + notification.userInfo?[tabIdKey] as? UUID + } + + static func reason(from notification: Notification) -> String { + notification.userInfo?[reasonKey] as? String ?? "unknown" + } +} + +enum SidebarOutsideDropResetPolicy { + static func shouldResetDrag(draggedTabId: UUID?, hasSidebarDragPayload: Bool) -> Bool { + draggedTabId != nil && hasSidebarDragPayload + } +} + +enum SidebarDragFailsafePolicy { + static let pollInterval: TimeInterval = 0.05 + static let clearDelay: TimeInterval = 0.15 + + static func shouldRequestClear(isDragActive: Bool, isLeftMouseButtonDown: Bool) -> Bool { + isDragActive && !isLeftMouseButtonDown + } +} + +@MainActor +private final class SidebarDragFailsafeMonitor: ObservableObject { + private static let escapeKeyCode: UInt16 = 53 + private var timer: Timer? + private var pendingClearWorkItem: DispatchWorkItem? + private var appResignObserver: NSObjectProtocol? + private var keyDownMonitor: Any? + private var onRequestClear: ((String) -> Void)? + + func start(onRequestClear: @escaping (String) -> Void) { + self.onRequestClear = onRequestClear + if timer == nil { + let timer = Timer(timeInterval: SidebarDragFailsafePolicy.pollInterval, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.tick() + } + } + self.timer = timer + RunLoop.main.add(timer, forMode: .common) + } + if appResignObserver == nil { + appResignObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.requestClearSoon(reason: "app_resign_active") + } + } + } + if keyDownMonitor == nil { + keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + if event.keyCode == Self.escapeKeyCode { + self?.requestClearSoon(reason: "escape_cancel") + } + return event + } + } + } + + func stop() { + timer?.invalidate() + timer = nil + pendingClearWorkItem?.cancel() + pendingClearWorkItem = nil + if let appResignObserver { + NotificationCenter.default.removeObserver(appResignObserver) + self.appResignObserver = nil + } + if let keyDownMonitor { + NSEvent.removeMonitor(keyDownMonitor) + self.keyDownMonitor = nil + } + onRequestClear = nil + } + + private func tick() { + let isLeftMouseButtonDown = CGEventSource.buttonState(.combinedSessionState, button: .left) + guard SidebarDragFailsafePolicy.shouldRequestClear( + isDragActive: true, // Monitor only runs while drag is active. + isLeftMouseButtonDown: isLeftMouseButtonDown + ) else { return } + requestClearSoon(reason: "mouse_up_failsafe") + } + + private func requestClearSoon(reason: String) { + guard pendingClearWorkItem == nil else { return } +#if DEBUG + dlog("sidebar.dragFailsafe.schedule reason=\(reason)") +#endif + let workItem = DispatchWorkItem { [weak self] in +#if DEBUG + dlog("sidebar.dragFailsafe.fire reason=\(reason)") +#endif + self?.pendingClearWorkItem = nil + self?.onRequestClear?(reason) + } + pendingClearWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + SidebarDragFailsafePolicy.clearDelay, execute: workItem) + } +} + +private struct SidebarExternalDropOverlay: View { + let draggedTabId: UUID? + + var body: some View { + Color.clear + .contentShape(Rectangle()) + .allowsHitTesting(draggedTabId != nil) + .onDrop( + of: [SidebarTabDragPayload.typeIdentifier], + delegate: SidebarExternalDropDelegate(draggedTabId: draggedTabId) + ) + } +} + +private struct SidebarExternalDropDelegate: DropDelegate { + let draggedTabId: UUID? + + func validateDrop(info: DropInfo) -> Bool { + let hasSidebarPayload = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) + let shouldReset = SidebarOutsideDropResetPolicy.shouldResetDrag( + draggedTabId: draggedTabId, + hasSidebarDragPayload: hasSidebarPayload + ) +#if DEBUG + dlog( + "sidebar.dropOutside.validate tab=\(debugShortSidebarTabId(draggedTabId)) " + + "hasType=\(hasSidebarPayload) allowed=\(shouldReset)" + ) +#endif + return shouldReset + } + + func dropEntered(info: DropInfo) { +#if DEBUG + dlog("sidebar.dropOutside.entered tab=\(debugShortSidebarTabId(draggedTabId))") +#endif + } + + func dropExited(info: DropInfo) { +#if DEBUG + dlog("sidebar.dropOutside.exited tab=\(debugShortSidebarTabId(draggedTabId))") +#endif + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + guard validateDrop(info: info) else { return nil } +#if DEBUG + dlog("sidebar.dropOutside.updated tab=\(debugShortSidebarTabId(draggedTabId)) op=move") +#endif + // Explicit move proposal avoids AppKit showing a copy (+) cursor. + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + guard validateDrop(info: info) else { return false } +#if DEBUG + dlog("sidebar.dropOutside.perform tab=\(debugShortSidebarTabId(draggedTabId))") +#endif + SidebarDragLifecycleNotification.postClearRequest(reason: "outside_sidebar_drop") + return true + } + + private func debugShortSidebarTabId(_ id: UUID?) -> String { + guard let id else { return "nil" } + return String(id.uuidString.prefix(5)) + } +} + @MainActor private final class SidebarCommandKeyMonitor: ObservableObject { @Published private(set) var isCommandPressed = false + private weak var hostWindow: NSWindow? + private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? + private var hostWindowDidResignKeyObserver: NSObjectProtocol? private var flagsMonitor: Any? private var keyDownMonitor: Any? - private var resignObserver: NSObjectProtocol? + private var appResignObserver: NSObjectProtocol? private var pendingShowWorkItem: DispatchWorkItem? + func setHostWindow(_ window: NSWindow?) { + guard hostWindow !== window else { return } + removeHostWindowObservers() + hostWindow = window + guard let window else { + cancelPendingHintShow(resetVisible: true) + return + } + + hostWindowDidBecomeKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.update(from: NSEvent.modifierFlags, eventWindow: nil) + } + } + + hostWindowDidResignKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.cancelPendingHintShow(resetVisible: true) + } + } + + update(from: NSEvent.modifierFlags, eventWindow: nil) + } + func start() { guard flagsMonitor == nil else { - update(from: NSEvent.modifierFlags) + update(from: NSEvent.modifierFlags, eventWindow: nil) return } flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in - self?.update(from: event.modifierFlags) + self?.update(from: event.modifierFlags, eventWindow: event.window) return event } keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - self?.cancelPendingHintShow(resetVisible: true) + self?.handleKeyDown(event) return event } - resignObserver = NotificationCenter.default.addObserver( + appResignObserver = NotificationCenter.default.addObserver( forName: NSApplication.didResignActiveNotification, object: nil, queue: .main @@ -1195,7 +1585,7 @@ private final class SidebarCommandKeyMonitor: ObservableObject { } } - update(from: NSEvent.modifierFlags) + update(from: NSEvent.modifierFlags, eventWindow: nil) } func stop() { @@ -1207,15 +1597,36 @@ private final class SidebarCommandKeyMonitor: ObservableObject { NSEvent.removeMonitor(keyDownMonitor) self.keyDownMonitor = nil } - if let resignObserver { - NotificationCenter.default.removeObserver(resignObserver) - self.resignObserver = nil + if let appResignObserver { + NotificationCenter.default.removeObserver(appResignObserver) + self.appResignObserver = nil } + removeHostWindowObservers() cancelPendingHintShow(resetVisible: true) } - private func update(from modifierFlags: NSEvent.ModifierFlags) { - guard SidebarCommandHintPolicy.shouldShowHints(for: modifierFlags) else { + private func handleKeyDown(_ event: NSEvent) { + guard isCurrentWindow(eventWindow: event.window) else { return } + cancelPendingHintShow(resetVisible: true) + } + + private func isCurrentWindow(eventWindow: NSWindow?) -> Bool { + SidebarCommandHintPolicy.isCurrentWindow( + hostWindowNumber: hostWindow?.windowNumber, + hostWindowIsKey: hostWindow?.isKeyWindow ?? false, + eventWindowNumber: eventWindow?.windowNumber, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) + } + + private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) { + guard SidebarCommandHintPolicy.shouldShowHints( + for: modifierFlags, + hostWindowNumber: hostWindow?.windowNumber, + hostWindowIsKey: hostWindow?.isKeyWindow ?? false, + eventWindowNumber: eventWindow?.windowNumber, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { cancelPendingHintShow(resetVisible: true) return } @@ -1230,7 +1641,13 @@ private final class SidebarCommandKeyMonitor: ObservableObject { let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.pendingShowWorkItem = nil - guard SidebarCommandHintPolicy.shouldShowHints(for: NSEvent.modifierFlags) else { return } + guard SidebarCommandHintPolicy.shouldShowHints( + for: NSEvent.modifierFlags, + hostWindowNumber: self.hostWindow?.windowNumber, + hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false, + eventWindowNumber: nil, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { return } self.isCommandPressed = true } @@ -1245,6 +1662,17 @@ private final class SidebarCommandKeyMonitor: ObservableObject { isCommandPressed = false } } + + private func removeHostWindowObservers() { + if let hostWindowDidBecomeKeyObserver { + NotificationCenter.default.removeObserver(hostWindowDidBecomeKeyObserver) + self.hostWindowDidBecomeKeyObserver = nil + } + if let hostWindowDidResignKeyObserver { + NotificationCenter.default.removeObserver(hostWindowDidResignKeyObserver) + self.hostWindowDidResignKeyObserver = nil + } + } } #if DEBUG @@ -1588,7 +2016,7 @@ private struct TabItemView: View { .transition(.opacity.combined(with: .move(edge: .top))) } - // Branch + directory + ports row + // Branch + directory row if let dirRow = branchDirectoryRow { HStack(spacing: 3) { if sidebarShowGitBranch && tab.gitBranch != nil && sidebarShowGitBranchIcon { @@ -1603,6 +2031,15 @@ private struct TabItemView: View { .truncationMode(.tail) } } + + // Ports row + if sidebarShowPorts, !tab.listeningPorts.isEmpty { + Text(tab.listeningPorts.map { ":\($0)" }.joined(separator: ", ")) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) + .lineLimit(1) + .truncationMode(.tail) + } } .animation(.easeInOut(duration: 0.2), value: tab.logEntries.count) .animation(.easeInOut(duration: 0.2), value: tab.progress != nil) @@ -1715,7 +2152,7 @@ private struct TabItemView: View { Divider() - Button("Close Tabs") { + Button("Close Workspaces") { closeTabs(targetIds, allowPinned: true) } .disabled(targetIds.isEmpty) @@ -1725,12 +2162,12 @@ private struct TabItemView: View { } .disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count) - Button("Close Tabs Below") { + Button("Close Workspaces Below") { closeTabsBelow(tabId: tab.id) } .disabled(index >= tabManager.tabs.count - 1) - Button("Close Tabs Above") { + Button("Close Workspaces Above") { closeTabsAbove(tabId: tab.id) } .disabled(index == 0) @@ -1925,12 +2362,6 @@ private struct TabItemView: View { parts.append(dirs) } - // Ports (if enabled and available) - if sidebarShowPorts, !tab.listeningPorts.isEmpty { - let portsStr = tab.listeningPorts.map { ":\($0)" }.joined(separator: ",") - parts.append(portsStr) - } - let result = parts.joined(separator: " · ") return result.isEmpty ? nil : result } @@ -2382,6 +2813,9 @@ private struct SidebarTabDropDelegate: DropDelegate { } func dropExited(info: DropInfo) { +#if DEBUG + dlog("sidebar.dropExited target=\(targetTabId?.uuidString.prefix(5) ?? "end")") +#endif if dropIndicator?.tabId == targetTabId { dropIndicator = nil } @@ -2390,6 +2824,12 @@ private struct SidebarTabDropDelegate: DropDelegate { func dropUpdated(info: DropInfo) -> DropProposal? { dragAutoScrollController.updateFromDragLocation() updateDropIndicator(for: info) +#if DEBUG + dlog( + "sidebar.dropUpdated target=\(targetTabId?.uuidString.prefix(5) ?? "end") " + + "indicator=\(debugIndicator(dropIndicator))" + ) +#endif return DropProposal(operation: .move) } @@ -2402,8 +2842,18 @@ private struct SidebarTabDropDelegate: DropDelegate { #if DEBUG dlog("sidebar.drop target=\(targetTabId?.uuidString.prefix(5) ?? "end")") #endif - guard let draggedTabId else { return false } - guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { return false } + guard let draggedTabId else { +#if DEBUG + dlog("sidebar.drop.abort reason=missingDraggedTab") +#endif + return false + } + guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { +#if DEBUG + dlog("sidebar.drop.abort reason=draggedTabMissing tab=\(draggedTabId.uuidString.prefix(5))") +#endif + return false + } let tabIds = tabManager.tabs.map(\.id) guard let targetIndex = SidebarDropPlanner.targetIndex( draggedTabId: draggedTabId, @@ -2411,14 +2861,26 @@ private struct SidebarTabDropDelegate: DropDelegate { indicator: dropIndicator, tabIds: tabIds ) else { +#if DEBUG + dlog( + "sidebar.drop.abort reason=noTargetIndex tab=\(draggedTabId.uuidString.prefix(5)) " + + "target=\(targetTabId?.uuidString.prefix(5) ?? "end") indicator=\(debugIndicator(dropIndicator))" + ) +#endif return false } guard fromIndex != targetIndex else { +#if DEBUG + dlog("sidebar.drop.noop from=\(fromIndex) to=\(targetIndex)") +#endif syncSidebarSelection() return true } +#if DEBUG + dlog("sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) from=\(fromIndex) to=\(targetIndex)") +#endif _ = tabManager.reorderWorkspace(tabId: draggedTabId, toIndex: targetIndex) if let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] @@ -2449,6 +2911,12 @@ private struct SidebarTabDropDelegate: DropDelegate { lastSidebarSelectionIndex = nil } } + + private func debugIndicator(_ indicator: SidebarDropIndicator?) -> String { + guard let indicator else { return "nil" } + let tabText = indicator.tabId.map { String($0.uuidString.prefix(5)) } ?? "end" + return "\(tabText):\(indicator.edge == .top ? "top" : "bottom")" + } } /// AppKit-level double-click handler for the sidebar title-bar area. diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 820844dd..9cac89e9 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -288,9 +288,7 @@ class GhosttyApp { // Load default config (includes user config). If this fails hard (e.g. due to // invalid user config), ghostty_app_new may return nil; we fall back below. - ghostty_config_load_default_files(primaryConfig) - loadLegacyGhosttyConfigIfNeeded(primaryConfig) - ghostty_config_finalize(primaryConfig) + loadDefaultConfigFilesWithLegacyFallback(primaryConfig) updateDefaultBackground(from: primaryConfig) // Create runtime config with callbacks @@ -458,6 +456,21 @@ class GhosttyApp { #endif } + private func loadDefaultConfigFilesWithLegacyFallback(_ config: ghostty_config_t) { + ghostty_config_load_default_files(config) + loadLegacyGhosttyConfigIfNeeded(config) + ghostty_config_finalize(config) + } + + static func shouldLoadLegacyGhosttyConfig( + newConfigFileSize: Int?, + legacyConfigFileSize: Int? + ) -> Bool { + guard let newConfigFileSize, newConfigFileSize == 0 else { return false } + guard let legacyConfigFileSize, legacyConfigFileSize > 0 else { return false } + return true + } + private func loadLegacyGhosttyConfigIfNeeded(_ config: ghostty_config_t) { #if os(macOS) // Ghostty 1.3+ prefers `config.ghostty`, but some users still have their real @@ -475,8 +488,10 @@ class GhosttyApp { return size.intValue } - guard let newSize = fileSize(configNew), newSize == 0 else { return } - guard let legacySize = fileSize(configLegacy), legacySize > 0 else { return } + guard Self.shouldLoadLegacyGhosttyConfig( + newConfigFileSize: fileSize(configNew), + legacyConfigFileSize: fileSize(configLegacy) + ) else { return } configLegacy.path.withCString { path in ghostty_config_load_file(config, path) @@ -512,8 +527,7 @@ class GhosttyApp { } guard let newConfig = ghostty_config_new() else { return } - ghostty_config_load_default_files(newConfig) - ghostty_config_finalize(newConfig) + loadDefaultConfigFilesWithLegacyFallback(newConfig) ghostty_app_update_config(app, newConfig) updateDefaultBackground(from: newConfig) DispatchQueue.main.async { @@ -533,8 +547,7 @@ class GhosttyApp { } guard let newConfig = ghostty_config_new() else { return } - ghostty_config_load_default_files(newConfig) - ghostty_config_finalize(newConfig) + loadDefaultConfigFilesWithLegacyFallback(newConfig) ghostty_surface_update_config(surface, newConfig) ghostty_config_free(newConfig) } @@ -666,7 +679,6 @@ class GhosttyApp { let command = actionTitle.isEmpty ? tabTitle : actionTitle let body = actionBody let surfaceId = tabManager.focusedSurfaceId(for: tabId) - tabManager.moveTabToTop(tabId) TerminalNotificationStore.shared.addNotification( tabId: tabId, surfaceId: surfaceId, @@ -870,7 +882,6 @@ class GhosttyApp { let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal" let command = actionTitle.isEmpty ? tabTitle : actionTitle let body = actionBody - AppDelegate.shared?.tabManager?.moveTabToTop(tabId) TerminalNotificationStore.shared.addNotification( tabId: tabId, surfaceId: surfaceId, @@ -1089,6 +1100,17 @@ final class TerminalSurface: Identifiable, ObservableObject { var isViewInWindow: Bool { hostedView.window != nil } let id: UUID private(set) var tabId: UUID + /// Port ordinal for CMUX_PORT range assignment + var portOrdinal: Int = 0 + /// Snapshotted once per app session so all workspaces use consistent values + private static let sessionPortBase: Int = { + let val = UserDefaults.standard.integer(forKey: "cmuxPortBase") + return val > 0 ? val : 9100 + }() + private static let sessionPortRangeSize: Int = { + let val = UserDefaults.standard.integer(forKey: "cmuxPortRange") + return val > 0 ? val : 10 + }() private let surfaceContext: ghostty_surface_context_e private let configTemplate: ghostty_surface_config_s? private let workingDirectory: String? @@ -1328,6 +1350,14 @@ final class TerminalSurface: Identifiable, ObservableObject { env["CMUX_TAB_ID"] = tabId.uuidString env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath() + // Port range for this workspace (base/range snapshotted once per app session) + do { + let startPort = Self.sessionPortBase + portOrdinal * Self.sessionPortRangeSize + env["CMUX_PORT"] = String(startPort) + env["CMUX_PORT_END"] = String(startPort + Self.sessionPortRangeSize - 1) + env["CMUX_PORT_RANGE"] = String(Self.sessionPortRangeSize) + } + let claudeHooksEnabled = UserDefaults.standard.object(forKey: "claudeCodeHooksEnabled") as? Bool ?? true if !claudeHooksEnabled { env["CMUX_CLAUDE_HOOKS_DISABLED"] = "1" diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 82a7fe84..8b2b8d14 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -26,16 +26,20 @@ enum KeyboardShortcutSettings { case focusDown case splitRight case splitDown + case splitBrowserRight + case splitBrowserDown // Panels case openBrowser + case toggleBrowserDeveloperTools + case showBrowserJavaScriptConsole var id: String { rawValue } var label: String { switch self { case .toggleSidebar: return "Toggle Sidebar" - case .newTab: return "New Tab" + case .newTab: return "New Workspace" case .newWindow: return "New Window" case .showNotifications: return "Show Notifications" case .jumpToUnread: return "Jump to Latest Unread" @@ -51,7 +55,11 @@ enum KeyboardShortcutSettings { case .focusDown: return "Focus Pane Down" case .splitRight: return "Split Right" case .splitDown: return "Split Down" + case .splitBrowserRight: return "Split Browser Right" + case .splitBrowserDown: return "Split Browser Down" case .openBrowser: return "Open Browser" + case .toggleBrowserDeveloperTools: return "Toggle Browser Developer Tools" + case .showBrowserJavaScriptConsole: return "Show Browser JavaScript Console" } } @@ -71,10 +79,14 @@ enum KeyboardShortcutSettings { case .focusDown: return "shortcut.focusDown" case .splitRight: return "shortcut.splitRight" case .splitDown: return "shortcut.splitDown" + case .splitBrowserRight: return "shortcut.splitBrowserRight" + case .splitBrowserDown: return "shortcut.splitBrowserDown" case .nextSurface: return "shortcut.nextSurface" case .prevSurface: return "shortcut.prevSurface" case .newSurface: return "shortcut.newSurface" case .openBrowser: return "shortcut.openBrowser" + case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools" + case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole" } } @@ -108,6 +120,10 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "d", command: true, shift: false, option: false, control: false) case .splitDown: return StoredShortcut(key: "d", command: true, shift: true, option: false, control: false) + case .splitBrowserRight: + return StoredShortcut(key: "d", command: true, shift: false, option: true, control: false) + case .splitBrowserDown: + return StoredShortcut(key: "d", command: true, shift: true, option: true, control: false) case .nextSurface: return StoredShortcut(key: "]", command: true, shift: true, option: false, control: false) case .prevSurface: @@ -116,6 +132,12 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "t", command: true, shift: false, option: false, control: false) case .openBrowser: return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false) + case .toggleBrowserDeveloperTools: + // Safari default: Show Web Inspector. + return StoredShortcut(key: "i", command: true, shift: false, option: true, control: false) + case .showBrowserJavaScriptConsole: + // Safari default: Show JavaScript Console. + return StoredShortcut(key: "c", command: true, shift: false, option: true, control: false) } } @@ -176,12 +198,16 @@ enum KeyboardShortcutSettings { static func splitRightShortcut() -> StoredShortcut { shortcut(for: .splitRight) } static func splitDownShortcut() -> StoredShortcut { shortcut(for: .splitDown) } + static func splitBrowserRightShortcut() -> StoredShortcut { shortcut(for: .splitBrowserRight) } + static func splitBrowserDownShortcut() -> StoredShortcut { shortcut(for: .splitBrowserDown) } static func nextSurfaceShortcut() -> StoredShortcut { shortcut(for: .nextSurface) } static func prevSurfaceShortcut() -> StoredShortcut { shortcut(for: .prevSurface) } static func newSurfaceShortcut() -> StoredShortcut { shortcut(for: .newSurface) } static func openBrowserShortcut() -> StoredShortcut { shortcut(for: .openBrowser) } + static func toggleBrowserDeveloperToolsShortcut() -> StoredShortcut { shortcut(for: .toggleBrowserDeveloperTools) } + static func showBrowserJavaScriptConsoleShortcut() -> StoredShortcut { shortcut(for: .showBrowserJavaScriptConsole) } } /// A keyboard shortcut that can be stored in UserDefaults diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index e083e705..0d8ac297 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2,6 +2,7 @@ import Foundation import Combine import WebKit import AppKit +import Bonsplit enum BrowserSearchEngine: String, CaseIterable, Identifiable { case google @@ -107,6 +108,164 @@ enum BrowserLinkOpenSettings { } } +enum BrowserInsecureHTTPSettings { + static let allowlistKey = "browserInsecureHTTPAllowlist" + static let defaultAllowlistPatterns = [ + "127.0.0.1", + "localhost", + "*.localtest.me", + ] + static let defaultAllowlistText = defaultAllowlistPatterns.joined(separator: "\n") + + static func normalizedAllowlistPatterns(defaults: UserDefaults = .standard) -> [String] { + normalizedAllowlistPatterns(rawValue: defaults.string(forKey: allowlistKey)) + } + + static func normalizedAllowlistPatterns(rawValue: String?) -> [String] { + let source: String + if let rawValue, !rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + source = rawValue + } else { + source = defaultAllowlistText + } + let parsed = parsePatterns(from: source) + return parsed.isEmpty ? defaultAllowlistPatterns : parsed + } + + static func isHostAllowed(_ host: String, defaults: UserDefaults = .standard) -> Bool { + isHostAllowed(host, rawAllowlist: defaults.string(forKey: allowlistKey)) + } + + static func isHostAllowed(_ host: String, rawAllowlist: String?) -> Bool { + guard let normalizedHost = normalizeHost(host) else { return false } + return normalizedAllowlistPatterns(rawValue: rawAllowlist).contains { pattern in + hostMatchesPattern(normalizedHost, pattern: pattern) + } + } + + static func addAllowedHost(_ host: String, defaults: UserDefaults = .standard) { + guard let normalizedHost = normalizeHost(host) else { return } + var patterns = normalizedAllowlistPatterns(defaults: defaults) + guard !patterns.contains(normalizedHost) else { return } + patterns.append(normalizedHost) + defaults.set(patterns.joined(separator: "\n"), forKey: allowlistKey) + } + + static func normalizeHost(_ rawHost: String) -> String? { + var value = rawHost + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard !value.isEmpty else { return nil } + + if let parsed = URL(string: value)?.host { + return trimHost(parsed) + } + + if let schemeRange = value.range(of: "://") { + value = String(value[schemeRange.upperBound...]) + } + + if let slash = value.firstIndex(where: { $0 == "/" || $0 == "?" || $0 == "#" }) { + value = String(value[..<slash]) + } + + if value.hasPrefix("[") { + if let closing = value.firstIndex(of: "]") { + value = String(value[value.index(after: value.startIndex)..<closing]) + } else { + value.removeFirst() + } + } else if let colon = value.lastIndex(of: ":"), + value[value.index(after: colon)...].allSatisfy(\.isNumber), + value.filter({ $0 == ":" }).count == 1 { + value = String(value[..<colon]) + } + + return trimHost(value) + } + + private static func parsePatterns(from rawValue: String) -> [String] { + let separators = CharacterSet(charactersIn: ",;\n\r\t") + var out: [String] = [] + var seen = Set<String>() + for token in rawValue.components(separatedBy: separators) { + guard let normalized = normalizePattern(token) else { continue } + guard seen.insert(normalized).inserted else { continue } + out.append(normalized) + } + return out + } + + private static func normalizePattern(_ rawPattern: String) -> String? { + let trimmed = rawPattern + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard !trimmed.isEmpty else { return nil } + + if trimmed.hasPrefix("*.") { + let suffixRaw = String(trimmed.dropFirst(2)) + guard let suffix = normalizeHost(suffixRaw) else { return nil } + return "*.\(suffix)" + } + + return normalizeHost(trimmed) + } + + private static func hostMatchesPattern(_ host: String, pattern: String) -> Bool { + if pattern.hasPrefix("*.") { + let suffix = String(pattern.dropFirst(2)) + return host == suffix || host.hasSuffix(".\(suffix)") + } + return host == pattern + } + + private static func trimHost(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return trimmed.isEmpty ? nil : trimmed + } +} + +func browserShouldBlockInsecureHTTPURL( + _ url: URL, + defaults: UserDefaults = .standard +) -> Bool { + browserShouldBlockInsecureHTTPURL( + url, + rawAllowlist: defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey) + ) +} + +func browserShouldBlockInsecureHTTPURL( + _ url: URL, + rawAllowlist: String? +) -> Bool { + guard url.scheme?.lowercased() == "http" else { return false } + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return true } + return !BrowserInsecureHTTPSettings.isHostAllowed(host, rawAllowlist: rawAllowlist) +} + +func browserShouldConsumeOneTimeInsecureHTTPBypass( + _ url: URL, + bypassHostOnce: inout String? +) -> Bool { + guard let bypassHost = bypassHostOnce else { return false } + guard url.scheme?.lowercased() == "http", + let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { + return false + } + guard host == bypassHost else { return false } + bypassHostOnce = nil + return true +} + +func browserShouldPersistInsecureHTTPAllowlistSelection( + response: NSApplication.ModalResponse, + suppressionEnabled: Bool +) -> Bool { + guard suppressionEnabled else { return false } + return response == .alertFirstButtonReturn || response == .alertSecondButtonReturn +} + enum BrowserUserAgentSettings { // Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens, // and some installs may have legacy Chrome UA overrides. Both can cause Google to serve @@ -801,6 +960,11 @@ actor BrowserSearchSuggestionService { /// BrowserPanel provides a WKWebView-based browser panel. /// All browser panels share a WKProcessPool for cookie sharing. +private enum BrowserInsecureHTTPNavigationIntent { + case currentTab + case newTab +} + @MainActor final class BrowserPanel: Panel, ObservableObject { /// Shared process pool for cookie sharing across all browser panels @@ -869,6 +1033,14 @@ final class BrowserPanel: Panel, ObservableObject { private let minPageZoom: CGFloat = 0.25 private let maxPageZoom: CGFloat = 5.0 private let pageZoomStep: CGFloat = 0.1 + private var insecureHTTPBypassHostOnce: String? + // Persist user intent across WebKit detach/reattach churn (split/layout updates). + private var preferredDeveloperToolsVisible: Bool = false + private var forceDeveloperToolsRefreshOnNextAttach: Bool = false + private var developerToolsRestoreRetryWorkItem: DispatchWorkItem? + private var developerToolsRestoreRetryAttempt: Int = 0 + private let developerToolsRestoreRetryDelay: TimeInterval = 0.05 + private let developerToolsRestoreRetryMaxAttempts: Int = 40 var displayTitle: String { if !pageTitle.isEmpty { @@ -888,9 +1060,10 @@ final class BrowserPanel: Panel, ObservableObject { false } - init(workspaceId: UUID, initialURL: URL? = nil) { + init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil) { self.id = UUID() self.workspaceId = workspaceId + self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") // Configure web view let config = WKWebViewConfiguration() @@ -909,6 +1082,11 @@ final class BrowserPanel: Panel, ObservableObject { let webView = CmuxWebView(frame: .zero, configuration: config) webView.allowsBackForwardNavigationGestures = true + // Required for Web Inspector support on recent WebKit SDKs. + if #available(macOS 13.3, *) { + webView.isInspectable = true + } + // Match the empty-page background to the window so newly-created browsers // don't flash white before content loads. webView.underPageBackgroundColor = .windowBackgroundColor @@ -939,6 +1117,12 @@ final class BrowserPanel: Panel, ObservableObject { navDelegate.openInNewTab = { [weak self] url in self?.openLinkInNewTab(url: url) } + navDelegate.shouldBlockInsecureHTTPNavigation = { [weak self] url in + self?.shouldBlockInsecureHTTPNavigation(to: url) ?? false + } + navDelegate.handleBlockedInsecureHTTPNavigation = { [weak self] url, intent in + self?.presentInsecureHTTPAlert(for: url, intent: intent, recordTypedNavigation: false) + } webView.navigationDelegate = navDelegate self.navigationDelegate = navDelegate @@ -948,6 +1132,9 @@ final class BrowserPanel: Panel, ObservableObject { guard let self else { return } self.openLinkInNewTab(url: url) } + browserUIDelegate.requestNavigation = { [weak self] url, intent in + self?.requestNavigation(url, intent: intent) + } webView.uiDelegate = browserUIDelegate self.uiDelegate = browserUIDelegate @@ -1240,9 +1427,20 @@ final class BrowserPanel: Panel, ObservableObject { // MARK: - Navigation /// Navigate to a URL - func navigate(to url: URL) { + func navigate(to url: URL, recordTypedNavigation: Bool = false) { + if shouldBlockInsecureHTTPNavigation(to: url) { + presentInsecureHTTPAlert(for: url, intent: .currentTab, recordTypedNavigation: recordTypedNavigation) + return + } + navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation) + } + + private func navigateWithoutInsecureHTTPPrompt(to url: URL, recordTypedNavigation: Bool) { // Some installs can end up with a legacy Chrome UA override; keep this pinned. webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent + if recordTypedNavigation { + BrowserHistoryStore.shared.recordTypedNavigation(url: url) + } navigationDelegate?.lastAttemptedURL = url var request = URLRequest(url: url) // Behave like a normal browser (respect HTTP caching). Reload is handled separately. @@ -1258,8 +1456,7 @@ final class BrowserPanel: Panel, ObservableObject { guard !trimmed.isEmpty else { return } if let url = resolveNavigableURL(from: trimmed) { - BrowserHistoryStore.shared.recordTypedNavigation(url: url) - navigate(to: url) + navigate(to: url, recordTypedNavigation: true) return } @@ -1272,7 +1469,77 @@ final class BrowserPanel: Panel, ObservableObject { resolveBrowserNavigableURL(input) } + private func shouldBlockInsecureHTTPNavigation(to url: URL) -> Bool { + if browserShouldConsumeOneTimeInsecureHTTPBypass(url, bypassHostOnce: &insecureHTTPBypassHostOnce) { + return false + } + return browserShouldBlockInsecureHTTPURL(url) + } + + private func requestNavigation(_ url: URL, intent: BrowserInsecureHTTPNavigationIntent) { + if shouldBlockInsecureHTTPNavigation(to: url) { + presentInsecureHTTPAlert(for: url, intent: intent, recordTypedNavigation: false) + return + } + switch intent { + case .currentTab: + navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: false) + case .newTab: + openLinkInNewTab(url: url) + } + } + + private func presentInsecureHTTPAlert( + for url: URL, + intent: BrowserInsecureHTTPNavigationIntent, + recordTypedNavigation: Bool + ) { + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return } + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Connection isn't secure" + alert.informativeText = """ + \(host) uses plain HTTP, so traffic can be read or modified on the network. + + Open this URL in your default browser, or proceed in cmux. + """ + alert.addButton(withTitle: "Open in Default Browser") + alert.addButton(withTitle: "Proceed in cmux") + alert.addButton(withTitle: "Cancel") + alert.showsSuppressionButton = true + alert.suppressionButton?.title = "Always allow this host in cmux" + + let response = alert.runModal() + if browserShouldPersistInsecureHTTPAllowlistSelection( + response: response, + suppressionEnabled: alert.suppressionButton?.state == .on + ) { + BrowserInsecureHTTPSettings.addAllowedHost(host) + } + switch response { + case .alertFirstButtonReturn: + NSWorkspace.shared.open(url) + case .alertSecondButtonReturn: + switch intent { + case .currentTab: + insecureHTTPBypassHostOnce = host + navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation) + case .newTab: + openLinkInNewTab(url: url, bypassInsecureHTTPHostOnce: host) + } + default: + return + } + } + deinit { + developerToolsRestoreRetryWorkItem?.cancel() + developerToolsRestoreRetryWorkItem = nil + let webView = webView + Task { @MainActor in + BrowserWindowPortalRegistry.detach(webView: webView) + } webViewObservers.removeAll() } } @@ -1322,11 +1589,16 @@ extension BrowserPanel { } /// Open a link in a new browser surface in the same pane - func openLinkInNewTab(url: URL) { + func openLinkInNewTab(url: URL, bypassInsecureHTTPHostOnce: String? = nil) { guard let tabManager = AppDelegate.shared?.tabManager, let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }), let paneId = workspace.paneId(forPanelId: id) else { return } - workspace.newBrowserSurface(inPane: paneId, url: url, focus: true) + workspace.newBrowserSurface( + inPane: paneId, + url: url, + focus: true, + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce + ) } /// Reload the current page @@ -1340,6 +1612,190 @@ extension BrowserPanel { webView.stopLoading() } + @discardableResult + func toggleDeveloperTools() -> Bool { +#if DEBUG + dlog( + "browser.devtools toggle.begin panel=\(id.uuidString.prefix(5)) " + + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" + ) +#endif + guard let inspector = webView.cmuxInspectorObject() else { return false } + let isVisibleSelector = NSSelectorFromString("isVisible") + let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false + let targetVisible = !visible + let selector = NSSelectorFromString(targetVisible ? "show" : "close") + guard inspector.responds(to: selector) else { return false } + inspector.cmuxCallVoid(selector: selector) + preferredDeveloperToolsVisible = targetVisible + if targetVisible { + let visibleAfterToggle = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false + if visibleAfterToggle { + cancelDeveloperToolsRestoreRetry() + } else { + developerToolsRestoreRetryAttempt = 0 + scheduleDeveloperToolsRestoreRetry() + } + } else { + cancelDeveloperToolsRestoreRetry() + forceDeveloperToolsRefreshOnNextAttach = false + } +#if DEBUG + dlog( + "browser.devtools toggle.end panel=\(id.uuidString.prefix(5)) targetVisible=\(targetVisible ? 1 : 0) " + + "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())" + ) + DispatchQueue.main.async { [weak self] in + guard let self else { return } + dlog( + "browser.devtools toggle.tick panel=\(self.id.uuidString.prefix(5)) " + + "\(self.debugDeveloperToolsStateSummary()) \(self.debugDeveloperToolsGeometrySummary())" + ) + } +#endif + return true + } + + @discardableResult + func showDeveloperTools() -> Bool { + guard let inspector = webView.cmuxInspectorObject() else { return false } + let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + if !visible { + let showSelector = NSSelectorFromString("show") + guard inspector.responds(to: showSelector) else { return false } + inspector.cmuxCallVoid(selector: showSelector) + } + preferredDeveloperToolsVisible = true + if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) { + cancelDeveloperToolsRestoreRetry() + } else { + scheduleDeveloperToolsRestoreRetry() + } + return true + } + + @discardableResult + func showDeveloperToolsConsole() -> Bool { + guard showDeveloperTools() else { return false } + guard let inspector = webView.cmuxInspectorObject() else { return true } + // WebKit private inspector API differs by OS; try known console selectors. + let consoleSelectors = [ + "showConsole", + "showConsoleTab", + "showConsoleView", + ] + for raw in consoleSelectors { + let selector = NSSelectorFromString(raw) + if inspector.responds(to: selector) { + inspector.cmuxCallVoid(selector: selector) + break + } + } + return true + } + + /// Called before WKWebView detaches so manual inspector closes are respected. + func syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: Bool = false) { + guard let inspector = webView.cmuxInspectorObject() else { return } + guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return } + if visible { + preferredDeveloperToolsVisible = true + cancelDeveloperToolsRestoreRetry() + return + } + if preserveVisibleIntent && preferredDeveloperToolsVisible { + return + } + preferredDeveloperToolsVisible = false + cancelDeveloperToolsRestoreRetry() + } + + /// Called after WKWebView reattaches to keep inspector stable across split/layout churn. + func restoreDeveloperToolsAfterAttachIfNeeded() { + guard preferredDeveloperToolsVisible else { + cancelDeveloperToolsRestoreRetry() + forceDeveloperToolsRefreshOnNextAttach = false + return + } + guard let inspector = webView.cmuxInspectorObject() else { + scheduleDeveloperToolsRestoreRetry() + return + } + + let shouldForceRefresh = forceDeveloperToolsRefreshOnNextAttach + forceDeveloperToolsRefreshOnNextAttach = false + + let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + if visible { + #if DEBUG + if shouldForceRefresh { + dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") + } + #endif + cancelDeveloperToolsRestoreRetry() + return + } + + let selector = NSSelectorFromString("show") + guard inspector.responds(to: selector) else { + cancelDeveloperToolsRestoreRetry() + return + } + #if DEBUG + if shouldForceRefresh { + dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())") + } + #endif + inspector.cmuxCallVoid(selector: selector) + preferredDeveloperToolsVisible = true + let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + if visibleAfterShow { + cancelDeveloperToolsRestoreRetry() + } else { + scheduleDeveloperToolsRestoreRetry() + } + } + + @discardableResult + func isDeveloperToolsVisible() -> Bool { + guard let inspector = webView.cmuxInspectorObject() else { return false } + return inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + } + + @discardableResult + func hideDeveloperTools() -> Bool { + guard let inspector = webView.cmuxInspectorObject() else { return false } + let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + if visible { + let selector = NSSelectorFromString("close") + guard inspector.responds(to: selector) else { return false } + inspector.cmuxCallVoid(selector: selector) + } + preferredDeveloperToolsVisible = false + forceDeveloperToolsRefreshOnNextAttach = false + cancelDeveloperToolsRestoreRetry() + return true + } + + /// During split/layout transitions SwiftUI can briefly mark the browser surface hidden + /// while its container is off-window. Avoid detaching in that transient phase if + /// DevTools is intended to remain open, because detach/reattach can blank inspector content. + func shouldPreserveWebViewAttachmentDuringTransientHide() -> Bool { + preferredDeveloperToolsVisible + } + + func requestDeveloperToolsRefreshAfterNextAttach(reason: String) { + guard preferredDeveloperToolsVisible else { return } + forceDeveloperToolsRefreshOnNextAttach = true + #if DEBUG + dlog("browser.devtools refresh.request panel=\(id.uuidString.prefix(5)) reason=\(reason) \(debugDeveloperToolsStateSummary())") + #endif + } + + func hasPendingDeveloperToolsRefreshAfterAttach() -> Bool { + forceDeveloperToolsRefreshOnNextAttach + } + @discardableResult func zoomIn() -> Bool { applyPageZoom(webView.pageZoom + pageZoomStep) @@ -1448,6 +1904,84 @@ extension BrowserPanel { } +private extension BrowserPanel { + func scheduleDeveloperToolsRestoreRetry() { + guard preferredDeveloperToolsVisible else { return } + guard developerToolsRestoreRetryWorkItem == nil else { return } + guard developerToolsRestoreRetryAttempt < developerToolsRestoreRetryMaxAttempts else { return } + + developerToolsRestoreRetryAttempt += 1 + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.developerToolsRestoreRetryWorkItem = nil + self.restoreDeveloperToolsAfterAttachIfNeeded() + } + developerToolsRestoreRetryWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + developerToolsRestoreRetryDelay, execute: work) + } + + func cancelDeveloperToolsRestoreRetry() { + developerToolsRestoreRetryWorkItem?.cancel() + developerToolsRestoreRetryWorkItem = nil + developerToolsRestoreRetryAttempt = 0 + } +} + +#if DEBUG +extension BrowserPanel { + private static func debugRectDescription(_ rect: NSRect) -> String { + String( + format: "%.1f,%.1f %.1fx%.1f", + rect.origin.x, + rect.origin.y, + rect.size.width, + rect.size.height + ) + } + + private static func debugObjectToken(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) + } + + private static func debugInspectorSubviewCount(in root: NSView) -> Int { + var stack: [NSView] = [root] + var count = 0 + while let current = stack.popLast() { + for subview in current.subviews { + if String(describing: type(of: subview)).contains("WKInspector") { + count += 1 + } + stack.append(subview) + } + } + return count + } + + func debugDeveloperToolsStateSummary() -> String { + let preferred = preferredDeveloperToolsVisible ? 1 : 0 + let visible = isDeveloperToolsVisible() ? 1 : 0 + let inspector = webView.cmuxInspectorObject() == nil ? 0 : 1 + let attached = webView.superview == nil ? 0 : 1 + let inWindow = webView.window == nil ? 0 : 1 + let forceRefresh = forceDeveloperToolsRefreshOnNextAttach ? 1 : 0 + return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh)" + } + + func debugDeveloperToolsGeometrySummary() -> String { + let container = webView.superview + let containerBounds = container?.bounds ?? .zero + let webFrame = webView.frame + let inspectorInsets = max(0, containerBounds.height - webFrame.height) + let inspectorOverflow = max(0, webFrame.maxY - containerBounds.maxY) + let inspectorHeightApprox = max(inspectorInsets, inspectorOverflow) + let inspectorSubviews = container.map { Self.debugInspectorSubviewCount(in: $0) } ?? 0 + let containerType = container.map { String(describing: type(of: $0)) } ?? "nil" + return "webFrame=\(Self.debugRectDescription(webFrame)) webBounds=\(Self.debugRectDescription(webView.bounds)) webWin=\(webView.window?.windowNumber ?? -1) super=\(Self.debugObjectToken(container)) superType=\(containerType) superBounds=\(Self.debugRectDescription(containerBounds)) inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) inspectorInsets=\(String(format: "%.1f", inspectorInsets)) inspectorOverflow=\(String(format: "%.1f", inspectorOverflow)) inspectorSubviews=\(inspectorSubviews)" + } +} +#endif + private extension BrowserPanel { @discardableResult func applyPageZoom(_ candidate: CGFloat) -> Bool { @@ -1471,12 +2005,41 @@ private extension BrowserPanel { } } +private extension WKWebView { + func cmuxInspectorObject() -> NSObject? { + let selector = NSSelectorFromString("_inspector") + guard responds(to: selector), + let inspector = perform(selector)?.takeUnretainedValue() as? NSObject else { + return nil + } + return inspector + } +} + +private extension NSObject { + func cmuxCallBool(selector: Selector) -> Bool? { + guard responds(to: selector) else { return nil } + typealias Fn = @convention(c) (AnyObject, Selector) -> Bool + let fn = unsafeBitCast(method(for: selector), to: Fn.self) + return fn(self, selector) + } + + func cmuxCallVoid(selector: Selector) { + guard responds(to: selector) else { return } + typealias Fn = @convention(c) (AnyObject, Selector) -> Void + let fn = unsafeBitCast(method(for: selector), to: Fn.self) + fn(self, selector) + } +} + // MARK: - Navigation Delegate private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { var didFinish: ((WKWebView) -> Void)? var didFailNavigation: ((WKWebView, String) -> Void)? var openInNewTab: ((URL) -> Void)? + var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)? + var handleBlockedInsecureHTTPNavigation: ((URL, BrowserInsecureHTTPNavigationIntent) -> Void)? /// The URL of the last navigation that was attempted. Used to preserve the omnibar URL /// when a provisional navigation fails (e.g. connection refused on localhost:3000). var lastAttemptedURL: URL? @@ -1596,6 +2159,21 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { + if let url = navigationAction.request.url, + navigationAction.targetFrame?.isMainFrame != false, + shouldBlockInsecureHTTPNavigation?(url) == true { + let intent: BrowserInsecureHTTPNavigationIntent + if navigationAction.navigationType == .linkActivated, + navigationAction.modifierFlags.contains(.command) { + intent = .newTab + } else { + intent = .currentTab + } + handleBlockedInsecureHTTPNavigation?(url, intent) + decisionHandler(.cancel) + return + } + // target=_blank or window.open() — navigate in the current webview if navigationAction.targetFrame == nil, let url = navigationAction.request.url { @@ -1621,6 +2199,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { private class BrowserUIDelegate: NSObject, WKUIDelegate { var openInNewTab: ((URL) -> Void)? + var requestNavigation: ((URL, BrowserInsecureHTTPNavigationIntent) -> Void)? /// Returning nil tells WebKit not to open a new window. /// Cmd+click opens in a new tab; regular target=_blank navigates in-place. @@ -1631,7 +2210,11 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { windowFeatures: WKWindowFeatures ) -> WKWebView? { if let url = navigationAction.request.url { - if navigationAction.modifierFlags.contains(.command) { + if let requestNavigation { + let intent: BrowserInsecureHTTPNavigationIntent = + navigationAction.modifierFlags.contains(.command) ? .newTab : .currentTab + requestNavigation(url, intent) + } else if navigationAction.modifierFlags.contains(.command) { openInNewTab?(url) } else { webView.load(URLRequest(url: url)) @@ -1639,4 +2222,20 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { } return nil } + + /// Handle <input type="file"> elements by presenting the native file picker. + func webView( + _ webView: WKWebView, + runOpenPanelWith parameters: WKOpenPanelParameters, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping ([URL]?) -> Void + ) { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = parameters.allowsMultipleSelection + panel.canChooseDirectories = parameters.allowsDirectories + panel.canChooseFiles = true + panel.begin { result in + completionHandler(result == .OK ? panel.urls : nil) + } + } } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 89a64487..6192970f 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -3,6 +3,113 @@ import SwiftUI import WebKit import AppKit +enum BrowserDevToolsIconOption: String, CaseIterable, Identifiable { + case wrenchAndScrewdriver = "wrench.and.screwdriver" + case wrenchAndScrewdriverFill = "wrench.and.screwdriver.fill" + case curlyBracesSquare = "curlybraces.square" + case curlyBraces = "curlybraces" + case terminalFill = "terminal.fill" + case terminal = "terminal" + case hammer = "hammer" + case hammerCircle = "hammer.circle" + case ladybug = "ladybug" + case ladybugFill = "ladybug.fill" + case scope = "scope" + case codeChevrons = "chevron.left.slash.chevron.right" + case gearshape = "gearshape" + case gearshapeFill = "gearshape.fill" + case globe = "globe" + case globeAmericas = "globe.americas.fill" + + var id: String { rawValue } + + var title: String { + switch self { + case .wrenchAndScrewdriver: return "Wrench + Screwdriver" + case .wrenchAndScrewdriverFill: return "Wrench + Screwdriver (Fill)" + case .curlyBracesSquare: return "Curly Braces" + case .curlyBraces: return "Curly Braces (Plain)" + case .terminalFill: return "Terminal (Fill)" + case .terminal: return "Terminal" + case .hammer: return "Hammer" + case .hammerCircle: return "Hammer Circle" + case .ladybug: return "Bug" + case .ladybugFill: return "Bug (Fill)" + case .scope: return "Scope" + case .codeChevrons: return "Code Chevrons" + case .gearshape: return "Gear" + case .gearshapeFill: return "Gear (Fill)" + case .globe: return "Globe" + case .globeAmericas: return "Globe Americas (Fill)" + } + } +} + +enum BrowserDevToolsIconColorOption: String, CaseIterable, Identifiable { + case bonsplitInactive + case bonsplitActive + case accent + case tertiary + + var id: String { rawValue } + + var title: String { + switch self { + case .bonsplitInactive: return "Bonsplit Inactive (Terminal/Globe)" + case .bonsplitActive: return "Bonsplit Active (Terminal/Globe)" + case .accent: return "Accent" + case .tertiary: return "Tertiary" + } + } + + var color: Color { + switch self { + case .bonsplitInactive: + // Matches Bonsplit tab icon tint for inactive tabs. + return Color(nsColor: .secondaryLabelColor) + case .bonsplitActive: + // Matches Bonsplit tab icon tint for active tabs. + return Color(nsColor: .labelColor) + case .accent: + return .accentColor + case .tertiary: + return Color(nsColor: .tertiaryLabelColor) + } + } +} + +enum BrowserDevToolsButtonDebugSettings { + static let iconNameKey = "browserDevToolsIconName" + static let iconColorKey = "browserDevToolsIconColor" + static let defaultIcon = BrowserDevToolsIconOption.wrenchAndScrewdriver + static let defaultColor = BrowserDevToolsIconColorOption.bonsplitInactive + + static func iconOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconOption { + guard let raw = defaults.string(forKey: iconNameKey), + let option = BrowserDevToolsIconOption(rawValue: raw) else { + return defaultIcon + } + return option + } + + static func colorOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconColorOption { + guard let raw = defaults.string(forKey: iconColorKey), + let option = BrowserDevToolsIconColorOption(rawValue: raw) else { + return defaultColor + } + return option + } + + static func copyPayload(defaults: UserDefaults = .standard) -> String { + let icon = iconOption(defaults: defaults) + let color = colorOption(defaults: defaults) + return """ + browserDevToolsIconName=\(icon.rawValue) + browserDevToolsIconColor=\(color.rawValue) + """ + } +} + struct OmnibarInlineCompletion: Equatable { let typedText: String let displayText: String @@ -20,11 +127,14 @@ struct BrowserPanelView: View { @ObservedObject var panel: BrowserPanel let isFocused: Bool let isVisibleInUI: Bool + let portalPriority: Int let onRequestPanelFocus: () -> Void @State private var omnibarState = OmnibarState() @State private var addressBarFocused: Bool = false @AppStorage(BrowserSearchSettings.searchEngineKey) private var searchEngineRaw = BrowserSearchSettings.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var searchSuggestionsEnabledStorage = BrowserSearchSettings.defaultSearchSuggestionsEnabled + @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue + @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue @State private var suggestionTask: Task<Void, Never>? @State private var isLoadingRemoteSuggestions: Bool = false @State private var latestRemoteSuggestionQuery: String = "" @@ -38,6 +148,8 @@ struct BrowserPanelView: View { @State private var omnibarPillFrame: CGRect = .zero @State private var lastHandledAddressBarFocusRequestId: UUID? private let omnibarPillCornerRadius: CGFloat = 12 + private let addressBarButtonSize: CGFloat = 22 + private let devToolsButtonIconSize: CGFloat = 11 private var searchEngine: BrowserSearchEngine { BrowserSearchEngine(rawValue: searchEngineRaw) ?? BrowserSearchSettings.defaultSearchEngine @@ -63,6 +175,14 @@ struct BrowserPanelView: View { return searchSuggestionsEnabled } + private var devToolsIconOption: BrowserDevToolsIconOption { + BrowserDevToolsIconOption(rawValue: devToolsIconNameRaw) ?? BrowserDevToolsButtonDebugSettings.defaultIcon + } + + private var devToolsColorOption: BrowserDevToolsIconColorOption { + BrowserDevToolsIconColorOption(rawValue: devToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor + } + var body: some View { VStack(spacing: 0) { addressBar @@ -210,6 +330,8 @@ struct BrowserPanelView: View { omnibarField .accessibilityIdentifier("BrowserOmnibarPill") .accessibilityLabel("Browser omnibar") + + developerToolsButton } .padding(.horizontal, 8) .padding(.vertical, 6) @@ -219,8 +341,6 @@ struct BrowserPanelView: View { } private var addressBarButtonBar: some View { - let navButtonSize: CGFloat = 22 - return HStack(spacing: 0) { Button(action: { #if DEBUG @@ -230,10 +350,10 @@ struct BrowserPanelView: View { }) { Image(systemName: "chevron.left") .font(.system(size: 12, weight: .medium)) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } .buttonStyle(.plain) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) .disabled(!panel.canGoBack) .opacity(panel.canGoBack ? 1.0 : 0.4) .help("Go Back") @@ -246,10 +366,10 @@ struct BrowserPanelView: View { }) { Image(systemName: "chevron.right") .font(.system(size: 12, weight: .medium)) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } .buttonStyle(.plain) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) .disabled(!panel.canGoForward) .opacity(panel.canGoForward ? 1.0 : 0.4) .help("Go Forward") @@ -269,14 +389,29 @@ struct BrowserPanelView: View { }) { Image(systemName: panel.isLoading ? "xmark" : "arrow.clockwise") .font(.system(size: 12, weight: .medium)) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } .buttonStyle(.plain) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) .help(panel.isLoading ? "Stop" : "Reload") } } + private var developerToolsButton: some View { + Button(action: { + openDevTools() + }) { + Image(systemName: devToolsIconOption.rawValue) + .font(.system(size: devToolsButtonIconSize, weight: .medium)) + .foregroundStyle(devToolsColorOption.color) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + } + .buttonStyle(.plain) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + .help("Toggle Developer Tools") + .accessibilityIdentifier("BrowserToggleDevToolsButton") + } + private var omnibarField: some View { let showSecureBadge = panel.currentURL?.scheme == "https" @@ -370,7 +505,8 @@ struct BrowserPanelView: View { panel: panel, shouldAttachWebView: isVisibleInUI, shouldFocusWebView: isFocused && !addressBarFocused, - isPanelFocused: isFocused + isPanelFocused: isFocused, + portalZPriority: portalPriority ) // Keep the representable identity stable across bonsplit structural updates. // This reduces WKWebView reparenting churn (and the associated WebKit crashes). @@ -384,12 +520,6 @@ struct BrowserPanelView: View { } }) .zIndex(0) - .contextMenu { - Button("Open Developer Tools") { - openDevTools() - } - .keyboardShortcut("i", modifiers: [.command, .option]) - } } private func triggerFocusFlashAnimation() { @@ -453,10 +583,11 @@ struct BrowserPanelView: View { } private func openDevTools() { - // WKWebView with developerExtrasEnabled allows right-click > Inspect Element - // We can also trigger via JavaScript - Task { - try? await panel.evaluateJavaScript("window.webkit?.messageHandlers?.devTools?.postMessage('open')") + #if DEBUG + dlog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))") + #endif + if !panel.toggleDeveloperTools() { + NSSound.beep() } } @@ -1071,7 +1202,19 @@ func buildOmnibarSuggestions( ) order += 1 if let existing = bestByCompletion[key] { - if ranked.score > existing.score { + let shouldReplaceExisting: Bool = { + // For identical completions, keep "go to URL" over "switch to tab" so + // pressing Enter performs navigation unless the user explicitly picks a tab row. + switch (existing.suggestion.kind, ranked.suggestion.kind) { + case (.navigate, .switchToTab): + return false + case (.switchToTab, .navigate): + return true + default: + return ranked.score > existing.score + } + }() + if shouldReplaceExisting { bestByCompletion[key] = ranked } } else { @@ -1970,6 +2113,8 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { parent.onMoveSelection(-1) return true case #selector(NSResponder.insertNewline(_:)): + let currentFlags = NSApp.currentEvent?.modifierFlags ?? [] + guard browserOmnibarShouldSubmitOnReturn(flags: currentFlags) else { return false } parent.onSubmit() return true case #selector(NSResponder.cancelOperation(_:)): @@ -2080,6 +2225,7 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { switch keyCode { case 36, 76: // Return / keypad Enter + guard browserOmnibarShouldSubmitOnReturn(flags: event.modifierFlags) else { return false } parent.onSubmit() return true case 53: // Escape @@ -2431,15 +2577,88 @@ struct WebViewRepresentable: NSViewRepresentable { let shouldAttachWebView: Bool let shouldFocusWebView: Bool let isPanelFocused: Bool + let portalZPriority: Int final class Coordinator { + weak var panel: BrowserPanel? weak var webView: WKWebView? - var constraints: [NSLayoutConstraint] = [] var attachRetryWorkItem: DispatchWorkItem? var attachRetryCount: Int = 0 var attachGeneration: Int = 0 + var usesWindowPortal: Bool = false + var desiredPortalVisibleInUI: Bool = true + var desiredPortalZPriority: Int = 0 + var lastPortalHostId: ObjectIdentifier? } + private final class HostContainerView: NSView { + var onDidMoveToWindow: (() -> Void)? + var onGeometryChanged: (() -> Void)? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + onDidMoveToWindow?() + onGeometryChanged?() + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + onGeometryChanged?() + } + + override func layout() { + super.layout() + onGeometryChanged?() + } + + override func setFrameOrigin(_ newOrigin: NSPoint) { + super.setFrameOrigin(newOrigin) + onGeometryChanged?() + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + onGeometryChanged?() + } + } + + #if DEBUG + private static func logDevToolsState( + _ panel: BrowserPanel, + event: String, + generation: Int, + retryCount: Int, + details: String? = nil + ) { + var line = "browser.devtools event=\(event) panel=\(panel.id.uuidString.prefix(5)) generation=\(generation) retry=\(retryCount) \(panel.debugDeveloperToolsStateSummary())" + if let details, !details.isEmpty { + line += " \(details)" + } + dlog(line) + } + + private static func objectID(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) + } + + private static func responderDescription(_ responder: NSResponder?) -> String { + guard let responder else { return "nil" } + return "\(type(of: responder))@\(objectID(responder))" + } + + private static func rectDescription(_ rect: NSRect) -> String { + String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height) + } + + private static func attachContext(webView: WKWebView, host: NSView) -> String { + let hostWindow = host.window?.windowNumber ?? -1 + let webWindow = webView.window?.windowNumber ?? -1 + let firstResponder = (webView.window ?? host.window)?.firstResponder + return "host=\(objectID(host)) hostWin=\(hostWindow) hostInWin=\(host.window == nil ? 0 : 1) hostFrame=\(rectDescription(host.frame)) hostBounds=\(rectDescription(host.bounds)) oldSuper=\(objectID(webView.superview)) webWin=\(webWindow) webInWin=\(webView.window == nil ? 0 : 1) webFrame=\(rectDescription(webView.frame)) webHidden=\(webView.isHidden ? 1 : 0) fr=\(responderDescription(firstResponder))" + } + #endif + private static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool { var r = start var hops = 0 @@ -2451,22 +2670,150 @@ struct WebViewRepresentable: NSViewRepresentable { return false } + private static func isLikelyInspectorResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + let responderType = String(describing: type(of: responder)) + if responderType.contains("WKInspector") { + return true + } + guard let view = responder as? NSView else { return false } + var node: NSView? = view + var hops = 0 + while let current = node, hops < 64 { + if String(describing: type(of: current)).contains("WKInspector") { + return true + } + node = current.superview + hops += 1 + } + return false + } + + private static func firstResponderResignState( + _ responder: NSResponder?, + webView: WKWebView + ) -> (needsResign: Bool, flags: String) { + let inWebViewChain = responderChainContains(responder, target: webView) + let inspectorResponder = isLikelyInspectorResponder(responder) + let needsResign = inWebViewChain || inspectorResponder + return ( + needsResign: needsResign, + flags: "frInWebChain=\(inWebViewChain ? 1 : 0) frIsInspector=\(inspectorResponder ? 1 : 0)" + ) + } + func makeCoordinator() -> Coordinator { - Coordinator() + let coordinator = Coordinator() + coordinator.panel = panel + return coordinator } func makeNSView(context: Context) -> NSView { - let container = NSView() + let container = HostContainerView() container.wantsLayer = true return container } - private static func attachWebView(_ webView: WKWebView, to host: NSView, coordinator: Coordinator) { + private static func clearPortalCallbacks(for host: NSView) { + guard let host = host as? HostContainerView else { return } + host.onDidMoveToWindow = nil + host.onGeometryChanged = nil + } + + private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) { + guard let host = nsView as? HostContainerView else { return } + + let coordinator = context.coordinator + let previousVisible = coordinator.desiredPortalVisibleInUI + let previousZPriority = coordinator.desiredPortalZPriority + coordinator.desiredPortalVisibleInUI = shouldAttachWebView + coordinator.desiredPortalZPriority = portalZPriority + coordinator.attachGeneration += 1 + let generation = coordinator.attachGeneration + + host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator] in + guard let host, let webView, let coordinator else { return } + guard coordinator.attachGeneration == generation else { return } + guard host.window != nil else { return } + BrowserWindowPortalRegistry.bind( + webView: webView, + to: host, + visibleInUI: coordinator.desiredPortalVisibleInUI, + zPriority: coordinator.desiredPortalZPriority + ) + coordinator.lastPortalHostId = ObjectIdentifier(host) + } + host.onGeometryChanged = { [weak host, weak coordinator] in + guard let host, let coordinator else { return } + guard coordinator.attachGeneration == generation else { return } + guard coordinator.lastPortalHostId == ObjectIdentifier(host) else { return } + BrowserWindowPortalRegistry.synchronizeForAnchor(host) + } + + if !shouldAttachWebView { + // In portal mode we no longer detach/re-attach to preserve DevTools state. + // Sync the inspector preference directly so manual closes are respected. + panel.syncDeveloperToolsPreferenceFromInspector() + } + + if host.window != nil { + let hostId = ObjectIdentifier(host) + let shouldBindNow = + coordinator.lastPortalHostId != hostId || + webView.superview == nil || + previousVisible != shouldAttachWebView || + previousZPriority != portalZPriority + if shouldBindNow { + BrowserWindowPortalRegistry.bind( + webView: webView, + to: host, + visibleInUI: coordinator.desiredPortalVisibleInUI, + zPriority: coordinator.desiredPortalZPriority + ) + coordinator.lastPortalHostId = hostId + } + BrowserWindowPortalRegistry.synchronizeForAnchor(host) + } else { + // Bind is deferred until host moves into a window. Keep the current + // portal entry's desired state in sync so stale callbacks cannot keep + // the previous anchor visible while this host is temporarily off-window. + BrowserWindowPortalRegistry.updateEntryVisibility( + for: webView, + visibleInUI: coordinator.desiredPortalVisibleInUI, + zPriority: coordinator.desiredPortalZPriority + ) + } + + panel.restoreDeveloperToolsAfterAttachIfNeeded() + + #if DEBUG + Self.logDevToolsState( + panel, + event: "portal.update", + generation: coordinator.attachGeneration, + retryCount: coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: host) + ) + #endif + } + + private static func attachWebView(_ webView: WKWebView, to host: NSView) { // WebKit can crash if a WKWebView (or an internal first-responder object) stays first responder // while being detached/reparented during bonsplit/SwiftUI structural updates. - if let window = webView.window, - responderChainContains(window.firstResponder, target: webView) { - window.makeFirstResponder(nil) + if let window = webView.window { + let state = firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + window.makeFirstResponder(nil) + } + } + + // The target host can already be in-window while the source host is tearing down. + // Re-check against the target window too (it can differ during split churn). + if let window = host.window { + let state = firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + window.makeFirstResponder(nil) + } } // Detach from any previous host (bonsplit/SwiftUI may rearrange views). @@ -2474,15 +2821,11 @@ struct WebViewRepresentable: NSViewRepresentable { host.subviews.forEach { $0.removeFromSuperview() } host.addSubview(webView) - webView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.deactivate(coordinator.constraints) - coordinator.constraints = [ - webView.leadingAnchor.constraint(equalTo: host.leadingAnchor), - webView.trailingAnchor.constraint(equalTo: host.trailingAnchor), - webView.topAnchor.constraint(equalTo: host.topAnchor), - webView.bottomAnchor.constraint(equalTo: host.bottomAnchor), - ] - NSLayoutConstraint.activate(coordinator.constraints) + // Work around WebKit bug 272474 where Inspect Element can render blank/flicker + // when WKWebView is edge-pinned using Auto Layout constraints. + webView.translatesAutoresizingMaskIntoConstraints = true + webView.autoresizingMask = [.width, .height] + webView.frame = host.bounds // Make reparenting resilient: WebKit can occasionally stay visually blank until forced to lay out. webView.needsLayout = true @@ -2491,7 +2834,13 @@ struct WebViewRepresentable: NSViewRepresentable { webView.displayIfNeeded() } - private static func scheduleAttachRetry(_ webView: WKWebView, to host: NSView, coordinator: Coordinator, generation: Int) { + private static func scheduleAttachRetry( + _ webView: WKWebView, + panel: BrowserPanel, + to host: NSView, + coordinator: Coordinator, + generation: Int + ) { // Don't schedule multiple overlapping retries. guard coordinator.attachRetryWorkItem == nil else { return } @@ -2510,18 +2859,54 @@ struct WebViewRepresentable: NSViewRepresentable { // is in a window during bonsplit tree updates; moving the webview too early can be flaky. guard host.window != nil else { coordinator.attachRetryCount += 1 + #if DEBUG + if coordinator.attachRetryCount == 1 || coordinator.attachRetryCount % 20 == 0 { + logDevToolsState( + panel, + event: "retry.waitingForWindow", + generation: generation, + retryCount: coordinator.attachRetryCount, + details: attachContext(webView: webView, host: host) + ) + } + #endif // Be generous here: bonsplit structural updates can keep a representable // container off-window longer than a few seconds under load. if coordinator.attachRetryCount < 400 { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - scheduleAttachRetry(webView, to: host, coordinator: coordinator, generation: generation) + scheduleAttachRetry( + webView, + panel: panel, + to: host, + coordinator: coordinator, + generation: generation + ) } } return } coordinator.attachRetryCount = 0 - attachWebView(webView, to: host, coordinator: coordinator) + #if DEBUG + logDevToolsState( + panel, + event: "retry.attach.begin", + generation: generation, + retryCount: 0, + details: attachContext(webView: webView, host: host) + ) + #endif + attachWebView(webView, to: host) + panel.restoreDeveloperToolsAfterAttachIfNeeded() + #if DEBUG + logDevToolsState( + panel, + event: "retry.attached", + generation: generation, + retryCount: 0, + details: attachContext(webView: webView, host: host) + ) + #endif } coordinator.attachRetryWorkItem = work @@ -2530,30 +2915,106 @@ struct WebViewRepresentable: NSViewRepresentable { func updateNSView(_ nsView: NSView, context: Context) { let webView = panel.webView + context.coordinator.panel = panel context.coordinator.webView = webView + let shouldUseWindowPortal = panel.shouldPreserveWebViewAttachmentDuringTransientHide() + if shouldUseWindowPortal { + context.coordinator.usesWindowPortal = true + Self.clearPortalCallbacks(for: nsView) + updateUsingWindowPortal(nsView, context: context, webView: webView) + Self.applyFocus( + panel: panel, + webView: webView, + nsView: nsView, + shouldFocusWebView: shouldFocusWebView, + isPanelFocused: isPanelFocused + ) + return + } + + if context.coordinator.usesWindowPortal { + BrowserWindowPortalRegistry.detach(webView: webView) + context.coordinator.usesWindowPortal = false + context.coordinator.lastPortalHostId = nil + } + Self.clearPortalCallbacks(for: nsView) + // Bonsplit keepAllAlive keeps hidden tabs alive (opacity 0). WKWebView is fragile when left // in the window hierarchy while hidden and rapidly switching focus between tabs. To reduce // WebKit crashes, detach the WKWebView when this surface is not the selected tab in its pane. if !shouldAttachWebView { + // Split/layout churn can briefly create an off-window phase while DevTools is open. + // Detaching here can blank inspector content even when visibility preference stays true. + if nsView.window == nil, + webView.superview != nil, + panel.shouldPreserveWebViewAttachmentDuringTransientHide() { + #if DEBUG + Self.logDevToolsState( + panel, + event: "detach.skipped.offWindowDevTools", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + ) + #endif + return + } + + #if DEBUG + Self.logDevToolsState( + panel, + event: "detach.beforeSync", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + ) + #endif + panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true) + #if DEBUG + Self.logDevToolsState( + panel, + event: "detach.afterSync", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + ) + #endif context.coordinator.attachRetryWorkItem?.cancel() context.coordinator.attachRetryWorkItem = nil context.coordinator.attachRetryCount = 0 context.coordinator.attachGeneration += 1 // Resign focus if WebKit currently owns first responder. - if let window = webView.window, - Self.responderChainContains(window.firstResponder, target: webView) { - window.makeFirstResponder(nil) + if let window = webView.window ?? nsView.window { + let state = Self.firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + #if DEBUG + Self.logDevToolsState( + panel, + event: "detach.resignFirstResponder", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags + ) + #endif + window.makeFirstResponder(nil) + } } - NSLayoutConstraint.deactivate(context.coordinator.constraints) - context.coordinator.constraints.removeAll() - if webView.superview != nil { webView.removeFromSuperview() } nsView.subviews.forEach { $0.removeFromSuperview() } + #if DEBUG + Self.logDevToolsState( + panel, + event: "detach.done", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + ) + #endif return } @@ -2563,17 +3024,83 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.attachRetryWorkItem = nil context.coordinator.attachGeneration += 1 + if let window = webView.window ?? nsView.window { + let state = Self.firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.reparent.resignFirstResponder.begin", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags + ) + #endif + let resigned = window.makeFirstResponder(nil) + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.reparent.resignFirstResponder.end", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags + " resigned=\(resigned ? 1 : 0)" + ) + #endif + } + } + if nsView.window == nil { // Avoid attaching to off-window containers; during bonsplit structural updates SwiftUI // can create containers that are never inserted into the window. + if panel.shouldPreserveWebViewAttachmentDuringTransientHide() { + panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "attach.defer.offWindow") + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.defer.requestRefresh", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + ) + #endif + } + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.defer.offWindow", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + ) + #endif Self.scheduleAttachRetry( webView, + panel: panel, to: nsView, coordinator: context.coordinator, generation: context.coordinator.attachGeneration ) } else { - Self.attachWebView(webView, to: nsView, coordinator: context.coordinator) + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.immediate.begin", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + ) + #endif + Self.attachWebView(webView, to: nsView) + panel.restoreDeveloperToolsAfterAttachIfNeeded() + #if DEBUG + Self.logDevToolsState( + panel, + event: "attach.immediate", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + ) + #endif } } else { // Already attached; no need for any pending retry. @@ -2581,25 +3108,59 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.attachRetryWorkItem = nil context.coordinator.attachRetryCount = 0 context.coordinator.attachGeneration += 1 + let hadPendingRefresh = panel.hasPendingDeveloperToolsRefreshAfterAttach() + panel.restoreDeveloperToolsAfterAttachIfNeeded() + #if DEBUG + if hadPendingRefresh { + Self.logDevToolsState( + panel, + event: "attach.alreadyAttached.consumePendingRefresh", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + ) + } + Self.logDevToolsState( + panel, + event: "attach.alreadyAttached", + generation: context.coordinator.attachGeneration, + retryCount: context.coordinator.attachRetryCount, + details: Self.attachContext(webView: webView, host: nsView) + ) + #endif } + Self.applyFocus( + panel: panel, + webView: webView, + nsView: nsView, + shouldFocusWebView: shouldFocusWebView, + isPanelFocused: isPanelFocused + ) + } + + private static func applyFocus( + panel: BrowserPanel, + webView: WKWebView, + nsView: NSView, + shouldFocusWebView: Bool, + isPanelFocused: Bool + ) { // Focus handling. Avoid fighting the address bar when it is focused. guard let window = nsView.window else { return } if shouldFocusWebView { if panel.shouldSuppressWebViewFocus() { return } - if Self.responderChainContains(window.firstResponder, target: webView) { + if responderChainContains(window.firstResponder, target: webView) { return } window.makeFirstResponder(webView) - } else { + } else if !isPanelFocused && responderChainContains(window.firstResponder, target: webView) { // Only force-resign WebView focus when this panel itself is not focused. // If the panel is focused but the omnibar-focus state is briefly stale, aggressively // clearing first responder here can undo programmatic webview focus (socket tests). - if !isPanelFocused && Self.responderChainContains(window.firstResponder, target: webView) { - window.makeFirstResponder(nil) - } + window.makeFirstResponder(nil) } } @@ -2608,20 +3169,85 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.attachRetryWorkItem = nil coordinator.attachRetryCount = 0 coordinator.attachGeneration += 1 - - NSLayoutConstraint.deactivate(coordinator.constraints) - coordinator.constraints.removeAll() + clearPortalCallbacks(for: nsView) guard let webView = coordinator.webView else { return } + let panel = coordinator.panel + + if coordinator.usesWindowPortal { + coordinator.usesWindowPortal = false + coordinator.lastPortalHostId = nil + + // During split/layout churn we keep the WKWebView portal-hosted so DevTools + // does not lose state. BrowserPanel deinit explicitly detaches on real teardown. + if let panel, panel.shouldPreserveWebViewAttachmentDuringTransientHide() { + #if DEBUG + logDevToolsState( + panel, + event: "dismantle.portal.keepAttached", + generation: coordinator.attachGeneration, + retryCount: coordinator.attachRetryCount, + details: attachContext(webView: webView, host: nsView) + ) + #endif + return + } + + BrowserWindowPortalRegistry.detach(webView: webView) + return + } // If we're being torn down while the WKWebView (or one of its subviews) is first responder, // resign it before detaching. let window = webView.window ?? nsView.window - if let window, responderChainContains(window.firstResponder, target: webView) { - window.makeFirstResponder(nil) + if let window { + let state = firstResponderResignState(window.firstResponder, webView: webView) + if state.needsResign { + #if DEBUG + if let panel { + logDevToolsState( + panel, + event: "dismantle.resignFirstResponder", + generation: coordinator.attachGeneration, + retryCount: coordinator.attachRetryCount, + details: attachContext(webView: webView, host: nsView) + " " + state.flags + ) + } + #endif + window.makeFirstResponder(nil) + } } + + // During split/layout churn, SwiftUI may tear down a host view while a new one is still + // coming online. When DevTools is intended open, avoid eagerly detaching here. + if let panel, + panel.shouldPreserveWebViewAttachmentDuringTransientHide(), + webView.superview === nsView { + #if DEBUG + logDevToolsState( + panel, + event: "dismantle.skipDetach.devTools", + generation: coordinator.attachGeneration, + retryCount: coordinator.attachRetryCount, + details: attachContext(webView: webView, host: nsView) + ) + #endif + return + } + if webView.superview === nsView { webView.removeFromSuperview() + #if DEBUG + if let panel { + logDevToolsState( + panel, + event: "dismantle.detached", + generation: coordinator.attachGeneration, + retryCount: coordinator.attachRetryCount, + details: attachContext(webView: webView, host: nsView) + ) + } + #endif } } } diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index ed27c263..08843c0f 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -7,6 +7,13 @@ import WebKit /// the first responder. final class CmuxWebView: WKWebView { override func performKeyEquivalent(with event: NSEvent) -> Bool { + // Preserve Cmd+Return/Enter for web content (e.g. editors/forms). Do not + // route it through app/menu key equivalents, which can trigger unintended actions. + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags.contains(.command), event.keyCode == 36 || event.keyCode == 76 { + return false + } + // Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc). if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { return true @@ -110,10 +117,21 @@ final class CmuxWebView: WKWebView { // of SwiftUI's sibling .onDrop overlays. Rejecting in draggingEntered doesn't help because // AppKit only bubbles up through superviews, not siblings. // - // Fix: prevent WKWebView from registering as a drag destination entirely. AppKit won't - // route drags here, so they reach the SwiftUI overlay drop zones as intended. + // Fix: filter out text-based types that conflict with bonsplit tab drags, but keep + // file URL types so Finder file drops and HTML drag-and-drop work. + private static let blockedDragTypes: Set<NSPasteboard.PasteboardType> = [ + .string, // public.utf8-plain-text — matches bonsplit's NSString tab drags + NSPasteboard.PasteboardType("public.text"), + NSPasteboard.PasteboardType("public.plain-text"), + NSPasteboard.PasteboardType("com.splittabbar.tabtransfer"), + NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder"), + ] + override func registerForDraggedTypes(_ newTypes: [NSPasteboard.PasteboardType]) { - // No-op: suppress WKWebView's automatic drag type registration. + let filtered = newTypes.filter { !Self.blockedDragTypes.contains($0) } + if !filtered.isEmpty { + super.registerForDraggedTypes(filtered) + } } override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) { diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index 8612a5bc..1374a5a7 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -37,6 +37,7 @@ struct PanelContentView: View { panel: browserPanel, isFocused: isFocused, isVisibleInUI: isVisibleInUI, + portalPriority: portalPriority, onRequestPanelFocus: onRequestPanelFocus ) } diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 1a133228..b9a9d767 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -82,7 +82,8 @@ final class TerminalPanel: Panel, ObservableObject { workspaceId: UUID, context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: ghostty_surface_config_s? = nil, - workingDirectory: String? = nil + workingDirectory: String? = nil, + portOrdinal: Int = 0 ) { let surface = TerminalSurface( tabId: workspaceId, @@ -90,6 +91,7 @@ final class TerminalPanel: Panel, ObservableObject { configTemplate: configTemplate, workingDirectory: workingDirectory ) + surface.portOrdinal = portOrdinal self.init(workspaceId: workspaceId, surface: surface) } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index df72c4d0..04eb6fec 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -39,6 +39,18 @@ enum NewWorkspacePlacement: String, CaseIterable, Identifiable { } } +enum WorkspaceAutoReorderSettings { + static let key = "workspaceAutoReorderOnNotification" + static let defaultValue = true + + static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: key) == nil { + return defaultValue + } + return defaults.bool(forKey: key) + } +} + enum WorkspacePlacementSettings { static let placementKey = "newWorkspacePlacement" static let defaultPlacement: NewWorkspacePlacement = .afterCurrent @@ -226,6 +238,10 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback( class TabManager: ObservableObject { @Published var tabs: [Workspace] = [] @Published private(set) var isWorkspaceCycleHot: Bool = false + + /// Global monotonically increasing counter for CMUX_PORT ordinal assignment. + /// Static so port ranges don't overlap across multiple windows (each window has its own TabManager). + private static var nextPortOrdinal: Int = 0 @Published var selectedTabId: UUID? { didSet { guard selectedTabId != oldValue else { return } @@ -394,7 +410,9 @@ class TabManager: ObservableObject { @discardableResult func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil) -> Workspace { let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab() - let newWorkspace = Workspace(title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory) + let ordinal = Self.nextPortOrdinal + Self.nextPortOrdinal += 1 + let newWorkspace = Workspace(title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory, portOrdinal: ordinal) let insertIndex = newTabInsertIndex() if insertIndex >= 0 && insertIndex <= tabs.count { tabs.insert(newWorkspace, at: insertIndex) @@ -823,6 +841,16 @@ class TabManager: ObservableObject { focusedBrowserPanel?.resetZoom() ?? false } + @discardableResult + func toggleDeveloperToolsFocusedBrowser() -> Bool { + focusedBrowserPanel?.toggleDeveloperTools() ?? false + } + + @discardableResult + func showJavaScriptConsoleFocusedBrowser() -> Bool { + focusedBrowserPanel?.showDeveloperToolsConsole() ?? false + } + /// Backwards compatibility: returns the focused surface ID func focusedSurfaceId(for tabId: UUID) -> UUID? { focusedPanelId(for: tabId) @@ -1247,6 +1275,28 @@ class TabManager: ObservableObject { _ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) } + /// Create a new browser split from the currently focused panel. + @discardableResult + func createBrowserSplit(direction: SplitDirection, url: URL? = nil) -> UUID? { + guard let selectedTabId, + let tab = tabs.first(where: { $0.id == selectedTabId }), + let focusedPanelId = tab.focusedPanelId else { return nil } + return newBrowserSplit( + tabId: selectedTabId, + fromPanelId: focusedPanelId, + orientation: direction.orientation, + insertFirst: direction.insertFirst, + url: url + ) + } + + /// Refresh Bonsplit right-side action button tooltips for all workspaces. + func refreshSplitButtonTooltips() { + for workspace in tabs { + workspace.refreshSplitButtonTooltips() + } + } + // MARK: - Pane Focus Navigation /// Move focus to an adjacent pane in the specified direction @@ -1387,9 +1437,20 @@ class TabManager: ObservableObject { // MARK: - Browser Panel Operations /// Create a new browser panel in a split - func newBrowserSplit(tabId: UUID, fromPanelId: UUID, orientation: SplitOrientation, url: URL? = nil) -> UUID? { + func newBrowserSplit( + tabId: UUID, + fromPanelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool = false, + url: URL? = nil + ) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } - return tab.newBrowserSplit(from: fromPanelId, orientation: orientation, url: url)?.id + return tab.newBrowserSplit( + from: fromPanelId, + orientation: orientation, + insertFirst: insertFirst, + url: url + )?.id } /// Create a new browser surface in a pane diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 96166d03..ad779aa2 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -461,6 +461,9 @@ class TerminalController { case "reset_sidebar": return resetSidebar(args) + case "read_screen": + return readScreenText(args) + #if DEBUG case "set_shortcut": @@ -496,9 +499,6 @@ class TerminalController { case "read_terminal_text": return readTerminalText(args) - case "read_screen": - return readScreen(args) - case "render_stats": return renderStats(args) @@ -667,6 +667,14 @@ class TerminalController { return v2Result(id: id, self.v2WorkspaceMoveToWindow(params: params)) case "workspace.reorder": return v2Result(id: id, self.v2WorkspaceReorder(params: params)) + case "workspace.rename": + return v2Result(id: id, self.v2WorkspaceRename(params: params)) + case "workspace.next": + return v2Result(id: id, self.v2WorkspaceNext(params: params)) + case "workspace.previous": + return v2Result(id: id, self.v2WorkspacePrevious(params: params)) + case "workspace.last": + return v2Result(id: id, self.v2WorkspaceLast(params: params)) // Surfaces / input @@ -696,6 +704,8 @@ class TerminalController { return v2Result(id: id, self.v2SurfaceSendText(params: params)) case "surface.send_key": return v2Result(id: id, self.v2SurfaceSendKey(params: params)) + case "surface.clear_history": + return v2Result(id: id, self.v2SurfaceClearHistory(params: params)) case "surface.trigger_flash": return v2Result(id: id, self.v2SurfaceTriggerFlash(params: params)) @@ -708,6 +718,16 @@ class TerminalController { return v2Result(id: id, self.v2PaneSurfaces(params: params)) case "pane.create": return v2Result(id: id, self.v2PaneCreate(params: params)) + case "pane.resize": + return v2Result(id: id, self.v2PaneResize(params: params)) + case "pane.swap": + return v2Result(id: id, self.v2PaneSwap(params: params)) + case "pane.break": + return v2Result(id: id, self.v2PaneBreak(params: params)) + case "pane.join": + return v2Result(id: id, self.v2PaneJoin(params: params)) + case "pane.last": + return v2Result(id: id, self.v2PaneLast(params: params)) // Notifications case "notification.create": @@ -896,6 +916,8 @@ class TerminalController { return v2Result(id: id, self.v2BrowserInputKeyboard(params: params)) case "browser.input_touch": return v2Result(id: id, self.v2BrowserInputTouch(params: params)) + case "surface.read_text": + return v2Result(id: id, self.v2SurfaceReadText(params: params)) #if DEBUG @@ -960,6 +982,10 @@ class TerminalController { "workspace.close", "workspace.move_to_window", "workspace.reorder", + "workspace.rename", + "workspace.next", + "workspace.previous", + "workspace.last", "surface.list", "surface.current", "surface.focus", @@ -973,11 +999,18 @@ class TerminalController { "surface.health", "surface.send_text", "surface.send_key", + "surface.read_text", + "surface.clear_history", "surface.trigger_flash", "pane.list", "pane.focus", "pane.surfaces", "pane.create", + "pane.resize", + "pane.swap", + "pane.break", + "pane.join", + "pane.last", "notification.create", "notification.create_for_surface", "notification.create_for_target", @@ -1683,6 +1716,116 @@ class TerminalController { "index": v2OrNull(newIndex) ]) } + private func v2WorkspaceRename(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let workspaceId = v2UUID(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + guard let titleRaw = v2String(params, "title"), + !titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return .err(code: "invalid_params", message: "Missing or invalid title", data: nil) + } + + let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines) + var renamed = false + v2MainSync { + guard tabManager.tabs.contains(where: { $0.id == workspaceId }) else { return } + tabManager.setCustomTitle(tabId: workspaceId, title: title) + renamed = true + } + + guard renamed else { + return .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId) + ]) + } + + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "title": title + ]) + } + private func v2WorkspaceNext(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) + v2MainSync { + guard tabManager.selectedTabId != nil else { return } + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + tabManager.selectNextTab() + guard let workspaceId = tabManager.selectedTabId else { return } + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId) + ]) + } + return result + } + + private func v2WorkspacePrevious(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) + v2MainSync { + guard tabManager.selectedTabId != nil else { return } + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + tabManager.selectPreviousTab() + guard let workspaceId = tabManager.selectedTabId else { return } + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId) + ]) + } + return result + } + + private func v2WorkspaceLast(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "No previous workspace in history", data: nil) + v2MainSync { + guard let before = tabManager.selectedTabId else { return } + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + tabManager.navigateBack() + guard let after = tabManager.selectedTabId, after != before else { return } + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "workspace_id": after.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: after), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId) + ]) + } + return result + } // MARK: - V2 Surface Methods @@ -2387,6 +2530,155 @@ class TerminalController { return result } + private func v2SurfaceClearHistory(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to clear history", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId + guard let surfaceId else { + result = .err(code: "not_found", message: "No focused surface", data: nil) + return + } + guard let terminalPanel = ws.terminalPanel(for: surfaceId) else { + result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) + return + } + + guard terminalPanel.performBindingAction("clear_screen") else { + result = .err(code: "not_supported", message: "clear_screen binding action is unavailable", data: nil) + return + } + + terminalPanel.surface.forceRefresh() + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId) + ]) + } + + return result + } + + private func v2SurfaceReadText(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var includeScrollback = v2Bool(params, "scrollback") ?? false + let lineLimit = v2Int(params, "lines") + if let lineLimit, lineLimit <= 0 { + return .err(code: "invalid_params", message: "lines must be greater than 0", data: nil) + } + if lineLimit != nil { + includeScrollback = true + } + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to read terminal text", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + + let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId + guard let surfaceId else { + result = .err(code: "not_found", message: "No focused surface", data: nil) + return + } + guard let terminalPanel = ws.terminalPanel(for: surfaceId) else { + result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) + return + } + + let response = readTerminalTextBase64( + terminalPanel: terminalPanel, + includeScrollback: includeScrollback, + lineLimit: lineLimit + ) + guard response.hasPrefix("OK ") else { + result = .err(code: "internal_error", message: response, data: nil) + return + } + let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) + let decoded = Data(base64Encoded: base64).flatMap { String(data: $0, encoding: .utf8) } + guard let text = decoded ?? (base64.isEmpty ? "" : nil) else { + result = .err(code: "internal_error", message: "Failed to decode terminal text", data: nil) + return + } + + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "text": text, + "base64": base64, + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId) + ]) + } + return result + } + + private func readTerminalTextBase64(terminalPanel: TerminalPanel, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String { + guard let surface = terminalPanel.surface.surface else { return "ERROR: Terminal surface not found" } + + let pointTag: ghostty_point_tag_e = includeScrollback ? GHOSTTY_POINT_SCREEN : GHOSTTY_POINT_VIEWPORT + let topLeft = ghostty_point_s( + tag: pointTag, + coord: GHOSTTY_POINT_COORD_TOP_LEFT, + x: 0, + y: 0 + ) + let bottomRight = ghostty_point_s( + tag: pointTag, + coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, + x: 0, + y: 0 + ) + let selection = ghostty_selection_s( + top_left: topLeft, + bottom_right: bottomRight, + rectangle: true + ) + var text = ghostty_text_s() + + guard ghostty_surface_read_text(surface, selection, &text) else { + return "ERROR: Failed to read terminal text" + } + defer { + ghostty_surface_free_text(surface, &text) + } + + let rawData: Data + if let ptr = text.text, text.text_len > 0 { + rawData = Data(bytes: ptr, count: Int(text.text_len)) + } else { + rawData = Data() + } + + var output = String(decoding: rawData, as: UTF8.self) + if let lineLimit { + output = tailTerminalLines(output, maxLines: lineLimit) + } + + let base64 = output.data(using: .utf8)?.base64EncodedString() ?? "" + return "OK \(base64)" + } + private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -2614,6 +2906,436 @@ class TerminalController { return result } + private enum V2PaneResizeDirection: String { + case left + case right + case up + case down + + var splitOrientation: String { + switch self { + case .left, .right: + return "horizontal" + case .up, .down: + return "vertical" + } + } + + /// A split controls the target pane's right/bottom edge when target is first child, + /// and left/top edge when target is second child. + var requiresPaneInFirstChild: Bool { + switch self { + case .right, .down: + return true + case .left, .up: + return false + } + } + + /// Positive value moves divider toward second child (right/down). + var dividerDeltaSign: CGFloat { + requiresPaneInFirstChild ? 1 : -1 + } + } + + private struct V2PaneResizeCandidate { + let splitId: UUID + let orientation: String + let paneInFirstChild: Bool + let dividerPosition: CGFloat + let axisPixels: CGFloat + } + + private struct V2PaneResizeTrace { + let containsTarget: Bool + let bounds: CGRect + } + + private func v2PaneResizeCollectCandidates( + node: ExternalTreeNode, + targetPaneId: String, + candidates: inout [V2PaneResizeCandidate] + ) -> V2PaneResizeTrace { + switch node { + case .pane(let pane): + let bounds = CGRect( + x: pane.frame.x, + y: pane.frame.y, + width: pane.frame.width, + height: pane.frame.height + ) + return V2PaneResizeTrace(containsTarget: pane.id == targetPaneId, bounds: bounds) + + case .split(let split): + let first = v2PaneResizeCollectCandidates( + node: split.first, + targetPaneId: targetPaneId, + candidates: &candidates + ) + let second = v2PaneResizeCollectCandidates( + node: split.second, + targetPaneId: targetPaneId, + candidates: &candidates + ) + + let combinedBounds = first.bounds.union(second.bounds) + let containsTarget = first.containsTarget || second.containsTarget + + if containsTarget, + let splitUUID = UUID(uuidString: split.id) { + let orientation = split.orientation.lowercased() + let axisPixels: CGFloat = orientation == "horizontal" + ? combinedBounds.width + : combinedBounds.height + candidates.append(V2PaneResizeCandidate( + splitId: splitUUID, + orientation: orientation, + paneInFirstChild: first.containsTarget, + dividerPosition: CGFloat(split.dividerPosition), + axisPixels: max(axisPixels, 1) + )) + } + + return V2PaneResizeTrace(containsTarget: containsTarget, bounds: combinedBounds) + } + } + + private func v2PaneResize(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + let directionRaw = (v2String(params, "direction") ?? "").lowercased() + let amount = v2Int(params, "amount") ?? 1 + guard let direction = V2PaneResizeDirection(rawValue: directionRaw), amount > 0 else { + return .err(code: "invalid_params", message: "direction must be one of left|right|up|down and amount must be > 0", data: nil) + } + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to resize pane", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + + let paneUUID = v2UUID(params, "pane_id") ?? ws.bonsplitController.focusedPaneId?.id + guard let paneUUID else { + result = .err(code: "not_found", message: "No focused pane", data: nil) + return + } + guard ws.bonsplitController.allPaneIds.contains(where: { $0.id == paneUUID }) else { + result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString]) + return + } + + let tree = ws.bonsplitController.treeSnapshot() + var candidates: [V2PaneResizeCandidate] = [] + let trace = v2PaneResizeCollectCandidates( + node: tree, + targetPaneId: paneUUID.uuidString, + candidates: &candidates + ) + guard trace.containsTarget else { + result = .err(code: "not_found", message: "Pane not found in split tree", data: ["pane_id": paneUUID.uuidString]) + return + } + + let orientationMatches = candidates.filter { $0.orientation == direction.splitOrientation } + guard !orientationMatches.isEmpty else { + result = .err( + code: "invalid_state", + message: "No \(direction.splitOrientation) split ancestor for pane", + data: ["pane_id": paneUUID.uuidString, "direction": direction.rawValue] + ) + return + } + + guard let candidate = orientationMatches.first(where: { $0.paneInFirstChild == direction.requiresPaneInFirstChild }) else { + result = .err( + code: "invalid_state", + message: "Pane has no adjacent border in direction \(direction.rawValue)", + data: ["pane_id": paneUUID.uuidString, "direction": direction.rawValue] + ) + return + } + + let delta = CGFloat(amount) / candidate.axisPixels + let requested = candidate.dividerPosition + (direction.dividerDeltaSign * delta) + let clamped = min(max(requested, 0.1), 0.9) + guard ws.bonsplitController.setDividerPosition(clamped, forSplit: candidate.splitId, fromExternal: true) else { + result = .err( + code: "internal_error", + message: "Failed to set split divider position", + data: ["split_id": candidate.splitId.uuidString] + ) + return + } + + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "pane_id": paneUUID.uuidString, + "pane_ref": v2Ref(kind: .pane, uuid: paneUUID), + "split_id": candidate.splitId.uuidString, + "direction": direction.rawValue, + "amount": amount, + "old_divider_position": candidate.dividerPosition, + "new_divider_position": clamped + ]) + } + return result + } + + private func v2PaneSwap(params: [String: Any]) -> V2CallResult { + guard let sourcePaneUUID = v2UUID(params, "pane_id") else { + return .err(code: "invalid_params", message: "Missing or invalid pane_id", data: nil) + } + guard let targetPaneUUID = v2UUID(params, "target_pane_id") else { + return .err(code: "invalid_params", message: "Missing or invalid target_pane_id", data: nil) + } + if sourcePaneUUID == targetPaneUUID { + return .err(code: "invalid_params", message: "pane_id and target_pane_id must be different", data: nil) + } + let focus = v2Bool(params, "focus") ?? true + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to swap panes", data: nil) + v2MainSync { + guard let located = v2LocatePane(sourcePaneUUID) else { + result = .err(code: "not_found", message: "Source pane not found", data: ["pane_id": sourcePaneUUID.uuidString]) + return + } + guard let targetPane = located.workspace.bonsplitController.allPaneIds.first(where: { $0.id == targetPaneUUID }) else { + result = .err(code: "not_found", message: "Target pane not found in source workspace", data: ["target_pane_id": targetPaneUUID.uuidString]) + return + } + let workspace = located.workspace + let sourcePane = located.paneId + + guard let selectedSourceTab = workspace.bonsplitController.selectedTab(inPane: sourcePane), + let selectedTargetTab = workspace.bonsplitController.selectedTab(inPane: targetPane), + let sourceSurfaceId = workspace.panelIdFromSurfaceId(selectedSourceTab.id), + let targetSurfaceId = workspace.panelIdFromSurfaceId(selectedTargetTab.id) else { + result = .err(code: "invalid_state", message: "Both panes must have a selected surface", data: nil) + return + } + + // Keep pane identities stable during swap when one side has a single surface. + var sourcePlaceholder: UUID? + var targetPlaceholder: UUID? + if workspace.bonsplitController.tabs(inPane: sourcePane).count <= 1 { + sourcePlaceholder = workspace.newTerminalSurface(inPane: sourcePane, focus: false)?.id + if sourcePlaceholder == nil { + result = .err(code: "internal_error", message: "Failed to create source placeholder surface", data: nil) + return + } + } + if workspace.bonsplitController.tabs(inPane: targetPane).count <= 1 { + targetPlaceholder = workspace.newTerminalSurface(inPane: targetPane, focus: false)?.id + if targetPlaceholder == nil { + result = .err(code: "internal_error", message: "Failed to create target placeholder surface", data: nil) + return + } + } + + guard workspace.moveSurface(panelId: sourceSurfaceId, toPane: targetPane, focus: false) else { + result = .err(code: "internal_error", message: "Failed moving source surface into target pane", data: nil) + return + } + guard workspace.moveSurface(panelId: targetSurfaceId, toPane: sourcePane, focus: false) else { + result = .err(code: "internal_error", message: "Failed moving target surface into source pane", data: nil) + return + } + + if let sourcePlaceholder { + _ = workspace.closePanel(sourcePlaceholder, force: true) + } + if let targetPlaceholder { + _ = workspace.closePanel(targetPlaceholder, force: true) + } + + if focus { + workspace.bonsplitController.focusPane(targetPane) + } + let windowId = located.windowId + result = .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "pane_id": sourcePane.id.uuidString, + "pane_ref": v2Ref(kind: .pane, uuid: sourcePane.id), + "target_pane_id": targetPane.id.uuidString, + "target_pane_ref": v2Ref(kind: .pane, uuid: targetPane.id), + "source_surface_id": sourceSurfaceId.uuidString, + "source_surface_ref": v2Ref(kind: .surface, uuid: sourceSurfaceId), + "target_surface_id": targetSurfaceId.uuidString, + "target_surface_ref": v2Ref(kind: .surface, uuid: targetSurfaceId) + ]) + } + return result + } + + private func v2PaneBreak(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let focus = v2Bool(params, "focus") ?? true + + var result: V2CallResult = .err(code: "internal_error", message: "Failed to break pane", data: nil) + v2MainSync { + guard let sourceWorkspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + + let sourcePaneUUID = v2UUID(params, "pane_id") + let sourcePane: PaneID? = { + if let sourcePaneUUID { + return sourceWorkspace.bonsplitController.allPaneIds.first(where: { $0.id == sourcePaneUUID }) + } + return sourceWorkspace.bonsplitController.focusedPaneId + }() + + let surfaceId: UUID? = { + if let explicitSurface = v2UUID(params, "surface_id") { return explicitSurface } + if let sourcePane, + let selected = sourceWorkspace.bonsplitController.selectedTab(inPane: sourcePane) { + return sourceWorkspace.panelIdFromSurfaceId(selected.id) + } + return sourceWorkspace.focusedPanelId + }() + guard let surfaceId else { + result = .err(code: "not_found", message: "No source surface to break", data: nil) + return + } + guard sourceWorkspace.panels[surfaceId] != nil else { + result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) + return + } + let sourceIndex = sourceWorkspace.indexInPane(forPanelId: surfaceId) + let sourcePaneForRollback = sourceWorkspace.paneId(forPanelId: surfaceId) + + guard let detached = sourceWorkspace.detachSurface(panelId: surfaceId) else { + result = .err(code: "internal_error", message: "Failed to detach source surface", data: nil) + return + } + + let destinationWorkspace = tabManager.addWorkspace() + guard let destinationPane = destinationWorkspace.bonsplitController.focusedPaneId + ?? destinationWorkspace.bonsplitController.allPaneIds.first else { + if let sourcePaneForRollback { + _ = sourceWorkspace.attachDetachedSurface( + detached, + inPane: sourcePaneForRollback, + atIndex: sourceIndex, + focus: true + ) + } + result = .err(code: "internal_error", message: "Destination workspace has no pane", data: nil) + return + } + + guard destinationWorkspace.attachDetachedSurface(detached, inPane: destinationPane, focus: focus) != nil else { + if let sourcePaneForRollback { + _ = sourceWorkspace.attachDetachedSurface( + detached, + inPane: sourcePaneForRollback, + atIndex: sourceIndex, + focus: true + ) + } + result = .err(code: "internal_error", message: "Failed to attach surface to new workspace", data: nil) + return + } + + if !focus { + tabManager.selectWorkspace(sourceWorkspace) + } + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": destinationWorkspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: destinationWorkspace.id), + "pane_id": destinationPane.id.uuidString, + "pane_ref": v2Ref(kind: .pane, uuid: destinationPane.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId) + ]) + } + return result + } + + private func v2PaneJoin(params: [String: Any]) -> V2CallResult { + guard let targetPaneUUID = v2UUID(params, "target_pane_id") else { + return .err(code: "invalid_params", message: "Missing or invalid target_pane_id", data: nil) + } + + var surfaceId = v2UUID(params, "surface_id") + if surfaceId == nil, let sourcePaneUUID = v2UUID(params, "pane_id") { + guard let sourceLocated = v2LocatePane(sourcePaneUUID), + let selected = sourceLocated.workspace.bonsplitController.selectedTab(inPane: sourceLocated.paneId), + let selectedSurface = sourceLocated.workspace.panelIdFromSurfaceId(selected.id) else { + return .err(code: "not_found", message: "Unable to resolve selected surface in source pane", data: [ + "pane_id": sourcePaneUUID.uuidString + ]) + } + surfaceId = selectedSurface + } + guard let surfaceId else { + return .err(code: "invalid_params", message: "Missing surface_id (or pane_id with selected surface)", data: nil) + } + + var moveParams: [String: Any] = [ + "surface_id": surfaceId.uuidString, + "pane_id": targetPaneUUID.uuidString + ] + if let focus = v2Bool(params, "focus") { + moveParams["focus"] = focus + } + return v2SurfaceMove(params: moveParams) + } + + private func v2PaneLast(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "No alternate pane available", data: nil) + v2MainSync { + guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + result = .err(code: "not_found", message: "Workspace not found", data: nil) + return + } + guard let focused = ws.bonsplitController.focusedPaneId else { + result = .err(code: "not_found", message: "No focused pane", data: nil) + return + } + guard let target = ws.bonsplitController.allPaneIds.first(where: { $0.id != focused.id }) else { + result = .err(code: "not_found", message: "No alternate pane available", data: nil) + return + } + + ws.bonsplitController.focusPane(target) + let selectedSurfaceId = ws.bonsplitController.selectedTab(inPane: target).flatMap { ws.panelIdFromSurfaceId($0.id) } + let windowId = v2ResolveWindowId(tabManager: tabManager) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "pane_id": target.id.uuidString, + "pane_ref": v2Ref(kind: .pane, uuid: target.id), + "surface_id": v2OrNull(selectedSurfaceId?.uuidString), + "surface_ref": v2Ref(kind: .surface, uuid: selectedSurfaceId) + ]) + } + return result + } + // MARK: - V2 Notification Methods private func v2NotificationCreate(params: [String: Any]) -> V2CallResult { @@ -6193,6 +6915,123 @@ class TerminalController { } #endif + private struct ReadScreenOptions { + let surfaceArg: String + let includeScrollback: Bool + let lineLimit: Int? + } + + private struct ReadScreenParseError: Error { + let message: String + } + + private func parseReadScreenArgs(_ args: String) -> Result<ReadScreenOptions, ReadScreenParseError> { + let tokens = args + .split(whereSeparator: { $0.isWhitespace }) + .map(String.init) + var surfaceArg: String? + var includeScrollback = false + var lineLimit: Int? + var idx = 0 + + while idx < tokens.count { + let token = tokens[idx] + switch token { + case "--scrollback": + includeScrollback = true + idx += 1 + case "--lines": + guard idx + 1 < tokens.count, let parsed = Int(tokens[idx + 1]), parsed > 0 else { + return .failure(ReadScreenParseError(message: "ERROR: --lines must be greater than 0")) + } + lineLimit = parsed + includeScrollback = true + idx += 2 + default: + guard surfaceArg == nil else { + return .failure(ReadScreenParseError(message: "ERROR: Usage: read_screen [id|idx] [--scrollback] [--lines <n>]")) + } + surfaceArg = token + idx += 1 + } + } + + return .success( + ReadScreenOptions( + surfaceArg: surfaceArg ?? "", + includeScrollback: includeScrollback, + lineLimit: lineLimit + ) + ) + } + + private func tailTerminalLines(_ text: String, maxLines: Int) -> String { + guard maxLines > 0 else { return "" } + let lines = text.split(separator: "\n", omittingEmptySubsequences: false) + guard lines.count > maxLines else { return text } + return lines.suffix(maxLines).joined(separator: "\n") + } + + private func readTerminalTextBase64(surfaceArg: String, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String { + guard let tabManager = tabManager else { return "ERROR: TabManager not available" } + + let trimmedSurfaceArg = surfaceArg.trimmingCharacters(in: .whitespacesAndNewlines) + var result = "ERROR: No tab selected" + DispatchQueue.main.sync { + guard let tabId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { + return + } + + let panelId: UUID? + if trimmedSurfaceArg.isEmpty { + panelId = tab.focusedPanelId + } else { + panelId = resolveSurfaceId(from: trimmedSurfaceArg, tab: tab) + } + + guard let panelId, + let terminalPanel = tab.terminalPanel(for: panelId) else { + result = "ERROR: Terminal surface not found" + return + } + + result = readTerminalTextBase64( + terminalPanel: terminalPanel, + includeScrollback: includeScrollback, + lineLimit: lineLimit + ) + } + return result + } + + private func readScreenText(_ args: String) -> String { + let options: ReadScreenOptions + switch parseReadScreenArgs(args) { + case .success(let parsed): + options = parsed + case .failure(let error): + return error.message + } + + let response = readTerminalTextBase64( + surfaceArg: options.surfaceArg, + includeScrollback: options.includeScrollback, + lineLimit: options.lineLimit + ) + guard response.hasPrefix("OK ") else { return response } + + let payload = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) + if payload.isEmpty { + return "" + } + + guard let data = Data(base64Encoded: payload) else { + return "ERROR: Failed to decode terminal text" + } + return String(decoding: data, as: UTF8.self) + } + private func helpText() -> String { var text = """ Hierarchy: Workspace (sidebar tab) > Pane (split region) > Surface (nested tab) > Panel (terminal/browser) @@ -6225,6 +7064,7 @@ class TerminalController { send_key <key> - Send special key (ctrl-c, ctrl-d, enter, tab, escape) send_surface <id|idx> <text> - Send text to a specific terminal send_key_surface <id|idx> <key> - Send special key to a specific terminal + read_screen [id|idx] [--scrollback] [--lines N] - Read terminal text (plain text) Notification commands: notify <title>|<subtitle>|<body> - Notify focused panel @@ -6282,7 +7122,6 @@ class TerminalController { activate_app - Bring app + main window to front (test-only) is_terminal_focused <id|idx> - Return true/false if terminal surface is first responder (test-only) read_terminal_text [id|idx] - Read visible terminal text (base64, test-only) - read_screen [id|idx] - Read visible terminal text (plain text, legacy test-only) render_stats [id|idx] - Read terminal render stats (draw counters, test-only) layout_debug - Dump bonsplit layout + selected panel bounds (test-only) bonsplit_underflow_count - Count bonsplit arranged-subview underflow events (test-only) @@ -6638,82 +7477,7 @@ class TerminalController { } private func readTerminalText(_ args: String) -> String { - guard let tabManager = tabManager else { return "ERROR: TabManager not available" } - - let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines) - - var result = "ERROR: No tab selected" - DispatchQueue.main.sync { - guard let tabId = tabManager.selectedTabId, - let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { - return - } - - let panelId: UUID? - if panelArg.isEmpty { - panelId = tab.focusedPanelId - } else { - panelId = resolveSurfaceId(from: panelArg, tab: tab) - } - - guard let panelId, - let terminalPanel = tab.terminalPanel(for: panelId), - let surface = terminalPanel.surface.surface else { - result = "ERROR: Terminal surface not found" - return - } - - var selection = ghostty_selection_s( - top_left: ghostty_point_s( - tag: GHOSTTY_POINT_VIEWPORT, - coord: GHOSTTY_POINT_COORD_TOP_LEFT, - x: 0, - y: 0 - ), - bottom_right: ghostty_point_s( - tag: GHOSTTY_POINT_VIEWPORT, - coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, - x: 0, - y: 0 - ), - rectangle: true - ) - var text = ghostty_text_s() - - guard ghostty_surface_read_text(surface, selection, &text) else { - result = "ERROR: Failed to read terminal text" - return - } - defer { - ghostty_surface_free_text(surface, &text) - } - - let b64: String - if let ptr = text.text, text.text_len > 0 { - b64 = Data(bytes: ptr, count: Int(text.text_len)).base64EncodedString() - } else { - b64 = "" - } - - result = "OK \(b64)" - } - return result - } - - private func readScreen(_ args: String) -> String { - let response = readTerminalText(args) - guard response.hasPrefix("OK ") else { return response } - - let payload = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) - if payload.isEmpty { - return "" - } - - guard let data = Data(base64Encoded: payload), - let text = String(data: data, encoding: .utf8) else { - return "ERROR: Failed to decode terminal text" - } - return text + readTerminalTextBase64(surfaceArg: args) } private struct RenderStatsResponse: Codable { diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index cafe7230..35060ebe 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -154,6 +154,10 @@ final class TerminalNotificationStore: ObservableObject { return } + if WorkspaceAutoReorderSettings.isEnabled() { + AppDelegate.shared?.tabManager?.moveTabToTop(tabId) + } + let notification = TerminalNotification( id: UUID(), tabId: tabId, diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index 876a7617..32e2304c 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -1,6 +1,17 @@ import Sparkle import Cocoa +enum UpdateFeedResolver { + static let fallbackFeedURL = "https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml" + + static func resolvedFeedURLString(infoFeedURL: String?) -> (url: String, isNightly: Bool, usedFallback: Bool) { + guard let infoFeedURL, !infoFeedURL.isEmpty else { + return (fallbackFeedURL, false, true) + } + return (infoFeedURL, infoFeedURL.contains("/nightly/"), false) + } +} + extension UpdateDriver: SPUUpdaterDelegate { func feedURLString(for updater: SPUUpdater) -> String? { #if DEBUG @@ -14,12 +25,11 @@ extension UpdateDriver: SPUUpdaterDelegate { // The feed URL is baked into Info.plist at build time: // - Stable releases use the stable appcast URL // - cmux NIGHTLY has the nightly appcast URL injected by CI - let feedURL = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String - let isNightly = feedURL?.contains("/nightly/") == true - UpdateLogStore.shared.append("update channel: \(isNightly ? "nightly" : "stable")") - let usedFallback = feedURL == nil || feedURL?.isEmpty == true - recordFeedURLString(feedURL ?? "https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml", usedFallback: usedFallback) - return feedURL + let infoFeedURL = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String + let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeedURL) + UpdateLogStore.shared.append("update channel: \(resolved.isNightly ? "nightly" : "stable")") + recordFeedURLString(resolved.url, usedFallback: resolved.usedFallback) + return infoFeedURL } /// Called when an update is scheduled to install silently, diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 5c96e1be..ff73c91a 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -276,6 +276,12 @@ struct TitlebarControlsView: View { controlsGroup(config: config) .padding(.leading, 4) .padding(.trailing, titlebarHintTrailingInset) + .background( + WindowAccessor { window in + commandKeyMonitor.setHostWindow(window) + } + .frame(width: 0, height: 0) + ) .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in shortcutRefreshTick &+= 1 } @@ -495,28 +501,63 @@ struct TitlebarControlsView: View { private final class TitlebarCommandKeyMonitor: ObservableObject { @Published private(set) var isCommandPressed = false + private weak var hostWindow: NSWindow? + private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? + private var hostWindowDidResignKeyObserver: NSObjectProtocol? private var flagsMonitor: Any? private var keyDownMonitor: Any? - private var resignObserver: NSObjectProtocol? + private var appResignObserver: NSObjectProtocol? private var pendingShowWorkItem: DispatchWorkItem? + func setHostWindow(_ window: NSWindow?) { + guard hostWindow !== window else { return } + removeHostWindowObservers() + hostWindow = window + guard let window else { + cancelPendingHintShow(resetVisible: true) + return + } + + hostWindowDidBecomeKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.update(from: NSEvent.modifierFlags, eventWindow: nil) + } + } + + hostWindowDidResignKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.cancelPendingHintShow(resetVisible: true) + } + } + + update(from: NSEvent.modifierFlags, eventWindow: nil) + } + func start() { guard flagsMonitor == nil else { - update(from: NSEvent.modifierFlags) + update(from: NSEvent.modifierFlags, eventWindow: nil) return } flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in - self?.update(from: event.modifierFlags) + self?.update(from: event.modifierFlags, eventWindow: event.window) return event } keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - self?.cancelPendingHintShow(resetVisible: true) + self?.handleKeyDown(event) return event } - resignObserver = NotificationCenter.default.addObserver( + appResignObserver = NotificationCenter.default.addObserver( forName: NSApplication.didResignActiveNotification, object: nil, queue: .main @@ -526,7 +567,7 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { } } - update(from: NSEvent.modifierFlags) + update(from: NSEvent.modifierFlags, eventWindow: nil) } func stop() { @@ -538,15 +579,36 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { NSEvent.removeMonitor(keyDownMonitor) self.keyDownMonitor = nil } - if let resignObserver { - NotificationCenter.default.removeObserver(resignObserver) - self.resignObserver = nil + if let appResignObserver { + NotificationCenter.default.removeObserver(appResignObserver) + self.appResignObserver = nil } + removeHostWindowObservers() cancelPendingHintShow(resetVisible: true) } - private func update(from modifierFlags: NSEvent.ModifierFlags) { - guard SidebarCommandHintPolicy.shouldShowHints(for: modifierFlags) else { + private func handleKeyDown(_ event: NSEvent) { + guard isCurrentWindow(eventWindow: event.window) else { return } + cancelPendingHintShow(resetVisible: true) + } + + private func isCurrentWindow(eventWindow: NSWindow?) -> Bool { + SidebarCommandHintPolicy.isCurrentWindow( + hostWindowNumber: hostWindow?.windowNumber, + hostWindowIsKey: hostWindow?.isKeyWindow ?? false, + eventWindowNumber: eventWindow?.windowNumber, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) + } + + private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) { + guard SidebarCommandHintPolicy.shouldShowHints( + for: modifierFlags, + hostWindowNumber: hostWindow?.windowNumber, + hostWindowIsKey: hostWindow?.isKeyWindow ?? false, + eventWindowNumber: eventWindow?.windowNumber, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { cancelPendingHintShow(resetVisible: true) return } @@ -561,7 +623,13 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { let workItem = DispatchWorkItem { [weak self] in guard let self else { return } self.pendingShowWorkItem = nil - guard SidebarCommandHintPolicy.shouldShowHints(for: NSEvent.modifierFlags) else { return } + guard SidebarCommandHintPolicy.shouldShowHints( + for: NSEvent.modifierFlags, + hostWindowNumber: self.hostWindow?.windowNumber, + hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false, + eventWindowNumber: nil, + keyWindowNumber: NSApp.keyWindow?.windowNumber + ) else { return } self.isCommandPressed = true } @@ -576,6 +644,17 @@ private final class TitlebarCommandKeyMonitor: ObservableObject { isCommandPressed = false } } + + private func removeHostWindowObservers() { + if let hostWindowDidBecomeKeyObserver { + NotificationCenter.default.removeObserver(hostWindowDidBecomeKeyObserver) + self.hostWindowDidBecomeKeyObserver = nil + } + if let hostWindowDidResignKeyObserver { + NotificationCenter.default.removeObserver(hostWindowDidResignKeyObserver) + self.hostWindowDidResignKeyObserver = nil + } + } } final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewController, NSPopoverDelegate { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 8d4af861..29348c39 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -47,6 +47,9 @@ final class Workspace: Identifiable, ObservableObject { @Published var isPinned: Bool = false @Published var currentDirectory: String + /// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session) + var portOrdinal: Int = 0 + /// The bonsplit controller managing the split panes for this workspace let bonsplitController: BonsplitController @@ -102,12 +105,22 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Initialization + private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { + BonsplitConfiguration.SplitButtonTooltips( + newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"), + newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"), + splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"), + splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip("Split Down") + ) + } + private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance { bonsplitAppearance(from: config.backgroundColor) } private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance { BonsplitConfiguration.Appearance( + splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, chromeColors: .init(backgroundHex: backgroundColor.hexString()) ) @@ -125,8 +138,9 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex } - init(title: String = "Terminal", workingDirectory: String? = nil) { + init(title: String = "Terminal", workingDirectory: String? = nil, portOrdinal: Int = 0) { self.id = UUID() + self.portOrdinal = portOrdinal self.processTitle = title self.title = title self.customTitle = nil @@ -160,7 +174,8 @@ final class Workspace: Identifiable, ObservableObject { let terminalPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, - workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil + workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, + portOrdinal: portOrdinal ) panels[terminalPanel.id] = terminalPanel @@ -203,6 +218,12 @@ final class Workspace: Identifiable, ObservableObject { } } + func refreshSplitButtonTooltips() { + var configuration = bonsplitController.configuration + configuration.appearance.splitButtonTooltips = Self.currentSplitButtonTooltips() + bonsplitController.configuration = configuration + } + // MARK: - Surface ID to Panel ID Mapping /// Mapping from bonsplit TabID (surface ID) to panel UUID @@ -404,7 +425,8 @@ final class Workspace: Identifiable, ObservableObject { let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: inheritedConfig + configTemplate: inheritedConfig, + portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel @@ -469,7 +491,8 @@ final class Workspace: Identifiable, ObservableObject { let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: inheritedConfig + configTemplate: inheritedConfig, + portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel @@ -564,11 +587,16 @@ final class Workspace: Identifiable, ObservableObject { inPane paneId: PaneID, url: URL? = nil, focus: Bool? = nil, - insertAtEnd: Bool = false + insertAtEnd: Bool = false, + bypassInsecureHTTPHostOnce: String? = nil ) -> BrowserPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) - let browserPanel = BrowserPanel(workspaceId: id, initialURL: url) + let browserPanel = BrowserPanel( + workspaceId: id, + initialURL: url, + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce + ) panels[browserPanel.id] = browserPanel guard let newTabId = bonsplitController.createTab( @@ -1009,7 +1037,8 @@ final class Workspace: Identifiable, ObservableObject { let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, - configTemplate: nil + configTemplate: nil, + portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel @@ -1474,7 +1503,8 @@ extension Workspace: BonsplitDelegate { let replacementPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: inheritedConfig + configTemplate: inheritedConfig, + portOrdinal: portOrdinal ) panels[replacementPanel.id] = replacementPanel surfaceIdToPanelId[replacementTab.id] = replacementPanel.id @@ -1532,7 +1562,8 @@ extension Workspace: BonsplitDelegate { let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: inheritedConfig + configTemplate: inheritedConfig, + portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 8349489b..998bdde5 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -15,6 +15,12 @@ struct cmuxApp: App { @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey) + private var toggleBrowserDeveloperToolsShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultsKey) + private var showBrowserJavaScriptConsoleShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey) private var splitBrowserRightShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey) private var splitBrowserDownShortcutData = Data() @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { @@ -426,6 +432,20 @@ struct cmuxApp: App { } .keyboardShortcut("r", modifiers: .command) + splitCommandButton(title: "Toggle Developer Tools", shortcut: toggleBrowserDeveloperToolsMenuShortcut) { + let manager = (AppDelegate.shared?.tabManager ?? tabManager) + if !manager.toggleDeveloperToolsFocusedBrowser() { + NSSound.beep() + } + } + + splitCommandButton(title: "Show JavaScript Console", shortcut: showBrowserJavaScriptConsoleMenuShortcut) { + let manager = (AppDelegate.shared?.tabManager ?? tabManager) + if !manager.showJavaScriptConsoleFocusedBrowser() { + NSSound.beep() + } + } + Button("Zoom In") { _ = (AppDelegate.shared?.tabManager ?? tabManager).zoomInFocusedBrowser() } @@ -463,11 +483,19 @@ struct cmuxApp: App { performSplitFromMenu(direction: .down) } + splitCommandButton(title: "Split Browser Right", shortcut: splitBrowserRightMenuShortcut) { + performBrowserSplitFromMenu(direction: .right) + } + + splitCommandButton(title: "Split Browser Down", shortcut: splitBrowserDownMenuShortcut) { + performBrowserSplitFromMenu(direction: .down) + } + Divider() // Cmd+1 through Cmd+9 for workspace selection (9 = last workspace) ForEach(1...9, id: \.self) { number in - Button("Tab \(number)") { + Button("Workspace \(number)") { let manager = (AppDelegate.shared?.tabManager ?? tabManager) if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: number, workspaceCount: manager.tabs.count) { manager.selectTab(at: targetIndex) @@ -545,6 +573,34 @@ struct cmuxApp: App { decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut) } + private var toggleBrowserDeveloperToolsMenuShortcut: StoredShortcut { + decodeShortcut( + from: toggleBrowserDeveloperToolsShortcutData, + fallback: KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut + ) + } + + private var showBrowserJavaScriptConsoleMenuShortcut: StoredShortcut { + decodeShortcut( + from: showBrowserJavaScriptConsoleShortcutData, + fallback: KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut + ) + } + + private var splitBrowserRightMenuShortcut: StoredShortcut { + decodeShortcut( + from: splitBrowserRightShortcutData, + fallback: KeyboardShortcutSettings.Action.splitBrowserRight.defaultShortcut + ) + } + + private var splitBrowserDownMenuShortcut: StoredShortcut { + decodeShortcut( + from: splitBrowserDownShortcutData, + fallback: KeyboardShortcutSettings.Action.splitBrowserDown.defaultShortcut + ) + } + private var notificationMenuSnapshot: NotificationMenuSnapshot { NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications) } @@ -577,6 +633,13 @@ struct cmuxApp: App { tabManager.createSplit(direction: direction) } + private func performBrowserSplitFromMenu(direction: SplitDirection) { + if AppDelegate.shared?.performBrowserSplitShortcut(direction: direction) == true { + return + } + _ = tabManager.createBrowserSplit(direction: direction) + } + @ViewBuilder private func splitCommandButton(title: String, shortcut: StoredShortcut, action: @escaping () -> Void) -> some View { if let key = keyEquivalent(for: shortcut) { @@ -1132,6 +1195,7 @@ private enum DebugWindowConfigSnapshot { """ let menuBarPayload = MenuBarIconDebugSettings.copyPayload(defaults: defaults) + let browserDevToolsPayload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults) return """ # Sidebar Debug @@ -1142,6 +1206,9 @@ private enum DebugWindowConfigSnapshot { # Menu Bar Extra Debug \(menuBarPayload) + + # Browser DevTools Button + \(browserDevToolsPayload) """ } @@ -1208,6 +1275,16 @@ private struct DebugWindowControlsView: View { @AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @AppStorage("debugTitlebarLeadingExtra") private var titlebarLeadingExtra: Double = 0 + @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue + @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue + + private var selectedDevToolsIconOption: BrowserDevToolsIconOption { + BrowserDevToolsIconOption(rawValue: browserDevToolsIconNameRaw) ?? BrowserDevToolsButtonDebugSettings.defaultIcon + } + + private var selectedDevToolsColorOption: BrowserDevToolsIconColorOption { + BrowserDevToolsIconColorOption(rawValue: browserDevToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor + } var body: some View { ScrollView { @@ -1291,12 +1368,58 @@ private struct DebugWindowControlsView: View { .padding(.top, 2) } + GroupBox("Browser DevTools Button") { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("Icon") + Picker("Icon", selection: $browserDevToolsIconNameRaw) { + ForEach(BrowserDevToolsIconOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + Spacer() + } + + HStack(spacing: 8) { + Text("Color") + Picker("Color", selection: $browserDevToolsIconColorRaw) { + ForEach(BrowserDevToolsIconColorOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + Spacer() + } + + HStack(spacing: 8) { + Text("Preview") + Spacer() + Image(systemName: selectedDevToolsIconOption.rawValue) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(selectedDevToolsColorOption.color) + } + + HStack(spacing: 12) { + Button("Reset Button") { + resetBrowserDevToolsButton() + } + Button("Copy Button Config") { + copyBrowserDevToolsButtonConfig() + } + } + } + .padding(.top, 2) + } + GroupBox("Copy") { VStack(alignment: .leading, spacing: 8) { Button("Copy All Debug Config") { DebugWindowConfigSnapshot.copyCombinedToPasteboard() } - Text("Copies sidebar, background, and menu bar debug settings as one payload.") + Text("Copies sidebar, background, menu bar, and browser devtools settings as one payload.") .font(.caption) .foregroundColor(.secondary) } @@ -1357,6 +1480,18 @@ private struct DebugWindowControlsView: View { pasteboard.clearContents() pasteboard.setString(payload, forType: .string) } + + private func resetBrowserDevToolsButton() { + browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue + browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue + } + + private func copyBrowserDevToolsButtonConfig() { + let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: .standard) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + } } private final class AboutWindowController: NSWindowController, NSWindowDelegate { @@ -2270,18 +2405,23 @@ struct SettingsView: View { @AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage("claudeCodeHooksEnabled") private var claudeCodeHooksEnabled = true + @AppStorage("cmuxPortBase") private var cmuxPortBase = 9100 + @AppStorage("cmuxPortRange") private var cmuxPortRange = 10 @AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled @AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser @AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist + @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue + @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @State private var topBlurBaselineOffset: CGFloat? @State private var settingsTitleLeadingInset: CGFloat = 92 @State private var showClearBrowserHistoryConfirmation = false @State private var browserHistoryEntryCount: Int = 0 + @State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText private var selectedWorkspacePlacement: NewWorkspacePlacement { NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement @@ -2302,6 +2442,10 @@ struct SettingsView: View { } } + private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool { + browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist + } + private func blurOpacity(forContentOffset offset: CGFloat) -> Double { guard let baseline = topBlurBaselineOffset else { return 0 } let reveal = (baseline - offset) / 24 @@ -2342,6 +2486,17 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + "Reorder on Notification", + subtitle: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions." + ) { + Toggle("", isOn: $workspaceAutoReorder) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + SettingsCardRow( "Dock Badge", subtitle: "Show unread count on app icon (Dock and Cmd+Tab)." @@ -2393,6 +2548,26 @@ struct SettingsView: View { SettingsCardNote("When enabled, cmux wraps the claude command to inject session tracking and notification hooks. Disable if you prefer to manage Claude Code hooks yourself.") } + SettingsCard { + SettingsCardRow("Port Base", subtitle: "Starting port for CMUX_PORT env var.", controlWidth: pickerColumnWidth) { + TextField("", value: $cmuxPortBase, format: .number) + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.trailing) + } + + SettingsCardDivider() + + SettingsCardRow("Port Range Size", subtitle: "Number of ports per workspace.", controlWidth: pickerColumnWidth) { + TextField("", value: $cmuxPortRange, format: .number) + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.trailing) + } + + SettingsCardDivider() + + SettingsCardNote("Each workspace gets CMUX_PORT and CMUX_PORT_END env vars with a dedicated port range. New terminals inherit these values.") + } + SettingsSectionHeader(title: "Browser") SettingsCard { SettingsCardRow( @@ -2457,6 +2632,69 @@ struct SettingsView: View { SettingsCardDivider() + VStack(alignment: .leading, spacing: 8) { + Text("HTTP Host Allowlist") + .font(.system(size: 13, weight: .semibold)) + + Text("HTTP loads outside this list show a warning prompt with options to open externally or proceed.") + .font(.caption) + .foregroundStyle(.secondary) + + TextEditor(text: $browserInsecureHTTPAllowlistDraft) + .font(.system(size: 12, weight: .regular, design: .monospaced)) + .frame(minHeight: 86) + .padding(6) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .accessibilityIdentifier("SettingsBrowserHTTPAllowlistField") + + ViewThatFits(in: .horizontal) { + HStack(alignment: .center, spacing: 10) { + Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 0) + + Button("Save") { + saveBrowserInsecureHTTPAllowlist() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges) + .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") + } + + VStack(alignment: .leading, spacing: 8) { + Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).") + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + Spacer(minLength: 0) + Button("Save") { + saveBrowserInsecureHTTPAllowlist() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges) + .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") + } + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + + SettingsCardDivider() + SettingsCardRow("Browsing History", subtitle: browserHistorySubtitle) { Button("Clear History…") { showClearBrowserHistoryConfirmation = true @@ -2580,6 +2818,13 @@ struct SettingsView: View { .onAppear { BrowserHistoryStore.shared.loadIfNeeded() browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count + browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist + } + .onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in + // Keep draft in sync with external changes unless the user has local unsaved edits. + if browserInsecureHTTPAllowlistDraft == oldValue { + browserInsecureHTTPAllowlistDraft = newValue + } } .onReceive(BrowserHistoryStore.shared.$entries) { entries in browserHistoryEntryCount = entries.count @@ -2606,11 +2851,18 @@ struct SettingsView: View { browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist + browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText + browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue + workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue KeyboardShortcutSettings.resetAll() shortcutResetToken = UUID() } + + private func saveBrowserInsecureHTTPAllowlist() { + browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft + } } private struct SettingsTopOffsetPreferenceKey: PreferenceKey { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 5c1aa029..edfa1897 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1,6 +1,7 @@ import XCTest import AppKit import WebKit +import ObjectiveC.runtime #if canImport(cmux_DEV) @testable import cmux_DEV @@ -8,6 +9,49 @@ import WebKit @testable import cmux #endif +private var cmuxUnitTestInspectorAssociationKey: UInt8 = 0 +private var cmuxUnitTestInspectorOverrideInstalled = false + +private extension CmuxWebView { + @objc func cmuxUnitTestInspector() -> NSObject? { + objc_getAssociatedObject(self, &cmuxUnitTestInspectorAssociationKey) as? NSObject + } +} + +private extension WKWebView { + func cmuxSetUnitTestInspector(_ inspector: NSObject?) { + objc_setAssociatedObject( + self, + &cmuxUnitTestInspectorAssociationKey, + inspector, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } +} + +private func installCmuxUnitTestInspectorOverride() { + guard !cmuxUnitTestInspectorOverrideInstalled else { return } + + guard let replacementMethod = class_getInstanceMethod( + CmuxWebView.self, + #selector(CmuxWebView.cmuxUnitTestInspector) + ) else { + fatalError("Unable to locate test inspector replacement method") + } + + let added = class_addMethod( + CmuxWebView.self, + NSSelectorFromString("_inspector"), + method_getImplementation(replacementMethod), + method_getTypeEncoding(replacementMethod) + ) + guard added else { + fatalError("Unable to install CmuxWebView _inspector test override") + } + + cmuxUnitTestInspectorOverrideInstalled = true +} + final class CmuxWebViewKeyEquivalentTests: XCTestCase { private final class ActionSpy: NSObject { private(set) var invoked: Bool = false @@ -88,6 +132,258 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } +final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase { + private func makeIsolatedDefaults() -> UserDefaults { + let suiteName = "BrowserDevToolsButtonDebugSettingsTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create defaults suite") + } + defaults.removePersistentDomain(forName: suiteName) + addTeardownBlock { + defaults.removePersistentDomain(forName: suiteName) + } + return defaults + } + + func testIconCatalogIncludesExpandedChoices() { + XCTAssertGreaterThanOrEqual(BrowserDevToolsIconOption.allCases.count, 10) + XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.terminal)) + XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.globe)) + XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.curlyBracesSquare)) + } + + func testIconOptionFallsBackToDefaultForUnknownRawValue() { + let defaults = makeIsolatedDefaults() + defaults.set("this.symbol.does.not.exist", forKey: BrowserDevToolsButtonDebugSettings.iconNameKey) + + XCTAssertEqual( + BrowserDevToolsButtonDebugSettings.iconOption(defaults: defaults), + BrowserDevToolsButtonDebugSettings.defaultIcon + ) + } + + func testColorOptionFallsBackToDefaultForUnknownRawValue() { + let defaults = makeIsolatedDefaults() + defaults.set("notAValidColor", forKey: BrowserDevToolsButtonDebugSettings.iconColorKey) + + XCTAssertEqual( + BrowserDevToolsButtonDebugSettings.colorOption(defaults: defaults), + BrowserDevToolsButtonDebugSettings.defaultColor + ) + } + + func testCopyPayloadUsesPersistedValues() { + let defaults = makeIsolatedDefaults() + defaults.set(BrowserDevToolsIconOption.scope.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconNameKey) + defaults.set(BrowserDevToolsIconColorOption.bonsplitActive.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconColorKey) + + let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults) + XCTAssertTrue(payload.contains("browserDevToolsIconName=scope")) + XCTAssertTrue(payload.contains("browserDevToolsIconColor=bonsplitActive")) + } +} + +final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { + func testSafariDefaultShortcutForToggleDeveloperTools() { + let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut + XCTAssertEqual(shortcut.key, "i") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.option) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.control) + } + + func testSafariDefaultShortcutForShowJavaScriptConsole() { + let shortcut = KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut + XCTAssertEqual(shortcut.key, "c") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.option) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.control) + } +} + +@MainActor +final class BrowserDeveloperToolsConfigurationTests: XCTestCase { + func testBrowserPanelEnablesInspectableWebViewAndDeveloperExtras() { + let panel = BrowserPanel(workspaceId: UUID()) + let developerExtras = panel.webView.configuration.preferences.value(forKey: "developerExtrasEnabled") as? Bool + XCTAssertEqual(developerExtras, true) + + if #available(macOS 13.3, *) { + XCTAssertTrue(panel.webView.isInspectable) + } + } +} + +@MainActor +final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { + private final class FakeInspector: NSObject { + private(set) var showCount = 0 + private(set) var closeCount = 0 + private var visible = false + + @objc func isVisible() -> Bool { + visible + } + + @objc func show() { + showCount += 1 + visible = true + } + + @objc func close() { + closeCount += 1 + visible = false + } + } + + override class func setUp() { + super.setUp() + installCmuxUnitTestInspectorOverride() + } + + private func makePanelWithInspector() -> (BrowserPanel, FakeInspector) { + let panel = BrowserPanel(workspaceId: UUID()) + let inspector = FakeInspector() + panel.webView.cmuxSetUnitTestInspector(inspector) + return (panel, inspector) + } + + func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + + // Simulate WebKit closing inspector during detach/reattach churn. + inspector.close() + XCTAssertFalse(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.closeCount, 1) + + panel.restoreDeveloperToolsAfterAttachIfNeeded() + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 2) + } + + func testSyncRespectsManualCloseAndPreventsUnexpectedRestore() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertEqual(inspector.showCount, 1) + + // Simulate user closing inspector before detach. + inspector.close() + panel.syncDeveloperToolsPreferenceFromInspector() + + panel.restoreDeveloperToolsAfterAttachIfNeeded() + XCTAssertFalse(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + } + + func testSyncCanPreserveVisibleIntentDuringDetachChurn() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertEqual(inspector.showCount, 1) + + // Simulate a transient close caused by view detach, not user intent. + inspector.close() + panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true) + panel.restoreDeveloperToolsAfterAttachIfNeeded() + + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 2) + } + + func testForcedRefreshAfterAttachKeepsVisibleInspectorState() { + let (panel, inspector) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.showCount, 1) + XCTAssertEqual(inspector.closeCount, 0) + + panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test") + panel.restoreDeveloperToolsAfterAttachIfNeeded() + + XCTAssertTrue(panel.isDeveloperToolsVisible()) + XCTAssertEqual(inspector.closeCount, 0) + XCTAssertEqual(inspector.showCount, 1) + + // The force-refresh request should be one-shot. + panel.restoreDeveloperToolsAfterAttachIfNeeded() + XCTAssertEqual(inspector.closeCount, 0) + XCTAssertEqual(inspector.showCount, 1) + } + + func testRefreshRequestTracksPendingStateUntilRestoreRuns() { + let (panel, _) = makePanelWithInspector() + + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach()) + + panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test") + XCTAssertTrue(panel.hasPendingDeveloperToolsRefreshAfterAttach()) + + panel.restoreDeveloperToolsAfterAttachIfNeeded() + XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach()) + } + + func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() { + let (panel, _) = makePanelWithInspector() + + XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + XCTAssertTrue(panel.showDeveloperTools()) + XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + XCTAssertTrue(panel.hideDeveloperTools()) + XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + } + + func testWebViewDismantleSkipsDetachWhenDeveloperToolsIntentIsVisible() { + let (panel, _) = makePanelWithInspector() + XCTAssertTrue(panel.showDeveloperTools()) + + let representable = WebViewRepresentable( + panel: panel, + shouldAttachWebView: true, + shouldFocusWebView: false, + isPanelFocused: true, + portalZPriority: 0 + ) + let coordinator = representable.makeCoordinator() + coordinator.webView = panel.webView + let host = NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)) + host.addSubview(panel.webView) + + WebViewRepresentable.dismantleNSView(host, coordinator: coordinator) + + XCTAssertTrue(panel.webView.superview === host) + } + + func testWebViewDismantleDetachesWhenDeveloperToolsIntentIsHidden() { + let (panel, _) = makePanelWithInspector() + XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide()) + + let representable = WebViewRepresentable( + panel: panel, + shouldAttachWebView: true, + shouldFocusWebView: false, + isPanelFocused: true, + portalZPriority: 0 + ) + let coordinator = representable.makeCoordinator() + coordinator.webView = panel.webView + let host = NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)) + host.addSubview(panel.webView) + + WebViewRepresentable.dismantleNSView(host, coordinator: coordinator) + + XCTAssertNil(panel.webView.superview) + } +} + final class WorkspaceShortcutMapperTests: XCTestCase { func testCommandNineMapsToLastWorkspaceIndex() { XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 1), 0) @@ -204,6 +500,57 @@ final class SidebarCommandHintPolicyTests: XCTestCase { func testCommandHintUsesIntentionalHoldDelay() { XCTAssertGreaterThanOrEqual(SidebarCommandHintPolicy.intentionalHoldDelay, 0.25) } + + func testCurrentWindowRequiresHostWindowToBeKeyAndMatchEventWindow() { + XCTAssertTrue( + SidebarCommandHintPolicy.isCurrentWindow( + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: 42, + keyWindowNumber: 42 + ) + ) + + XCTAssertFalse( + SidebarCommandHintPolicy.isCurrentWindow( + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: 7, + keyWindowNumber: 42 + ) + ) + + XCTAssertFalse( + SidebarCommandHintPolicy.isCurrentWindow( + hostWindowNumber: 42, + hostWindowIsKey: false, + eventWindowNumber: 42, + keyWindowNumber: 42 + ) + ) + } + + func testWindowScopedCommandHintsUseKeyWindowWhenNoEventWindowIsAvailable() { + XCTAssertTrue( + SidebarCommandHintPolicy.shouldShowHints( + for: [.command], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: nil, + keyWindowNumber: 42 + ) + ) + + XCTAssertFalse( + SidebarCommandHintPolicy.shouldShowHints( + for: [.command], + hostWindowNumber: 42, + hostWindowIsKey: true, + eventWindowNumber: nil, + keyWindowNumber: 7 + ) + ) + } } final class ShortcutHintDebugSettingsTests: XCTestCase { @@ -339,6 +686,43 @@ final class WorkspacePlacementSettingsTests: XCTestCase { } } +final class WorkspaceAutoReorderSettingsTests: XCTestCase { + func testDefaultIsEnabled() { + let suiteName = "WorkspaceAutoReorderSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertTrue(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults)) + } + + func testDisabledWhenSetToFalse() { + let suiteName = "WorkspaceAutoReorderSettingsTests.Disabled.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(false, forKey: WorkspaceAutoReorderSettings.key) + XCTAssertFalse(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults)) + } + + func testEnabledWhenSetToTrue() { + let suiteName = "WorkspaceAutoReorderSettingsTests.Enabled.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(true, forKey: WorkspaceAutoReorderSettings.key) + XCTAssertTrue(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults)) + } +} + final class AppearanceSettingsTests: XCTestCase { func testResolvedModeDefaultsToSystemWhenUnset() { let suiteName = "AppearanceSettingsTests.Default.\(UUID().uuidString)" @@ -354,40 +738,6 @@ final class AppearanceSettingsTests: XCTestCase { XCTAssertEqual(resolved, .system) XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue) } - - func testResolvedModeMigratesLegacyAndInvalidValuesToSystem() { - let suiteName = "AppearanceSettingsTests.Migrate.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(AppearanceMode.auto.rawValue, forKey: AppearanceSettings.appearanceModeKey) - XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .system) - XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue) - - defaults.set("invalid-value", forKey: AppearanceSettings.appearanceModeKey) - XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .system) - XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue) - } - - func testResolvedModePreservesExplicitLightAndDark() { - let suiteName = "AppearanceSettingsTests.Preserve.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(AppearanceMode.light.rawValue, forKey: AppearanceSettings.appearanceModeKey) - XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .light) - XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.light.rawValue) - - defaults.set(AppearanceMode.dark.rawValue, forKey: AppearanceSettings.appearanceModeKey) - XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .dark) - XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.dark.rawValue) - } } final class UpdateChannelSettingsTests: XCTestCase { @@ -2013,22 +2363,6 @@ final class GhosttySurfaceOverlayTests: XCTestCase { state = hostedView.debugInactiveOverlayState() XCTAssertTrue(state.isHidden) } - - func testUnreadNotificationRingVisibilityTracksRequestedState() { - let hostedView = GhosttySurfaceScrollView( - surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 80, height: 50)) - ) - - hostedView.setNotificationRing(visible: true) - var state = hostedView.debugNotificationRingState() - XCTAssertFalse(state.isHidden) - XCTAssertEqual(state.opacity, 1, accuracy: 0.001) - - hostedView.setNotificationRing(visible: false) - state = hostedView.debugNotificationRingState() - XCTAssertTrue(state.isHidden) - XCTAssertEqual(state.opacity, 0, accuracy: 0.001) - } } @MainActor @@ -2224,6 +2558,222 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { } } +@MainActor +final class BrowserWindowPortalLifecycleTests: XCTestCase { + private func realizeWindowLayout(_ window: NSWindow) { + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + window.contentView?.layoutSubtreeIfNeeded() + } + + func testPortalHostInstallsAboveContentViewForVisibility() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + let portal = WindowBrowserPortal(window: window) + _ = portal.webViewAtWindowPoint(NSPoint(x: 1, y: 1)) + + guard let contentView = window.contentView, + let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }), + let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else { + XCTFail("Expected host/content views in same container") + return + } + + XCTAssertGreaterThan( + hostIndex, + contentIndex, + "Browser portal host must remain above content view so portal-hosted web views stay visible" + ) + } + + func testAnchorRebindKeepsWebViewInStablePortalSuperview() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120)) + let anchor2 = NSView(frame: NSRect(x: 240, y: 40, width: 180, height: 120)) + contentView.addSubview(anchor1) + contentView.addSubview(anchor2) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor1, visibleInUI: true) + let firstSuperview = webView.superview + + XCTAssertNotNil(firstSuperview) + XCTAssertTrue(firstSuperview is WindowBrowserSlotView) + + portal.bind(webView: webView, to: anchor2, visibleInUI: true) + XCTAssertTrue(webView.superview === firstSuperview, "Anchor moves should not reparent the web view") + + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor2) + guard let slot = webView.superview as? WindowBrowserSlotView, + let host = slot.superview as? WindowBrowserHostView else { + XCTFail("Expected browser slot + host views") + return + } + let expectedFrame = host.convert(anchor2.bounds, from: anchor2) + XCTAssertEqual(slot.frame.origin.x, expectedFrame.origin.x, accuracy: 0.5) + XCTAssertEqual(slot.frame.origin.y, expectedFrame.origin.y, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.width, expectedFrame.size.width, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.height, expectedFrame.size.height, accuracy: 0.5) + } + + func testPortalClampsWebViewFrameToHostBoundsWhenAnchorOverflowsSidebar() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + // Simulate a transient oversized anchor rect during split churn. + let anchor = NSView(frame: NSRect(x: 120, y: 20, width: 260, height: 150)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected web view slot") + return + } + + XCTAssertFalse(slot.isHidden, "Partially visible browser anchor should stay visible") + XCTAssertEqual(slot.frame.origin.x, 120, accuracy: 0.5) + XCTAssertEqual(slot.frame.origin.y, 20, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.width, 200, accuracy: 0.5) + XCTAssertEqual(slot.frame.size.height, 150, accuracy: 0.5) + } + + func testPortalSyncNormalizesOutOfBoundsWebFrame() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 300), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 40, y: 20, width: 220, height: 160)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + contentView.layoutSubtreeIfNeeded() + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + + // Reproduce observed drift from logs where WebKit shifts/expands frame beyond slot bounds. + webView.frame = NSRect(x: 0, y: 250, width: slot.bounds.width, height: slot.bounds.height) + XCTAssertGreaterThan(webView.frame.maxY, slot.bounds.maxY) + + portal.synchronizeWebViewForAnchor(anchor) + XCTAssertEqual(webView.frame.origin.x, slot.bounds.origin.x, accuracy: 0.5) + XCTAssertEqual(webView.frame.origin.y, slot.bounds.origin.y, accuracy: 0.5) + XCTAssertEqual(webView.frame.size.width, slot.bounds.size.width, accuracy: 0.5) + XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5) + } + + func testPortalHostBoundsBecomeReadyAfterBindingInFrameDrivenHierarchy() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + let portal = WindowBrowserPortal(window: window) + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160)) + contentView.addSubview(anchor) + + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + portal.bind(webView: webView, to: anchor, visibleInUI: true) + portal.synchronizeWebViewForAnchor(anchor) + + guard let slot = webView.superview as? WindowBrowserSlotView, + let host = slot.superview as? WindowBrowserHostView else { + XCTFail("Expected portal slot + host views") + return + } + XCTAssertGreaterThan(host.bounds.width, 1, "Portal host width should be ready for clipping/sync") + XCTAssertGreaterThan(host.bounds.height, 1, "Portal host height should be ready for clipping/sync") + } + + func testRegistryDetachRemovesPortalHostedWebView() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120)) + contentView.addSubview(anchor) + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true) + XCTAssertNotNil(webView.superview) + + BrowserWindowPortalRegistry.detach(webView: webView) + XCTAssertNil(webView.superview) + } +} + final class BrowserLinkOpenSettingsTests: XCTestCase { private var suiteName: String! private var defaults: UserDefaults! diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index b6f13d4d..dddecf16 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -126,6 +126,42 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertEqual(rgb255(config.backgroundColor), RGB(red: 253, green: 246, blue: 227)) } + func testLegacyConfigFallbackUsesLegacyFileWhenConfigGhosttyIsEmpty() { + XCTAssertTrue( + GhosttyApp.shouldLoadLegacyGhosttyConfig( + newConfigFileSize: 0, + legacyConfigFileSize: 42 + ) + ) + } + + func testLegacyConfigFallbackSkipsWhenNewFileMissingOrLegacyEmpty() { + XCTAssertFalse( + GhosttyApp.shouldLoadLegacyGhosttyConfig( + newConfigFileSize: nil, + legacyConfigFileSize: 42 + ) + ) + XCTAssertFalse( + GhosttyApp.shouldLoadLegacyGhosttyConfig( + newConfigFileSize: 10, + legacyConfigFileSize: 42 + ) + ) + XCTAssertFalse( + GhosttyApp.shouldLoadLegacyGhosttyConfig( + newConfigFileSize: 0, + legacyConfigFileSize: 0 + ) + ) + XCTAssertFalse( + GhosttyApp.shouldLoadLegacyGhosttyConfig( + newConfigFileSize: 0, + legacyConfigFileSize: nil + ) + ) + } + private func rgb255(_ color: NSColor) -> RGB { let srgb = color.usingColorSpace(.sRGB)! var red: CGFloat = 0 diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 4efc8c2b..c782eee9 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -1,5 +1,7 @@ import XCTest import Foundation +import AppKit +@testable import cmux_DEV /// Regression test: ensures UpdatePill is never gated behind #if DEBUG in production code paths. /// This prevents accidentally hiding the update UI in Release builds. @@ -64,3 +66,175 @@ final class UpdatePillReleaseVisibilityTests: XCTestCase { return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) } } + +/// Regression test: ensure WKWebView can load HTTP development URLs (e.g. *.localtest.me). +final class AppTransportSecurityTests: XCTestCase { + func testInfoPlistAllowsArbitraryLoadsInWebContent() throws { + let projectRoot = findProjectRoot() + let infoPlistURL = projectRoot.appendingPathComponent("Resources/Info.plist") + let data = try Data(contentsOf: infoPlistURL) + var format = PropertyListSerialization.PropertyListFormat.xml + let plist = try XCTUnwrap( + PropertyListSerialization.propertyList(from: data, options: [], format: &format) as? [String: Any] + ) + let ats = try XCTUnwrap(plist["NSAppTransportSecurity"] as? [String: Any]) + XCTAssertEqual( + ats["NSAllowsArbitraryLoadsInWebContent"] as? Bool, + true, + "Resources/Info.plist must allow HTTP loads in WKWebView for local dev hostnames." + ) + } + + private func findProjectRoot() -> URL { + var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent() + for _ in 0..<10 { + let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj") + if FileManager.default.fileExists(atPath: marker.path) { + return dir + } + dir = dir.deletingLastPathComponent() + } + return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + } +} + +final class BrowserInsecureHTTPSettingsTests: XCTestCase { + func testDefaultAllowlistPatternsArePresent() { + XCTAssertEqual( + BrowserInsecureHTTPSettings.normalizedAllowlistPatterns(rawValue: nil), + ["127.0.0.1", "localhost", "*.localtest.me"] + ) + } + + func testWildcardAndExactHostMatching() { + XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("localhost", rawAllowlist: nil)) + XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("api.localtest.me", rawAllowlist: nil)) + XCTAssertFalse(BrowserInsecureHTTPSettings.isHostAllowed("neverssl.com", rawAllowlist: nil)) + } + + func testCustomAllowlistNormalizesAndDeduplicatesEntries() { + let raw = """ + localhost + *.example.com + 127.0.0.1 + https://dev.internal:8080/path + *.example.com + """ + + XCTAssertEqual( + BrowserInsecureHTTPSettings.normalizedAllowlistPatterns(rawValue: raw), + ["localhost", "*.example.com", "127.0.0.1", "dev.internal"] + ) + XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("foo.example.com", rawAllowlist: raw)) + XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("dev.internal", rawAllowlist: raw)) + XCTAssertFalse(BrowserInsecureHTTPSettings.isHostAllowed("example.net", rawAllowlist: raw)) + } + + func testBlockDecisionUsesAllowlistAndSchemeRules() throws { + let localURL = try XCTUnwrap(URL(string: "http://foo.localtest.me:3000")) + XCTAssertFalse(browserShouldBlockInsecureHTTPURL(localURL, rawAllowlist: nil)) + + let insecureURL = try XCTUnwrap(URL(string: "http://neverssl.com")) + XCTAssertTrue(browserShouldBlockInsecureHTTPURL(insecureURL, rawAllowlist: nil)) + + let httpsURL = try XCTUnwrap(URL(string: "https://neverssl.com")) + XCTAssertFalse(browserShouldBlockInsecureHTTPURL(httpsURL, rawAllowlist: nil)) + } + + func testOneTimeBypassIsConsumedAfterFirstNavigation() throws { + let insecureURL = try XCTUnwrap(URL(string: "http://neverssl.com")) + var bypassHostOnce: String? = "neverssl.com" + + XCTAssertTrue(browserShouldConsumeOneTimeInsecureHTTPBypass( + insecureURL, + bypassHostOnce: &bypassHostOnce + )) + XCTAssertNil(bypassHostOnce) + + // Subsequent visits should prompt again unless host was saved. + XCTAssertFalse(browserShouldConsumeOneTimeInsecureHTTPBypass( + insecureURL, + bypassHostOnce: &bypassHostOnce + )) + XCTAssertTrue(browserShouldBlockInsecureHTTPURL(insecureURL, rawAllowlist: nil)) + } + + func testAddAllowedHostPersistsToDefaultsAndUnblocksHTTP() throws { + let suiteName = "BrowserInsecureHTTPSettingsTests.Persist.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + let url = try XCTUnwrap(URL(string: "http://persist-me.test")) + XCTAssertTrue(browserShouldBlockInsecureHTTPURL(url, defaults: defaults)) + + BrowserInsecureHTTPSettings.addAllowedHost("persist-me.test", defaults: defaults) + let persisted = defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey) + XCTAssertNotNil(persisted) + XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("persist-me.test", defaults: defaults)) + XCTAssertFalse(browserShouldBlockInsecureHTTPURL(url, defaults: defaults)) + } + + func testAllowlistSelectionPersistsForProceedAndOpenExternal() { + XCTAssertTrue(browserShouldPersistInsecureHTTPAllowlistSelection( + response: .alertFirstButtonReturn, + suppressionEnabled: true + )) + XCTAssertTrue(browserShouldPersistInsecureHTTPAllowlistSelection( + response: .alertSecondButtonReturn, + suppressionEnabled: true + )) + XCTAssertFalse(browserShouldPersistInsecureHTTPAllowlistSelection( + response: .alertThirdButtonReturn, + suppressionEnabled: true + )) + XCTAssertFalse(browserShouldPersistInsecureHTTPAllowlistSelection( + response: .alertSecondButtonReturn, + suppressionEnabled: false + )) + } +} + +/// Regression test: ensure new terminal windows are born in full-size content mode so +/// titlebar/content offsets are correct before the first resize. +final class MainWindowLayoutStyleTests: XCTestCase { + func testCreateMainWindowUsesFullSizeContentViewStyleMask() throws { + let projectRoot = findProjectRoot() + let appDelegateURL = projectRoot.appendingPathComponent("Sources/AppDelegate.swift") + let source = try String(contentsOf: appDelegateURL, encoding: .utf8) + + guard let start = source.range(of: "func createMainWindow("), + let end = source.range(of: "@objc func checkForUpdates", range: start.upperBound..<source.endIndex) else { + XCTFail("Could not locate createMainWindow block in Sources/AppDelegate.swift") + return + } + + let block = String(source[start.lowerBound..<end.lowerBound]) + let regex = try NSRegularExpression( + pattern: #"styleMask:\s*\[[^\]]*\.fullSizeContentView"#, + options: [.dotMatchesLineSeparators] + ) + let range = NSRange(block.startIndex..<block.endIndex, in: block) + XCTAssertNotNil( + regex.firstMatch(in: block, options: [], range: range), + """ + createMainWindow must include `.fullSizeContentView` in the NSWindow style mask. + Without it, initial titlebar/content offsets can be wrong until a manual resize. + """ + ) + } + + private func findProjectRoot() -> URL { + var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent() + for _ in 0..<10 { + let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj") + if FileManager.default.fileExists(atPath: marker.path) { + return dir + } + dir = dir.deletingLastPathComponent() + } + return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + } +} diff --git a/scripts/setup.sh b/scripts/setup.sh index 10413872..bcfeb818 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -1,5 +1,5 @@ -#!/bin/bash -set -e +#!/usr/bin/env bash +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" @@ -16,13 +16,70 @@ if ! command -v zig &> /dev/null; then exit 1 fi -echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..." -cd ghostty -zig build -Demit-xcframework=true -Doptimize=ReleaseFast -cd "$PROJECT_DIR" +GHOSTTY_SHA="$(git -C ghostty rev-parse HEAD)" +CACHE_ROOT="${CMUX_GHOSTTYKIT_CACHE_DIR:-$HOME/.cache/cmux/ghosttykit}" +CACHE_DIR="$CACHE_ROOT/$GHOSTTY_SHA" +CACHE_XCFRAMEWORK="$CACHE_DIR/GhosttyKit.xcframework" +LOCAL_XCFRAMEWORK="$PROJECT_DIR/ghostty/macos/GhosttyKit.xcframework" +LOCAL_SHA_STAMP="$LOCAL_XCFRAMEWORK/.ghostty_sha" +LOCK_DIR="$CACHE_ROOT/$GHOSTTY_SHA.lock" + +mkdir -p "$CACHE_ROOT" + +echo "==> Ghostty submodule commit: $GHOSTTY_SHA" + +LOCK_TIMEOUT=300 +LOCK_START=$SECONDS +while ! mkdir "$LOCK_DIR" 2>/dev/null; do + if (( SECONDS - LOCK_START > LOCK_TIMEOUT )); then + echo "==> Lock stale (>${LOCK_TIMEOUT}s), removing and retrying..." + rmdir "$LOCK_DIR" 2>/dev/null || rm -rf "$LOCK_DIR" + continue + fi + echo "==> Waiting for GhosttyKit cache lock for $GHOSTTY_SHA..." + sleep 1 +done +trap 'rmdir "$LOCK_DIR" >/dev/null 2>&1 || true' EXIT + +if [ -d "$CACHE_XCFRAMEWORK" ]; then + echo "==> Reusing cached GhosttyKit.xcframework" +else + # Only reuse local xcframework if its SHA stamp matches the current ghostty commit. + # Without this check, a stale build from a previous commit could be cached under + # the wrong SHA, producing ABI mismatches. + LOCAL_SHA="" + if [ -f "$LOCAL_SHA_STAMP" ]; then + LOCAL_SHA="$(cat "$LOCAL_SHA_STAMP")" + fi + + if [ -d "$LOCAL_XCFRAMEWORK" ] && [ "$LOCAL_SHA" = "$GHOSTTY_SHA" ]; then + echo "==> Seeding cache from existing local GhosttyKit.xcframework (SHA matches)" + else + echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..." + ( + cd ghostty + zig build -Demit-xcframework=true -Doptimize=ReleaseFast + ) + # Stamp the build output with the SHA it was built from + echo "$GHOSTTY_SHA" > "$LOCAL_SHA_STAMP" + fi + + if [ ! -d "$LOCAL_XCFRAMEWORK" ]; then + echo "Error: GhosttyKit.xcframework not found at $LOCAL_XCFRAMEWORK" + exit 1 + fi + + TMP_DIR="$(mktemp -d "$CACHE_ROOT/.ghosttykit-tmp.XXXXXX")" + mkdir -p "$CACHE_DIR" + cp -R "$LOCAL_XCFRAMEWORK" "$TMP_DIR/GhosttyKit.xcframework" + rm -rf "$CACHE_XCFRAMEWORK" + mv "$TMP_DIR/GhosttyKit.xcframework" "$CACHE_XCFRAMEWORK" + rmdir "$TMP_DIR" + echo "==> Cached GhosttyKit.xcframework at $CACHE_XCFRAMEWORK" +fi echo "==> Creating symlink for GhosttyKit.xcframework..." -ln -sf ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework +ln -sfn "$CACHE_XCFRAMEWORK" GhosttyKit.xcframework echo "==> Setup complete!" echo "" diff --git a/skills/cmux-debug-windows/SKILL.md b/skills/cmux-debug-windows/SKILL.md index fb5dc859..885c5f49 100644 --- a/skills/cmux-debug-windows/SKILL.md +++ b/skills/cmux-debug-windows/SKILL.md @@ -10,6 +10,9 @@ Keep this workflow focused on existing debug windows and menu entries. Do not ad ## Workflow 1. Verify debug menu wiring in `Sources/cmuxApp.swift` under `CommandMenu("Debug")`. + - Menu path in app: `Debug` → `Debug Windows` → window entry. + - The `Debug` menu only exists in DEBUG builds (`./scripts/reload.sh --tag ...`). + - Release builds (`reloadp.sh`, `reloads.sh`) do not show this menu. 2. Keep these actions available in `Menu("Debug Windows")`: - `Sidebar Debug…` - `Background Debug…` diff --git a/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh b/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh index c4461cb0..ac08502d 100755 --- a/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh +++ b/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh @@ -5,8 +5,8 @@ usage() { cat <<'USAGE' Usage: debug_windows_snapshot.sh [--domain <defaults-domain>] [--copy] -Collect Sidebar Debug, Background Debug, and Menu Bar Extra debug values from macOS defaults -and print a combined payload. Use --copy to also copy the payload to clipboard. +Collect Sidebar Debug, Background Debug, Menu Bar Extra, and Browser DevTools debug values +from macOS defaults and print a combined payload. Use --copy to also copy the payload. Examples: debug_windows_snapshot.sh @@ -118,13 +118,16 @@ menubarDebugSingleDigitYOffset="$(format_number "$(read_value menubarDebugSingle menubarDebugMultiDigitYOffset="$(format_number "$(read_value menubarDebugMultiDigitYOffset 0.60)" 2)" legacySingleDigitX="$(read_value menubarDebugTextRectXAdjust '')" if [[ -n "$legacySingleDigitX" ]]; then - menubarDebugSingleDigitXAdjust="$(format_number "$legacySingleDigitX" 2)" +menubarDebugSingleDigitXAdjust="$(format_number "$legacySingleDigitX" 2)" else menubarDebugSingleDigitXAdjust="$(format_number "$(read_value menubarDebugSingleDigitXAdjust -1.10)" 2)" fi menubarDebugMultiDigitXAdjust="$(format_number "$(read_value menubarDebugMultiDigitXAdjust 2.42)" 2)" menubarDebugTextRectWidthAdjust="$(format_number "$(read_value menubarDebugTextRectWidthAdjust 1.80)" 2)" +browserDevToolsIconName="$(read_value browserDevToolsIconName 'wrench.and.screwdriver')" +browserDevToolsIconColor="$(read_value browserDevToolsIconColor bonsplitInactive)" + payload="$(cat <<PAYLOAD # Defaults domain $domain @@ -166,6 +169,10 @@ menubarDebugMultiDigitYOffset=$menubarDebugMultiDigitYOffset menubarDebugSingleDigitXAdjust=$menubarDebugSingleDigitXAdjust menubarDebugMultiDigitXAdjust=$menubarDebugMultiDigitXAdjust menubarDebugTextRectWidthAdjust=$menubarDebugTextRectWidthAdjust + +# Browser DevTools Button +browserDevToolsIconName=$browserDevToolsIconName +browserDevToolsIconColor=$browserDevToolsIconColor PAYLOAD )" diff --git a/tests/test_browser_devtools_portal_regressions.py b/tests/test_browser_devtools_portal_regressions.py new file mode 100644 index 00000000..6ec27096 --- /dev/null +++ b/tests/test_browser_devtools_portal_regressions.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Static regression checks for browser DevTools/portal review fixes. + +Guards two follow-up fixes: +1) DevTools toggle path must retry restore when inspector show is transiently ignored. +2) Browser portal visibility must propagate even if host is temporarily off-window. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + + +def repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path(__file__).resolve().parents[1] + + +def extract_block(source: str, signature: str) -> str: + start = source.find(signature) + if start < 0: + raise ValueError(f"Missing signature: {signature}") + brace_start = source.find("{", start) + if brace_start < 0: + raise ValueError(f"Missing opening brace for: {signature}") + depth = 0 + for idx in range(brace_start, len(source)): + char = source[idx] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source[brace_start : idx + 1] + raise ValueError(f"Unbalanced braces for: {signature}") + + +def main() -> int: + root = repo_root() + failures: list[str] = [] + + panel_path = root / "Sources" / "Panels" / "BrowserPanel.swift" + panel_source = panel_path.read_text(encoding="utf-8") + toggle_block = extract_block(panel_source, "func toggleDeveloperTools() -> Bool") + if "visibleAfterToggle" not in toggle_block: + failures.append("toggleDeveloperTools() no longer re-checks inspector visibility") + if "scheduleDeveloperToolsRestoreRetry()" not in toggle_block: + failures.append("toggleDeveloperTools() no longer schedules a DevTools restore retry") + + view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift" + view_source = view_path.read_text(encoding="utf-8") + portal_update_block = extract_block(view_source, "private func updateUsingWindowPortal(") + if "BrowserWindowPortalRegistry.updateEntryVisibility(" not in portal_update_block: + failures.append("BrowserPanelView.updateUsingWindowPortal() is missing deferred portal visibility propagation") + if "zPriority: coordinator.desiredPortalZPriority" not in portal_update_block: + failures.append("BrowserPanelView deferred portal update no longer propagates zPriority") + + portal_path = root / "Sources" / "BrowserWindowPortal.swift" + portal_source = portal_path.read_text(encoding="utf-8") + if not re.search( + r"func\s+updateEntryVisibility\s*\(\s*forWebViewId\s+webViewId:\s*ObjectIdentifier,\s*visibleInUI:\s*Bool,\s*zPriority:\s*Int\s*\)", + portal_source, + flags=re.MULTILINE, + ): + failures.append("WindowBrowserPortal is missing updateEntryVisibility(forWebViewId:visibleInUI:zPriority:)") + if not re.search( + r"static\s+func\s+updateEntryVisibility\s*\(\s*for\s+webView:\s*WKWebView,\s*visibleInUI:\s*Bool,\s*zPriority:\s*Int\s*\)", + portal_source, + flags=re.MULTILINE, + ): + failures.append("BrowserWindowPortalRegistry is missing updateEntryVisibility(for:visibleInUI:zPriority:)") + + if failures: + print("FAIL: browser devtools/portal regression guards failed") + for item in failures: + print(f" - {item}") + return 1 + + print("PASS: browser devtools/portal regression guards are in place") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_real_click_overlay_forwarding.py b/tests/test_real_click_overlay_forwarding.py index f38d7381..0e52eb85 100644 --- a/tests/test_real_click_overlay_forwarding.py +++ b/tests/test_real_click_overlay_forwarding.py @@ -104,6 +104,34 @@ up?.post(tap: .cghidEventTap) ) +def post_scroll_with_cgevent(x: float, y: float, delta_y: int = 3) -> None: + ix = int(round(x)) + iy = int(round(y)) + code = f""" +import CoreGraphics +let p = CGPoint(x: {ix}, y: {iy}) +let source = CGEventSource(stateID: .hidSystemState) +if let scroll = CGEvent( + scrollWheelEvent2Source: source, + units: .line, + wheelCount: 1, + wheel1: Int32({delta_y}), + wheel2: 0, + wheel3: 0 +) {{ + scroll.location = p + scroll.post(tap: .cghidEventTap) +}} +""" + subprocess.run( + ["swift", "-e", code], + check=True, + capture_output=True, + text=True, + timeout=10, + ) + + def pick_top_bottom_terminal_panels(layout: dict) -> tuple[dict, dict]: candidates = [] for panel in layout.get("selectedPanels", []): @@ -282,7 +310,14 @@ def main() -> int: print("FAIL: real right click disrupted terminal focus routing") return 1 - print("PASS: stale file-drag overlay forwards real left/right clicks") + for _ in range(6): + post_scroll_with_cgevent(click_x, click_y, delta_y=2) + time.sleep(0.25) + if not client.is_terminal_focused(bottom_id): + print("FAIL: real scroll wheel disrupted terminal focus routing") + return 1 + + print("PASS: stale file-drag overlay forwards real left/right clicks and scroll") print(f" focused_panel={bottom_id}") return 0 finally: diff --git a/tests_v2/cmux.py b/tests_v2/cmux.py index bc10f568..cf94aae2 100755 --- a/tests_v2/cmux.py +++ b/tests_v2/cmux.py @@ -398,12 +398,43 @@ class cmux: wsid = self._resolve_workspace_id(workspace) self._call("workspace.select", {"workspace_id": wsid}) + def rename_workspace(self, title: str, workspace: Union[str, int, None] = None) -> None: + renamed = str(title).strip() + if not renamed: + raise cmuxError("rename_workspace requires a non-empty title") + wsid = self._resolve_workspace_id(workspace) + params: Dict[str, Any] = {"title": renamed} + if wsid: + params["workspace_id"] = wsid + self._call("workspace.rename", params) + def current_workspace(self) -> str: wsid = self._resolve_workspace_id(None) if not wsid: raise cmuxError("No current workspace") return wsid + def next_workspace(self) -> str: + res = self._call("workspace.next") or {} + wsid = res.get("workspace_id") + if not wsid: + raise cmuxError(f"workspace.next returned no workspace_id: {res}") + return str(wsid) + + def previous_workspace(self) -> str: + res = self._call("workspace.previous") or {} + wsid = res.get("workspace_id") + if not wsid: + raise cmuxError(f"workspace.previous returned no workspace_id: {res}") + return str(wsid) + + def last_workspace(self) -> str: + res = self._call("workspace.last") or {} + wsid = res.get("workspace_id") + if not wsid: + raise cmuxError(f"workspace.last returned no workspace_id: {res}") + return str(wsid) + def move_workspace_to_window(self, workspace: Union[str, int], window_id: str, focus: bool = True) -> None: wsid = self._resolve_workspace_id(workspace) self._call( @@ -639,6 +670,18 @@ class cmux: res = self._call("surface.health", params) or {} return list(res.get("surfaces") or []) + def clear_history(self, surface: Union[str, int, None] = None, workspace: Union[str, int, None] = None) -> None: + params: Dict[str, Any] = {} + if workspace is not None: + wsid = self._resolve_workspace_id(workspace) + params["workspace_id"] = wsid + if surface is not None: + sid = self._resolve_surface_id(surface, workspace_id=params.get("workspace_id")) + if not sid: + raise cmuxError(f"Invalid surface: {surface!r}") + params["surface_id"] = sid + self._call("surface.clear_history", params) + # --------------------------------------------------------------------- # Pane commands # --------------------------------------------------------------------- @@ -677,6 +720,61 @@ class cmux: )) return out + def swap_pane(self, pane: Union[str, int], target_pane: Union[str, int], focus: bool = True) -> None: + source = self._resolve_pane_id(pane) + target = self._resolve_pane_id(target_pane) + if not source or not target: + raise cmuxError(f"Invalid panes: pane={pane!r}, target_pane={target_pane!r}") + self._call("pane.swap", {"pane_id": source, "target_pane_id": target, "focus": bool(focus)}) + + def break_pane(self, pane: Union[str, int, None] = None, surface: Union[str, int, None] = None, focus: bool = True) -> str: + params: Dict[str, Any] = {"focus": bool(focus)} + if pane is not None: + pid = self._resolve_pane_id(pane) + if not pid: + raise cmuxError(f"Invalid pane: {pane!r}") + params["pane_id"] = pid + if surface is not None: + sid = self._resolve_surface_id(surface) + if not sid: + raise cmuxError(f"Invalid surface: {surface!r}") + params["surface_id"] = sid + res = self._call("pane.break", params) or {} + wsid = res.get("workspace_id") + if not wsid: + raise cmuxError(f"pane.break returned no workspace_id: {res}") + return str(wsid) + + def join_pane( + self, + target_pane: Union[str, int], + pane: Union[str, int, None] = None, + surface: Union[str, int, None] = None, + focus: bool = True, + ) -> None: + target = self._resolve_pane_id(target_pane) + if not target: + raise cmuxError(f"Invalid target_pane: {target_pane!r}") + params: Dict[str, Any] = {"target_pane_id": target, "focus": bool(focus)} + if pane is not None: + source = self._resolve_pane_id(pane) + if not source: + raise cmuxError(f"Invalid pane: {pane!r}") + params["pane_id"] = source + if surface is not None: + sid = self._resolve_surface_id(surface) + if not sid: + raise cmuxError(f"Invalid surface: {surface!r}") + params["surface_id"] = sid + self._call("pane.join", params) + + def last_pane(self) -> str: + res = self._call("pane.last") or {} + pid = res.get("pane_id") + if not pid: + raise cmuxError(f"pane.last returned no pane_id: {res}") + return str(pid) + # --------------------------------------------------------------------- # Input # --------------------------------------------------------------------- @@ -830,6 +928,18 @@ class cmux: if panel is not None: sid = self._resolve_surface_id(panel) params["surface_id"] = sid + try: + res = self._call("surface.read_text", params) or {} + if "text" in res: + return str(res.get("text") or "") + b64 = str(res.get("base64") or "") + raw = base64.b64decode(b64) if b64 else b"" + return raw.decode("utf-8", errors="replace") + except cmuxError as exc: + # Back-compat for older builds that only expose the debug method. + if "method_not_found" not in str(exc): + raise + res = self._call("debug.terminal.read_text", params) or {} b64 = str(res.get("base64") or "") raw = base64.b64decode(b64) if b64 else b"" diff --git a/tests_v2/test_read_screen_capture_pane_parity.py b/tests_v2/test_read_screen_capture_pane_parity.py new file mode 100644 index 00000000..a416e8c2 --- /dev/null +++ b/tests_v2/test_read_screen_capture_pane_parity.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Regression: capture-pane parity via production read-screen APIs.""" + +import glob +import json +import os +import subprocess +import sys +import time +from pathlib import Path +from typing import Callable, List + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _wait_for(pred: Callable[[], bool], timeout_s: float = 5.0, step_s: float = 0.05) -> None: + start = time.time() + while time.time() - start < timeout_s: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli(cli: str, args: List[str]) -> str: + cmd = [cli, "--socket", SOCKET_PATH] + args + proc = subprocess.run(cmd, capture_output=True, text=True, check=False) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}") + return proc.stdout + + +def main() -> int: + cli = _find_cli_binary() + + with cmux(SOCKET_PATH) as c: + caps = c.capabilities() or {} + methods = set(caps.get("methods") or []) + _must("surface.read_text" in methods, f"Missing surface.read_text in capabilities: {sorted(methods)[:20]}") + + created_target = c._call("workspace.create") or {} + ws_target = str(created_target.get("workspace_id") or "") + _must(bool(ws_target), f"workspace.create returned no workspace_id: {created_target}") + c._call("workspace.select", {"workspace_id": ws_target}) + + surfaces_payload = c._call("surface.list", {"workspace_id": ws_target}) or {} + surfaces = surfaces_payload.get("surfaces") or [] + _must(bool(surfaces), f"Expected at least one surface in workspace: {surfaces_payload}") + surface_target = str(surfaces[0].get("id") or "") + _must(bool(surface_target), f"surface.list returned surface without id: {surfaces_payload}") + + created_other = c._call("workspace.create") or {} + ws_other = str(created_other.get("workspace_id") or "") + _must(bool(ws_other), f"workspace.create returned no workspace_id: {created_other}") + c._call("workspace.select", {"workspace_id": ws_other}) + + selected = c._call("workspace.current") or {} + _must(str(selected.get("workspace_id") or "") == ws_other, f"Expected selected workspace {ws_other}, got: {selected}") + + token = f"CMUX_READ_SCREEN_{int(time.time() * 1000)}" + c._call("surface.send_text", { + "workspace_id": ws_target, + "surface_id": surface_target, + "text": f"echo {token}\n", + }) + + def has_token() -> bool: + payload = c._call("surface.read_text", {"workspace_id": ws_target, "surface_id": surface_target}) or {} + return token in str(payload.get("text") or "") + + _wait_for(has_token, timeout_s=5.0) + + read_payload = c._call("surface.read_text", {"workspace_id": ws_target, "surface_id": surface_target}) or {} + text = str(read_payload.get("text") or "") + _must(token in text, f"surface.read_text missing token {token!r}: {read_payload}") + + ws_only_payload = c._call("surface.read_text", {"workspace_id": ws_target}) or {} + _must(token in str(ws_only_payload.get("text") or ""), f"surface.read_text workspace-only call missing token {token!r}: {ws_only_payload}") + + cli_text = _run_cli(cli, ["read-screen", "--workspace", ws_target, "--surface", surface_target]) + _must(token in cli_text, f"cmux read-screen output missing token {token!r}: {cli_text!r}") + + cli_ws_only = _run_cli(cli, ["read-screen", "--workspace", ws_target]) + _must(token in cli_ws_only, f"cmux read-screen --workspace output missing token {token!r}: {cli_ws_only!r}") + + cli_text_scrollback = _run_cli(cli, ["read-screen", "--workspace", ws_target, "--surface", surface_target, "--scrollback", "--lines", "80"]) + _must(token in cli_text_scrollback, f"cmux read-screen --scrollback output missing token {token!r}: {cli_text_scrollback!r}") + + cli_json = _run_cli(cli, ["--json", "read-screen", "--workspace", ws_target, "--surface", surface_target]) + payload = json.loads(cli_json or "{}") + _must(token in str(payload.get("text") or ""), f"cmux --json read-screen missing token {token!r}: {payload}") + + invalid = subprocess.run( + [cli, "--socket", SOCKET_PATH, "read-screen", "--workspace", ws_target, "--surface", surface_target, "--lines", "0"], + capture_output=True, + text=True, + check=False, + ) + invalid_output = f"{invalid.stdout}\n{invalid.stderr}" + _must(invalid.returncode != 0, "Expected read-screen --lines 0 to fail") + _must("--lines must be greater than 0" in invalid_output, f"Unexpected error for --lines 0: {invalid_output!r}") + + print("PASS: production read-screen APIs expose capture-pane behavior") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_rename_window_workspace_parity.py b/tests_v2/test_rename_window_workspace_parity.py new file mode 100644 index 00000000..13e564c1 --- /dev/null +++ b/tests_v2/test_rename_window_workspace_parity.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Regression: tmux rename-window parity via workspace.rename + CLI aliases.""" + +import glob +import os +import subprocess +import sys +import time +from pathlib import Path +from typing import List + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli(cli: str, args: List[str]) -> str: + env = dict(os.environ) + # Keep this test deterministic when running from inside another cmux shell. + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + cmd = [cli, "--socket", SOCKET_PATH] + args + proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}") + return proc.stdout + + +def _workspace_title(c: cmux, workspace_id: str) -> str: + payload = c._call("workspace.list") or {} + for row in payload.get("workspaces") or []: + if str(row.get("id") or "") == workspace_id: + return str(row.get("title") or "") + raise cmuxError(f"workspace.list missing workspace {workspace_id}: {payload}") + + +def main() -> int: + cli = _find_cli_binary() + stamp = int(time.time() * 1000) + + with cmux(SOCKET_PATH) as c: + caps = c.capabilities() or {} + methods = set(caps.get("methods") or []) + _must("workspace.rename" in methods, f"Missing workspace.rename in capabilities: {sorted(methods)[:30]}") + + created = c._call("workspace.create") or {} + ws_id = str(created.get("workspace_id") or "") + _must(bool(ws_id), f"workspace.create returned no workspace_id: {created}") + c._call("workspace.select", {"workspace_id": ws_id}) + + api_title = f"tmux-api-{stamp}" + c.rename_workspace(api_title, workspace=ws_id) + _must(_workspace_title(c, ws_id) == api_title, "workspace.rename API did not update workspace title") + + cli_title = f"tmux cli {stamp}" + _run_cli(cli, ["rename-workspace", "--workspace", ws_id, cli_title]) + _must(_workspace_title(c, ws_id) == cli_title, "cmux rename-workspace did not update workspace title") + + alias_title = f"tmux alias {stamp}" + _run_cli(cli, ["rename-window", "--workspace", ws_id, alias_title]) + _must(_workspace_title(c, ws_id) == alias_title, "cmux rename-window did not update workspace title") + + current_title = f"tmux current {stamp}" + _run_cli(cli, ["rename-window", current_title]) + _must( + _workspace_title(c, ws_id) == current_title, + "cmux rename-window without --workspace should target current workspace", + ) + + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + invalid = subprocess.run( + [cli, "--socket", SOCKET_PATH, "rename-window", "--workspace", ws_id], + capture_output=True, + text=True, + check=False, + env=env, + ) + invalid_output = f"{invalid.stdout}\n{invalid.stderr}" + _must(invalid.returncode != 0, "Expected rename-window without title to fail") + _must( + "rename-window requires a title" in invalid_output, + f"Unexpected error for rename-window without title: {invalid_output!r}", + ) + + print("PASS: tmux rename-window parity works via workspace.rename and CLI aliases") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_tmux_compat_matrix.py b/tests_v2/test_tmux_compat_matrix.py new file mode 100644 index 00000000..59ee3d3a --- /dev/null +++ b/tests_v2/test_tmux_compat_matrix.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Regression: tmux compatibility command matrix (implemented + explicit not-supported).""" + +import glob +import json +import os +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from typing import Callable, List, Tuple + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _wait_for(pred: Callable[[], bool], timeout_s: float = 5.0, step_s: float = 0.05) -> None: + start = time.time() + while time.time() - start < timeout_s: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli(cli: str, args: List[str], *, expect_ok: bool = True) -> subprocess.CompletedProcess[str]: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + cmd = [cli, "--socket", SOCKET_PATH] + args + proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) + if expect_ok and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}") + return proc + + +def _pane_selected_surface(c: cmux, pane_id: str) -> str: + rows = c.list_pane_surfaces(pane_id) + for _idx, sid, _title, selected in rows: + if selected: + return sid + if rows: + return rows[0][1] + raise cmuxError(f"pane {pane_id} has no surfaces") + + +def _pane_surface_ids(c: cmux, pane_id: str) -> List[str]: + rows = c.list_pane_surfaces(pane_id) + return [sid for _idx, sid, _title, _selected in rows] + + +def _surface_has(c: cmux, workspace_id: str, surface_id: str, token: str) -> bool: + payload = c._call("surface.read_text", {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}) or {} + return token in str(payload.get("text") or "") + + +def _layout_panes(c: cmux) -> List[dict]: + layout_payload = c.layout_debug() or {} + layout = layout_payload.get("layout") or {} + panes = layout.get("panes") or [] + return list(panes) + + +def _pane_extent(c: cmux, pane_id: str, axis: str) -> float: + panes = _layout_panes(c) + for pane in panes: + pid = str(pane.get("paneId") or pane.get("pane_id") or "") + if pid != pane_id: + continue + frame = pane.get("frame") or {} + return float(frame.get(axis) or 0.0) + raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}") + + +def _pick_resize_target(c: cmux, pane_ids: List[str]) -> Tuple[str, str, str]: + panes = [p for p in _layout_panes(c) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids] + if len(panes) < 2: + raise cmuxError(f"Need >=2 panes for resize test, got {panes}") + + def x_of(p: dict) -> float: + return float((p.get("frame") or {}).get("x") or 0.0) + + def y_of(p: dict) -> float: + return float((p.get("frame") or {}).get("y") or 0.0) + + x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes) + y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes) + + if x_span >= y_span: + target = min(panes, key=x_of) + return str(target.get("paneId") or target.get("pane_id") or ""), "-R", "width" + + target = min(panes, key=y_of) + return str(target.get("paneId") or target.get("pane_id") or ""), "-D", "height" + + +def main() -> int: + cli = _find_cli_binary() + stamp = int(time.time() * 1000) + + with cmux(SOCKET_PATH) as c: + caps = c.capabilities() or {} + methods = set(caps.get("methods") or []) + for method in [ + "workspace.next", + "workspace.previous", + "workspace.last", + "pane.swap", + "pane.break", + "pane.join", + "pane.last", + "surface.clear_history", + ]: + _must(method in methods, f"Missing capability {method!r}") + + ws = c.new_workspace() + c.select_workspace(ws) + _ = c.new_split("right") + time.sleep(0.2) + + panes = [pid for _pidx, pid, _count, _focused in c.list_panes()] + _must(len(panes) >= 2, f"Expected >=2 panes, got {panes}") + p1, p2 = panes[0], panes[1] + + s1 = _pane_selected_surface(c, p1) + s2 = _pane_selected_surface(c, p2) + + capture_token = f"TMUX_CAPTURE_{stamp}" + c.send_surface(s1, f"echo {capture_token}\n") + _wait_for(lambda: _surface_has(c, ws, s1, capture_token)) + + cap = _run_cli(cli, ["capture-pane", "--workspace", ws, "--surface", s1, "--scrollback"]) + _must(capture_token in cap.stdout, f"capture-pane missing token: {cap.stdout!r}") + + pipe_file = Path(tempfile.gettempdir()) / f"cmux_pipe_pane_{stamp}.log" + _run_cli(cli, ["pipe-pane", "--workspace", ws, "--surface", s1, "--command", f"cat > {pipe_file}"]) + piped = pipe_file.read_text() if pipe_file.exists() else "" + _must(capture_token in piped, f"pipe-pane output missing token: {piped!r}") + + wait_name = f"tmux_wait_{stamp}" + waiter = _run_cli(cli, ["wait-for", wait_name, "--timeout", "5"], expect_ok=False) + _must(waiter.returncode != 0, "wait-for without signal should time out when run synchronously in test") + signaler = subprocess.Popen( + [cli, "--socket", SOCKET_PATH, "wait-for", wait_name, "--timeout", "5"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env={k: v for k, v in os.environ.items() if k not in {"CMUX_WORKSPACE_ID", "CMUX_SURFACE_ID"}}, + ) + time.sleep(0.2) + _run_cli(cli, ["wait-for", "-S", wait_name]) + out, err = signaler.communicate(timeout=5) + _must(signaler.returncode == 0, f"wait-for signal/wait failed: out={out!r} err={err!r}") + + title = f"tmux-title-{stamp}" + _run_cli(cli, ["rename-window", "--workspace", ws, title]) + find = _run_cli(cli, ["find-window", title]) + _must(title in find.stdout, f"find-window title search failed: {find.stdout!r}") + + ws2 = c.new_workspace() + ws3 = c.new_workspace() + c.select_workspace(ws) + c.select_workspace(ws2) + _run_cli(cli, ["last-window"]) + _must(c.current_workspace() == ws, f"last-window should navigate history back to ws={ws}") + _run_cli(cli, ["next-window"]) + _must(c.current_workspace() == ws2, f"next-window should move to ws2={ws2}") + _run_cli(cli, ["previous-window"]) + _must(c.current_workspace() == ws, f"previous-window should move back to ws={ws}") + c.select_workspace(ws) + + pre_p1 = _pane_selected_surface(c, p1) + pre_p2 = _pane_selected_surface(c, p2) + _run_cli(cli, ["swap-pane", "--workspace", ws, "--pane", p1, "--target-pane", p2]) + post_p1_ids = set(_pane_surface_ids(c, p1)) + post_p2_ids = set(_pane_surface_ids(c, p2)) + _must(pre_p2 in post_p1_ids, f"swap-pane should move target surface into source pane (p1={post_p1_ids}, pre_p2={pre_p2})") + _must(pre_p1 in post_p2_ids, f"swap-pane should move source surface into target pane (p2={post_p2_ids}, pre_p1={pre_p1})") + + s_break = _pane_selected_surface(c, p1) + br = _run_cli(cli, ["--json", "--id-format", "both", "break-pane", "--workspace", ws, "--surface", s_break]) + br_payload = json.loads(br.stdout or "{}") + ws_break = str(br_payload.get("workspace_id") or "") + _must(bool(ws_break), f"break-pane returned invalid payload: {br_payload}") + _must(ws_break in [wid for _idx, wid, _title, _sel in c.list_workspaces()], "break-pane workspace missing from list") + _run_cli(cli, ["join-pane", "--workspace", ws, "--surface", s_break, "--target-pane", p2]) + _must(s_break in _pane_surface_ids(c, p2), f"join-pane should move broken surface into target pane {p2}") + + current_panes = [pid for _pidx, pid, _count, _focused in c.list_panes()] + if len(current_panes) < 2: + _ = c.new_split("right") + time.sleep(0.2) + current_panes = [pid for _pidx, pid, _count, _focused in c.list_panes()] + _must(len(current_panes) >= 2, f"Expected >=2 panes after break/join, got {current_panes}") + lp_source, lp_target = current_panes[0], current_panes[1] + + c.focus_pane(lp_source) + c.focus_pane(lp_target) + _run_cli(cli, ["last-pane", "--workspace", ws]) + ident = c.identify() + focused = ident.get("focused") or {} + _must( + str(focused.get("pane_id") or "") == lp_source, + f"last-pane should focus previous pane {lp_source}, focused={focused}", + ) + + _run_cli(cli, ["clear-history", "--workspace", ws, "--surface", s1]) + + _run_cli(cli, ["set-hook", "workspace-created", "echo created"]) + hooks = _run_cli(cli, ["set-hook", "--list"]) + _must("workspace-created" in hooks.stdout, f"set-hook --list missing stored hook: {hooks.stdout!r}") + _run_cli(cli, ["set-hook", "--unset", "workspace-created"]) + hooks2 = _run_cli(cli, ["set-hook", "--list"]) + _must("workspace-created" not in hooks2.stdout, f"set-hook --unset failed: {hooks2.stdout!r}") + + for cmd in (["popup"], ["bind-key", "C-b", "split-window"], ["unbind-key", "C-b"], ["copy-mode"]): + proc = _run_cli(cli, cmd, expect_ok=False) + merged = f"{proc.stdout}\n{proc.stderr}".lower() + _must(proc.returncode != 0 and "not supported" in merged, f"Expected not_supported for {cmd}, got: {merged!r}") + + resize_target, resize_flag, resize_axis = _pick_resize_target(c, current_panes) + pre_extent = _pane_extent(c, resize_target, resize_axis) + _run_cli(cli, ["resize-pane", "--pane", resize_target, resize_flag, "--amount", "80"]) + _wait_for( + lambda: _pane_extent(c, resize_target, resize_axis) > pre_extent + 1.0, + timeout_s=3.0, + ) + + buffer_token = f"TMUX_BUFFER_{stamp}" + _run_cli(cli, ["set-buffer", "--name", "tmuxbuf", f"echo {buffer_token}\\n"]) + buffers = _run_cli(cli, ["list-buffers"]) + _must("tmuxbuf" in buffers.stdout, f"list-buffers missing tmuxbuf: {buffers.stdout!r}") + _run_cli(cli, ["paste-buffer", "--name", "tmuxbuf", "--workspace", ws, "--surface", s1]) + _wait_for(lambda: _surface_has(c, ws, s1, buffer_token)) + + respawn_token = f"TMUX_RESPAWN_{stamp}" + _run_cli(cli, ["respawn-pane", "--workspace", ws, "--surface", s1, "--command", f"echo {respawn_token}"]) + _wait_for(lambda: _surface_has(c, ws, s1, respawn_token)) + + msg = f"tmux-message-{stamp}" + shown = _run_cli(cli, ["display-message", "-p", msg]) + _must(msg in shown.stdout, f"display-message -p should print message: {shown.stdout!r}") + + print("PASS: tmux compatibility matrix commands are wired and tested") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/bonsplit b/vendor/bonsplit index ae234a22..6ac667d3 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit ae234a227cb77cc4f34e28098a565e987ca23d87 +Subproject commit 6ac667d3a9c359b84f920eac4a2ffb027e3bf745 diff --git a/web/app/community/page.tsx b/web/app/community/page.tsx index 805ec62c..a46fd614 100644 --- a/web/app/community/page.tsx +++ b/web/app/community/page.tsx @@ -54,7 +54,7 @@ export default function CommunityPage() { <div className="grid gap-4 sm:grid-cols-2"> <CommunityLink - href="https://discord.gg/QRxkhZgY" + href="https://discord.com/invite/QRxkhZgY" name="Discord" action="Join our Discord" description="Chat with the community, get help, and share feedback" diff --git a/web/app/components/nav-links.tsx b/web/app/components/nav-links.tsx index 1f5c5138..9caa4829 100644 --- a/web/app/components/nav-links.tsx +++ b/web/app/components/nav-links.tsx @@ -57,7 +57,7 @@ export function SiteFooter() { GitHub </a> <a href="https://twitter.com/manaflowai" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">Twitter</a> - <a href="https://discord.gg/QRxkhZgY" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">Discord</a> + <a href="https://discord.com/invite/QRxkhZgY" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">Discord</a> <Link href="/privacy-policy" className="hover:text-foreground transition-colors">Privacy</Link> <Link href="/terms-of-service" className="hover:text-foreground transition-colors">Terms</Link> <Link href="/eula" className="hover:text-foreground transition-colors">EULA</Link> diff --git a/web/app/docs/api/page.tsx b/web/app/docs/api/page.tsx index 07a11b52..6f7322e7 100644 --- a/web/app/docs/api/page.tsx +++ b/web/app/docs/api/page.tsx @@ -61,15 +61,26 @@ export default function ApiPage() { <code>/tmp/cmux-debug.sock</code> </td> </tr> + <tr> + <td>Tagged debug build</td> + <td> + <code>/tmp/cmux-debug-<tag>.sock</code> + </td> + </tr> </tbody> </table> <p> Override with the <code>CMUX_SOCKET_PATH</code> environment variable. - Commands are newline-terminated JSON: + Send one newline-terminated JSON request per call: </p> - <CodeBlock lang="json">{`{"command": "command-name", "arg1": "value1"} + <CodeBlock lang="json">{`{"id":"req-1","method":"workspace.list","params":{}} // Response: -{"success": true, "data": {...}}`}</CodeBlock> +{"id":"req-1","ok":true,"result":{"workspaces":[...]}}`}</CodeBlock> + <Callout> + JSON socket requests must use <code>method</code> and{" "} + <code>params</code>. Legacy v1 JSON payloads such as{" "} + <code>{`{"command":"..."}`}</code> are not supported. + </Callout> <h2>Access modes</h2> <table> @@ -77,6 +88,7 @@ export default function ApiPage() { <tr> <th>Mode</th> <th>Description</th> + <th>How to enable</th> </tr> </thead> <tbody> @@ -85,24 +97,31 @@ export default function ApiPage() { <strong>Off</strong> </td> <td>Socket disabled</td> + <td>Settings UI or <code>CMUX_SOCKET_MODE=off</code></td> </tr> <tr> <td> - <strong>Notifications only</strong> + <strong>cmux processes only</strong> </td> - <td>Only notification commands allowed</td> + <td> + Only processes spawned inside cmux terminals can connect. + </td> + <td>Default mode in Settings UI</td> </tr> <tr> <td> - <strong>Full control</strong> + <strong>allowAll</strong> + </td> + <td>Allow any local process to connect (no ancestry check).</td> + <td> + Environment override only: <code>CMUX_SOCKET_MODE=allowAll</code> </td> - <td>All commands enabled</td> </tr> </tbody> </table> <Callout type="warn"> - On shared machines, use “Notifications only” mode to prevent - other users from controlling your terminals. + On shared machines, use <strong>Off</strong> or{" "} + <strong>cmux processes only</strong>. </Callout> <h2>CLI options</h2> @@ -126,6 +145,12 @@ export default function ApiPage() { </td> <td>Output in JSON format</td> </tr> + <tr> + <td> + <code>--window ID</code> + </td> + <td>Target a specific window</td> + </tr> <tr> <td> <code>--workspace ID</code> @@ -138,6 +163,12 @@ export default function ApiPage() { </td> <td>Target a specific surface</td> </tr> + <tr> + <td> + <code>--id-format refs|uuids|both</code> + </td> + <td>Control identifier format in JSON output</td> + </tr> </tbody> </table> @@ -148,32 +179,32 @@ export default function ApiPage() { desc="List all open workspaces." cli={`cmux list-workspaces cmux list-workspaces --json`} - socket={`{"command": "list-workspaces"}`} + socket={`{"id":"ws-list","method":"workspace.list","params":{}}`} /> <Cmd name="new-workspace" desc="Create a new workspace." cli={`cmux new-workspace`} - socket={`{"command": "new-workspace"}`} + socket={`{"id":"ws-new","method":"workspace.create","params":{}}`} /> <Cmd name="select-workspace" desc="Switch to a specific workspace." cli={`cmux select-workspace --workspace <id>`} - socket={`{"command": "select-workspace", "id": "<id>"}`} + socket={`{"id":"ws-select","method":"workspace.select","params":{"workspace_id":"<id>"}}`} /> <Cmd name="current-workspace" desc="Get the currently active workspace." cli={`cmux current-workspace cmux current-workspace --json`} - socket={`{"command": "current-workspace"}`} + socket={`{"id":"ws-current","method":"workspace.current","params":{}}`} /> <Cmd name="close-workspace" desc="Close a workspace." cli={`cmux close-workspace --workspace <id>`} - socket={`{"command": "close-workspace", "id": "<id>"}`} + socket={`{"id":"ws-close","method":"workspace.close","params":{"workspace_id":"<id>"}}`} /> <h2>Split commands</h2> @@ -183,20 +214,20 @@ cmux current-workspace --json`} desc="Create a new split pane. Directions: left, right, up, down." cli={`cmux new-split right cmux new-split down`} - socket={`{"command": "new-split", "direction": "right"}`} + socket={`{"id":"split-new","method":"surface.split","params":{"direction":"right"}}`} /> <Cmd name="list-surfaces" desc="List all surfaces in the current workspace." cli={`cmux list-surfaces cmux list-surfaces --json`} - socket={`{"command": "list-surfaces"}`} + socket={`{"id":"surface-list","method":"surface.list","params":{}}`} /> <Cmd name="focus-surface" desc="Focus a specific surface." cli={`cmux focus-surface --surface <id>`} - socket={`{"command": "focus-surface", "id": "<id>"}`} + socket={`{"id":"surface-focus","method":"surface.focus","params":{"surface_id":"<id>"}}`} /> <h2>Input commands</h2> @@ -206,25 +237,25 @@ cmux list-surfaces --json`} desc="Send text input to the focused terminal." cli={`cmux send "echo hello" cmux send "ls -la\\n"`} - socket={`{"command": "send", "text": "echo hello\\n"}`} + socket={`{"id":"send-text","method":"surface.send_text","params":{"text":"echo hello\\n"}}`} /> <Cmd name="send-key" desc="Send a key press. Keys: enter, tab, escape, backspace, delete, up, down, left, right." cli={`cmux send-key enter`} - socket={`{"command": "send-key", "key": "enter"}`} + socket={`{"id":"send-key","method":"surface.send_key","params":{"key":"enter"}}`} /> <Cmd name="send-surface" desc="Send text to a specific surface." cli={`cmux send-surface --surface <id> "command"`} - socket={`{"command": "send-surface", "id": "<id>", "text": "command"}`} + socket={`{"id":"send-surface","method":"surface.send_text","params":{"surface_id":"<id>","text":"command"}}`} /> <Cmd name="send-key-surface" desc="Send a key press to a specific surface." cli={`cmux send-key-surface --surface <id> enter`} - socket={`{"command": "send-key-surface", "id": "<id>", "key": "enter"}`} + socket={`{"id":"send-key-surface","method":"surface.send_key","params":{"surface_id":"<id>","key":"enter"}}`} /> <h2>Notification commands</h2> @@ -234,21 +265,20 @@ cmux send "ls -la\\n"`} desc="Send a notification." cli={`cmux notify --title "Title" --body "Body" cmux notify --title "T" --subtitle "S" --body "B"`} - socket={`{"command": "notify", "title": "Title", - "subtitle": "S", "body": "Body"}`} + socket={`{"id":"notify","method":"notification.create","params":{"title":"Title","subtitle":"S","body":"Body"}}`} /> <Cmd name="list-notifications" desc="List all notifications." cli={`cmux list-notifications cmux list-notifications --json`} - socket={`{"command": "list-notifications"}`} + socket={`{"id":"notif-list","method":"notification.list","params":{}}`} /> <Cmd name="clear-notifications" desc="Clear all notifications." cli={`cmux clear-notifications`} - socket={`{"command": "clear-notifications"}`} + socket={`{"id":"notif-clear","method":"notification.clear","params":{}}`} /> <h2>Utility commands</h2> @@ -257,8 +287,22 @@ cmux list-notifications --json`} name="ping" desc="Check if cmux is running and responsive." cli={`cmux ping`} - socket={`{"command": "ping"} -// Response: {"success": true, "pong": true}`} + socket={`{"id":"ping","method":"system.ping","params":{}} +// Response: {"id":"ping","ok":true,"result":{"pong":true}}`} + /> + <Cmd + name="capabilities" + desc="List available socket methods and current access mode." + cli={`cmux capabilities +cmux capabilities --json`} + socket={`{"id":"caps","method":"system.capabilities","params":{}}`} + /> + <Cmd + name="identify" + desc="Show focused window/workspace/pane/surface context." + cli={`cmux identify +cmux identify --json`} + socket={`{"id":"identify","method":"system.identify","params":{}}`} /> <h2>Environment variables</h2> @@ -274,14 +318,16 @@ cmux list-notifications --json`} <td> <code>CMUX_SOCKET_PATH</code> </td> - <td>Override the default socket path</td> + <td>Override the socket path used by CLI and integrations</td> </tr> <tr> <td> <code>CMUX_SOCKET_ENABLE</code> </td> <td> - Enable/disable socket (<code>1</code>/<code>0</code>) + Force-enable/disable socket (<code>1</code>/<code>0</code>,{" "} + <code>true</code>/<code>false</code>, <code>on</code>/ + <code>off</code>) </td> </tr> <tr> @@ -289,8 +335,10 @@ cmux list-notifications --json`} <code>CMUX_SOCKET_MODE</code> </td> <td> - Override access mode (<code>full</code>,{" "} - <code>notifications</code>, <code>off</code>) + Override access mode (<code>cmuxOnly</code>,{" "} + <code>allowAll</code>, <code>off</code>). Also accepts{" "} + <code>cmux-only</code>/<code>cmux_only</code> and{" "} + <code>allow-all</code>/<code>allow_all</code> </td> </tr> <tr> @@ -324,51 +372,60 @@ cmux list-notifications --json`} </tbody> </table> <Callout> - Environment variables override app settings. Use the socket check to - distinguish cmux from regular Ghostty. + Legacy <code>CMUX_SOCKET_MODE</code> values <code>full</code> and{" "} + <code>notifications</code> are still accepted for compatibility. </Callout> <h2>Detecting cmux</h2> - <CodeBlock title="bash" lang="bash">{`# Check for the socket -[ -S /tmp/cmux.sock ] && echo "In cmux" + <CodeBlock title="bash" lang="bash">{`# Prefer explicit socket path if set +SOCK="\${CMUX_SOCKET_PATH:-/tmp/cmux.sock}" +[ -S "$SOCK" ] && echo "Socket available" # Check for the CLI command -v cmux &>/dev/null && echo "cmux available" +# In cmux-managed terminals these are auto-set +[ -n "\${CMUX_WORKSPACE_ID:-}" ] && [ -n "\${CMUX_SURFACE_ID:-}" ] && echo "Inside cmux surface" + # Distinguish from regular Ghostty -[ "$TERM_PROGRAM" = "ghostty" ] && [ -S /tmp/cmux.sock ] && echo "In cmux"`}</CodeBlock> +[ "$TERM_PROGRAM" = "ghostty" ] && [ -n "\${CMUX_WORKSPACE_ID:-}" ] && echo "In cmux"`}</CodeBlock> <h2>Examples</h2> <h3>Python client</h3> - <CodeBlock title="python" lang="python">{`import socket, json + <CodeBlock title="python" lang="python">{`import json +import os +import socket -def send_command(cmd): - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect('/tmp/cmux.sock') - sock.send(json.dumps(cmd).encode() + b'\\n') - response = sock.recv(4096).decode() - sock.close() - return json.loads(response) +SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux.sock") + +def rpc(method, params=None, req_id=1): + payload = {"id": req_id, "method": method, "params": params or {}} + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.connect(SOCKET_PATH) + sock.sendall(json.dumps(payload).encode("utf-8") + b"\\n") + return json.loads(sock.recv(65536).decode("utf-8")) # List workspaces -print(send_command({"command": "list-workspaces"})) +print(rpc("workspace.list", req_id="ws")) # Send notification -send_command({ - "command": "notify", - "title": "Hello", - "body": "From Python!" -})`}</CodeBlock> +print(rpc( + "notification.create", + {"title": "Hello", "body": "From Python!"}, + req_id="notify" +))`}</CodeBlock> <h3>Shell script</h3> <CodeBlock title="bash" lang="bash">{`#!/bin/bash +SOCK="\${CMUX_SOCKET_PATH:-/tmp/cmux.sock}" + cmux_cmd() { - echo "$1" | nc -U /tmp/cmux.sock + printf "%s\\n" "$1" | nc -U "$SOCK" } -cmux_cmd '{"command": "list-workspaces"}' -cmux_cmd '{"command": "notify", "title": "Done", "body": "Task complete"}'`}</CodeBlock> +cmux_cmd '{"id":"ws","method":"workspace.list","params":{}}' +cmux_cmd '{"id":"notify","method":"notification.create","params":{"title":"Done","body":"Task complete"}}'`}</CodeBlock> <h3>Build script with notification</h3> <CodeBlock title="bash" lang="bash">{`#!/bin/bash diff --git a/web/app/docs/configuration/page.tsx b/web/app/docs/configuration/page.tsx index 4a2f9ffc..49bea384 100644 --- a/web/app/docs/configuration/page.tsx +++ b/web/app/docs/configuration/page.tsx @@ -95,15 +95,17 @@ working-directory = ~/Projects`}</CodeBlock> <strong>Off</strong> — no socket control (most secure) </li> <li> - <strong>Notifications only</strong> — only allow notification commands + <strong>cmux processes only</strong> — only allow processes started + inside cmux terminals to connect </li> <li> - <strong>Full control</strong> — allow all socket commands + <strong>allowAll</strong> — allow any local process to connect ( + <code>CMUX_SOCKET_MODE=allowAll</code>, env override only) </li> </ul> <Callout type="warn"> - On shared machines, consider using “Notifications only” mode - to prevent other processes from controlling your terminals. + On shared machines, consider using “Off” or + “cmux processes only” mode. </Callout> <h2>Example config</h2> diff --git a/web/app/keyboard-shortcuts.tsx b/web/app/keyboard-shortcuts.tsx index e70d174a..f4c483c0 100644 --- a/web/app/keyboard-shortcuts.tsx +++ b/web/app/keyboard-shortcuts.tsx @@ -80,6 +80,16 @@ const CATEGORIES: ShortcutCategory[] = [ combos: [["⌥", "⌘", "←/→/↑/↓"]], description: "Focus pane directionally", }, + { + id: "sp-browser-right", + combos: [["⌥", "⌘", "D"]], + description: "Split browser right", + }, + { + id: "sp-browser-down", + combos: [["⌥", "⌘", "⇧", "D"]], + description: "Split browser down", + }, ], }, { @@ -88,8 +98,8 @@ const CATEGORIES: ShortcutCategory[] = [ shortcuts: [ { id: "br-open", - combos: [["⌘", "⇧", "B"]], - description: "Open browser in split", + combos: [["⌘", "⇧", "L"]], + description: "Open browser surface", }, { id: "br-addr", combos: [["⌘", "L"]], description: "Focus address bar" }, { id: "br-forward", combos: [["⌘", "]"]], description: "Forward" },