diff --git a/CLI/cmux.swift b/CLI/cmux.swift index bdb617b4..b0740d9f 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -860,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 { @@ -868,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") @@ -988,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()) @@ -2803,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> @@ -3025,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()) @@ -3462,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> @@ -3474,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/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d353ee7d..2b2db96f 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) @@ -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..726a90d7 --- /dev/null +++ b/Sources/BrowserWindowPortal.swift @@ -0,0 +1,867 @@ +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() + } + + 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) + } + + 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 513f2125..dabfbe24 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 @@ -224,6 +225,9 @@ 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 } @@ -293,14 +297,26 @@ 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, phase: "entered") + return updateDragTarget(sender, phase: "entered") } override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { - return dragOperationForSender(sender, phase: "updated") + return updateDragTarget(sender, phase: "updated") + } + + override func draggingExited(_ sender: (any NSDraggingInfo)?) { + if let prev = activeDragWebView { + prev.draggingExited(sender) + activeDragWebView = nil + } } override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { @@ -310,38 +326,58 @@ final class FileDropOverlayView: NSView { pasteboardTypes: types, hasLocalDraggingSource: hasLocalDraggingSource ) + let webView = activeDragWebView + activeDragWebView = nil #if DEBUG - if shouldCapture || (types?.contains(.fileURL) ?? false) { - dlog( - "overlay.fileDrop.perform capture=\(shouldCapture ? 1 : 0) " + - "localSource=\(hasLocalDraggingSource ? 1 : 0) " + - "types=\(debugPasteboardTypes(types))" - ) - } + dlog( + "overlay.fileDrop.perform capture=\(shouldCapture ? 1 : 0) " + + "localSource=\(hasLocalDraggingSource ? 1 : 0) " + + "hasWebView=\(webView != nil ? 1 : 0) " + + "types=\(debugPasteboardTypes(types))" + ) #endif guard shouldCapture else { return false } + 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, phase: String) -> NSDragOperation { + private func updateDragTarget(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation { + let loc = sender.draggingLocation let hasLocalDraggingSource = sender.draggingSource != nil let types = sender.draggingPasteboard.types let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( pasteboardTypes: types, hasLocalDraggingSource: hasLocalDraggingSource ) -#if DEBUG - if shouldCapture || (types?.contains(.fileURL) ?? false) { - dlog( - "overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " + - "localSource=\(hasLocalDraggingSource ? 1 : 0) " + - "types=\(debugPasteboardTypes(types))" - ) + let webView = shouldCapture ? webViewUnderPoint(loc) : nil + + if let prev = activeDragWebView, prev !== webView { + prev.draggingExited(sender) + activeDragWebView = nil } + + if let webView { + if activeDragWebView !== webView { + activeDragWebView = webView + return webView.draggingEntered(sender) + } + return webView.draggingUpdated(sender) + } + + let hasTerminalTarget = terminalUnderPoint(loc) != nil +#if DEBUG + dlog( + "overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " + + "localSource=\(hasLocalDraggingSource ? 1 : 0) " + + "hasWebView=\(webView != nil ? 1 : 0) " + + "hasTerminal=\(hasTerminalTarget ? 1 : 0) " + + "types=\(debugPasteboardTypes(types))" + ) #endif - guard shouldCapture, - terminalUnderPoint(sender.draggingLocation) != nil else { return [] } + guard shouldCapture, hasTerminalTarget else { return [] } return .copy } @@ -350,6 +386,22 @@ final class FileDropOverlayView: NSView { return types.map(\.rawValue).joined(separator: ",") } + /// 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, diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 7bc8194f..5f24ffe3 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -679,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, @@ -883,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, diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 6ecc0e6c..8b2b8d14 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -26,9 +26,13 @@ enum KeyboardShortcutSettings { case focusDown case splitRight case splitDown + case splitBrowserRight + case splitBrowserDown // Panels case openBrowser + case toggleBrowserDeveloperTools + case showBrowserJavaScriptConsole var id: String { rawValue } @@ -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 031887df..f2b86d0c 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 @@ -75,6 +76,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 @@ -769,6 +928,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 @@ -837,6 +1001,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 { @@ -856,9 +1028,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() @@ -877,6 +1050,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 @@ -907,6 +1085,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 @@ -916,6 +1100,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 @@ -1208,9 +1395,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. @@ -1226,8 +1424,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 } @@ -1240,7 +1437,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() } } @@ -1290,11 +1557,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 @@ -1308,6 +1580,183 @@ 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 visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? 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 { + developerToolsRestoreRetryAttempt = 0 + } 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) @@ -1416,6 +1865,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 { @@ -1439,12 +1966,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? @@ -1564,6 +2120,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 { @@ -1589,6 +2160,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. @@ -1599,7 +2171,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)) @@ -1607,4 +2183,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..671b1de0 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,141 @@ 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) + } + + 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 +2812,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 +2825,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 +2850,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 +2906,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 +3015,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 +3099,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 +3160,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/TabManager.swift b/Sources/TabManager.swift index f7585f2b..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 @@ -829,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) @@ -1253,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 @@ -1393,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 f3300268..ebd0d673 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": @@ -508,9 +511,6 @@ class TerminalController { case "read_terminal_text": return readTerminalText(args) - case "read_screen": - return readScreen(args) - case "render_stats": return renderStats(args) @@ -679,6 +679,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 @@ -708,6 +716,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)) @@ -720,6 +730,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": @@ -908,6 +928,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 @@ -972,6 +994,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", @@ -985,11 +1011,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", @@ -1695,6 +1728,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 @@ -2399,6 +2542,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) @@ -2626,6 +2918,269 @@ class TerminalController { return result } + private func v2PaneResize(params: [String: Any]) -> V2CallResult { + let direction = (v2String(params, "direction") ?? "").lowercased() + let amount = v2Int(params, "amount") ?? 1 + guard ["left", "right", "up", "down"].contains(direction), 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) + } + return .err( + code: "not_supported", + message: "pane.resize is not supported yet; Bonsplit does not currently expose a stable programmable divider API", + data: [ + "direction": direction, + "amount": amount + ] + ) + } + + 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 { @@ -6205,6 +6760,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) @@ -6237,6 +6909,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 @@ -6298,7 +6971,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) @@ -6766,82 +7438,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/Workspace.swift b/Sources/Workspace.swift index 7ed6e468..29348c39 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -105,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()) ) @@ -208,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 @@ -571,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( diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 3a2e5264..ffcde89b 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,6 +483,14 @@ 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) @@ -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 { @@ -2275,14 +2410,17 @@ struct SettingsView: View { @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(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 @@ -2303,6 +2441,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 @@ -2343,6 +2485,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)." @@ -2451,6 +2604,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 @@ -2574,6 +2790,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 @@ -2599,11 +2822,18 @@ struct SettingsView: View { browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser + 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 0060171f..f4ddbf21 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) @@ -339,6 +635,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,62 +687,56 @@ 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 UpdateFeedResolverTests: XCTestCase { +final class UpdateChannelSettingsTests: XCTestCase { + func testDefaultNightlyPreferenceIsDisabled() { + XCTAssertFalse(UpdateChannelSettings.defaultIncludeNightlyBuilds) + } + func testResolvedFeedFallsBackToStableWhenInfoFeedMissing() { - let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: nil) - XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL) + let suiteName = "UpdateChannelSettingsTests.MissingInfo.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: nil, defaults: defaults) + XCTAssertEqual(resolved.url, UpdateChannelSettings.stableFeedURL) XCTAssertFalse(resolved.isNightly) XCTAssertTrue(resolved.usedFallback) } func testResolvedFeedUsesInfoFeedForStableChannel() { + let suiteName = "UpdateChannelSettingsTests.InfoFeed.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + let infoFeed = "https://example.com/custom/appcast.xml" - let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed) + let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: infoFeed, defaults: defaults) XCTAssertEqual(resolved.url, infoFeed) XCTAssertFalse(resolved.isNightly) XCTAssertFalse(resolved.usedFallback) } - func testResolvedFeedDetectsNightlyChannelFromInfoFeed() { - let infoFeed = "https://example.com/nightly/appcast.xml" - let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed) - XCTAssertEqual(resolved.url, infoFeed) + func testResolvedFeedUsesNightlyWhenPreferenceEnabled() { + let suiteName = "UpdateChannelSettingsTests.Nightly.\(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: UpdateChannelSettings.includeNightlyBuildsKey) + let resolved = UpdateChannelSettings.resolvedFeedURLString( + infoFeedURL: "https://example.com/custom/appcast.xml", + defaults: defaults + ) + XCTAssertEqual(resolved.url, UpdateChannelSettings.nightlyFeedURL) XCTAssertTrue(resolved.isNightly) XCTAssertFalse(resolved.usedFallback) } @@ -753,54 +1080,6 @@ final class SidebarDropPlannerTests: XCTestCase { } } -final class SidebarOutsideDropResetPolicyTests: XCTestCase { - func testOutsideDropResetsOnlyWhenDragIsActiveAndPayloadMatches() { - let tabId = UUID() - - XCTAssertTrue( - SidebarOutsideDropResetPolicy.shouldResetDrag( - draggedTabId: tabId, - hasSidebarDragPayload: true - ) - ) - XCTAssertFalse( - SidebarOutsideDropResetPolicy.shouldResetDrag( - draggedTabId: nil, - hasSidebarDragPayload: true - ) - ) - XCTAssertFalse( - SidebarOutsideDropResetPolicy.shouldResetDrag( - draggedTabId: tabId, - hasSidebarDragPayload: false - ) - ) - } -} - -final class SidebarDragFailsafePolicyTests: XCTestCase { - func testRequestsClearOnlyWhenDragIsActiveAndMouseIsUp() { - XCTAssertTrue( - SidebarDragFailsafePolicy.shouldRequestClear( - isDragActive: true, - isLeftMouseButtonDown: false - ) - ) - XCTAssertFalse( - SidebarDragFailsafePolicy.shouldRequestClear( - isDragActive: true, - isLeftMouseButtonDown: true - ) - ) - XCTAssertFalse( - SidebarDragFailsafePolicy.shouldRequestClear( - isDragActive: false, - isLeftMouseButtonDown: false - ) - ) - } -} - final class SidebarDragAutoScrollPlannerTests: XCTestCase { func testAutoScrollPlanTriggersNearTopAndBottomOnly() { let topPlan = SidebarDragAutoScrollPlanner.plan(distanceToTop: 4, distanceToBottom: 96, edgeInset: 44, minStep: 2, maxStep: 12) @@ -2033,22 +2312,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 @@ -2244,6 +2507,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/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 4efc8c2b..2dc252fd 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,133 @@ 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 + )) + } +} diff --git a/scripts/setup.sh b/scripts/setup.sh index 2f4f6d2a..bcfeb818 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -21,13 +21,21 @@ 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 @@ -36,25 +44,34 @@ trap 'rmdir "$LOCK_DIR" >/dev/null 2>&1 || true' EXIT if [ -d "$CACHE_XCFRAMEWORK" ]; then echo "==> Reusing cached GhosttyKit.xcframework" else - if [ -d "$LOCAL_XCFRAMEWORK" ]; then - echo "==> Seeding cache from existing local GhosttyKit.xcframework" + # 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 - SRC_XCFRAMEWORK="$LOCAL_XCFRAMEWORK" - if [ ! -d "$SRC_XCFRAMEWORK" ]; then - echo "Error: GhosttyKit.xcframework not found at $SRC_XCFRAMEWORK" + 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 "$SRC_XCFRAMEWORK" "$TMP_DIR/GhosttyKit.xcframework" + cp -R "$LOCAL_XCFRAMEWORK" "$TMP_DIR/GhosttyKit.xcframework" rm -rf "$CACHE_XCFRAMEWORK" mv "$TMP_DIR/GhosttyKit.xcframework" "$CACHE_XCFRAMEWORK" rmdir "$TMP_DIR" 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_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..876e4130 --- /dev/null +++ b/tests_v2/test_tmux_compat_matrix.py @@ -0,0 +1,232 @@ +#!/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 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 = _run_cli(cli, ["resize-pane", "--pane", lp_source, "-L", "--amount", "5"], expect_ok=False) + _must(resize.returncode != 0, "Expected resize-pane to return not_supported until backend support is added") + + 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/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" },