From d2cacabe641eb0e919d2b1681c244b2e5cb39517 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 18 Sep 2026 18:06:01 -0700 Subject: [PATCH 1/3] refactor: extract terminal Cmd-click UI test coordinator --- Sources/AppDelegate.swift | 824 +----------------- .../TerminalCmdClickUITestConfiguration.swift | 18 + .../TerminalCmdClickUITestCoordinator.swift | 810 +++++++++++++++++ ...TerminalCmdClickUITestManifestWriter.swift | 31 + cmux.xcodeproj/project.pbxproj | 12 + 5 files changed, 892 insertions(+), 803 deletions(-) create mode 100644 Sources/Debug/UITests/TerminalCmdClickUITestConfiguration.swift create mode 100644 Sources/Debug/UITests/TerminalCmdClickUITestCoordinator.swift create mode 100644 Sources/Debug/UITests/TerminalCmdClickUITestManifestWriter.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 2d62aa3a112e..4df6862339e1 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1000,11 +1000,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var didSetupJumpUnreadUITest = false private var jumpUnreadFocusExpectation: (tabId: UUID, surfaceId: UUID)? private var jumpUnreadFocusObserver: NSObjectProtocol? - private var didSetupTerminalCmdClickUITest = false private var didSetupGotoSplitUITest = false private var didSetupBonsplitTabDragUITest = false private var didSetupTerminalViewportUITest = false - private var terminalCmdClickUITestPoller: DispatchSourceTimer? private var bonsplitTabDragUITestRecorder: DispatchSourceTimer? private var terminalViewportUITestRecorder: TerminalViewportUITestRecorder? private var gotoSplitUITestRecorder: DispatchSourceTimer? @@ -1015,6 +1013,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var didSetupFeedSidebarUITest = false private var didStartFeedSidebarUITestPush = false private var feedSidebarUITestObservers: [NSObjectProtocol] = [] + private lazy var terminalCmdClickUITestCoordinator = TerminalCmdClickUITestCoordinator( + dependencies: .init( + environment: ProcessInfo.processInfo.environment, + notificationCenter: .default, + tabManager: { [weak self] in self?.tabManager }, + window: { [weak self] tabManager in + guard let self, + let windowId = self.windowId(for: tabManager) else { return nil } + return self.mainWindow(for: windowId) + }, + managerToken: { [weak self] manager in + self?.debugManagerToken(manager) ?? "nil" + }, + sendText: { [weak self] text, workspace, beforeSend in + self?.sendTextWhenReady(text, to: workspace, beforeSend: beforeSend) + } + ) + ) + private var didSetupPortalStatsUITestDiagnostics = false private var portalStatsUITestObservers: [NSObjectProtocol] = [] private struct UITestRenderDiagnosticsSnapshot { @@ -2354,7 +2371,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent startSessionAutosaveTimerIfNeeded() #if DEBUG setupJumpUnreadUITestIfNeeded() - setupTerminalCmdClickUITestIfNeeded() + terminalCmdClickUITestCoordinator.startIfNeeded() setupGotoSplitUITestIfNeeded() setupBonsplitTabDragUITestIfNeeded() setupTerminalViewportUITestIfNeeded() @@ -2404,807 +2421,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } -#if DEBUG - private func setupTerminalCmdClickUITestIfNeeded() { - guard !didSetupTerminalCmdClickUITest else { return } - - let env = ProcessInfo.processInfo.environment - guard env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_SETUP"] == "1" else { - cmuxDebugLog("cmdclick.ui.setup skip reason=env_missing tag=\(env["CMUX_TAG"] ?? "nil")") - return - } - guard let manifestPath = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_PATH"]? - .trimmingCharacters(in: .whitespacesAndNewlines), - !manifestPath.isEmpty else { - cmuxDebugLog("cmdclick.ui.setup skip reason=missing_manifest_path") - return - } - didSetupTerminalCmdClickUITest = true - guard let fixtureDirectory = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_FIXTURE_DIR"]? - .trimmingCharacters(in: .whitespacesAndNewlines), - !fixtureDirectory.isEmpty else { - cmuxDebugLog("cmdclick.ui.setup error reason=missing_fixture_dir manifest=\(manifestPath)") - writeTerminalCmdClickUITestData(at: manifestPath, updates: [ - "setupError": "Missing fixture directory" - ]) - return - } - let commandPath = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_COMMAND_PATH"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - let screenshotDirectory = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_SCREENSHOT_DIR"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - let displayMode = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_DISPLAY_MODE"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - let lineFormat = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_LINE_FORMAT"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - let linePrefix = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_LINE_PREFIX"] ?? "" - let displaySuffix = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_DISPLAY_SUFFIX"] ?? "" - let displayAsAbsolutePath = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_DISPLAY_AS_ABSOLUTE_PATH"] == "1" - if let rawOpenSupportedFiles = env["CMUX_UI_TEST_OPEN_SUPPORTED_FILES_IN_CMUX"]? - .trimmingCharacters(in: .whitespacesAndNewlines), - !rawOpenSupportedFiles.isEmpty { - FileRouteSettingsStore(defaults: .standard).setSupportedFileRouteEnabled(rawOpenSupportedFiles == "1") - } - if let rawOpenMarkdown = env["CMUX_UI_TEST_OPEN_MARKDOWN_IN_CMUX_VIEWER"]? - .trimmingCharacters(in: .whitespacesAndNewlines), - !rawOpenMarkdown.isEmpty { - FileRouteSettingsStore(defaults: .standard).setMarkdownRouteEnabled(rawOpenMarkdown == "1") - } - let extraFileNamesJSON = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_EXTRA_FILE_NAMES_JSON"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - - let fileName = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_FILE_NAME"]? - .trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedFileName = (fileName?.isEmpty == false) ? fileName! : "Cmd Click Fixture.txt" - let fixtureDirectoryURL = URL(fileURLWithPath: fixtureDirectory, isDirectory: true) - let expectedFileURL = fixtureDirectoryURL.appendingPathComponent(resolvedFileName) - let siblingFileURL = fixtureDirectoryURL.appendingPathComponent("OtherFile") - let extraFileNames: [String] - if let extraFileNamesJSON, - let data = extraFileNamesJSON.data(using: .utf8), - let values = try? JSONSerialization.jsonObject(with: data) as? [String] { - extraFileNames = values - } else { - extraFileNames = [] - } - let escapedToken = resolvedFileName.replacingOccurrences(of: " ", with: "\\ ") - let baseDisplayToken = displayAsAbsolutePath ? expectedFileURL.path : resolvedFileName - let resolvedDisplayMode = (displayMode == "raw") ? "raw" : "escaped" - let resolvedLineFormat: String - switch lineFormat { - case "log": - resolvedLineFormat = "log" - case "alt_screen_log": - resolvedLineFormat = "alt_screen_log" - case "osc8": - resolvedLineFormat = "osc8" - default: - resolvedLineFormat = "grid" - } - cmuxDebugLog( - "cmdclick.ui.setup start manifest=\(manifestPath) fixture=\(fixtureDirectory) " + - "command=\(commandPath ?? "nil") display=\(resolvedDisplayMode) " + - "lineFormat=\(resolvedLineFormat) " + - "file=\(resolvedFileName)" - ) - func singleQuotedShellLiteral(_ text: String) -> String { - text.replacingOccurrences(of: "'", with: "'\"'\"'") - } - let displayToken: String - let shellCommand: String - switch resolvedLineFormat { - case "osc8": - displayToken = resolvedFileName - let escapedDisplayToken = singleQuotedShellLiteral(displayToken) - let escapedURL = singleQuotedShellLiteral(expectedFileURL.absoluteString) - shellCommand = "clear\rfor i in $(seq 1 48); do printf '\\033]8;;%s\\033\\\\%s\\033]8;;\\033\\\\\\n' '\(escapedURL)' '\(escapedDisplayToken)'; done\r" - case "log": - displayToken = "\(baseDisplayToken)\(displaySuffix)" - let blockLine = "\(linePrefix)\(displayToken)" - let shellBlockLine = singleQuotedShellLiteral(blockLine) - shellCommand = "clear\rfor i in $(seq 1 48); do printf '%s\\n' '\(shellBlockLine)'; done\r" - case "alt_screen_log": - displayToken = "\(baseDisplayToken)\(displaySuffix)" - let blockLine = "\(linePrefix)\(displayToken)" - let shellBlockLine = singleQuotedShellLiteral(blockLine) - shellCommand = "clear\rprintf '\\033[?1049h\\033[H\\033[2J'; for i in $(seq 1 48); do printf '%s\\n' '\(shellBlockLine)'; done\r" - default: - switch resolvedDisplayMode { - case "raw": - displayToken = "\(baseDisplayToken)\(displaySuffix)" - let blockLine = "\(displayToken) OtherFile" - let shellBlockLine = singleQuotedShellLiteral(blockLine) - shellCommand = "clear\rfor i in $(seq 1 48); do printf '%s\\n' '\(shellBlockLine)'; done\r" - default: - displayToken = "\(escapedToken)\(displaySuffix)" - let blockLine = Array(repeating: displayToken, count: 3).joined(separator: " ") - let shellBlockLine = singleQuotedShellLiteral(blockLine) - shellCommand = "clear\rfor i in $(seq 1 48); do printf '%s\\n' '\(shellBlockLine)'; done\r" - } - } - let deadline = Date().addingTimeInterval((commandPath?.isEmpty == false) ? 60.0 : 20.0) - var seeded = false - var resolved = false - var tokenPointPayload: [String: Any]? - var observers: [NSObjectProtocol] = [] - var lastHandledCommandID: String? - var screenshotSequence = 0 - - func rectPayload(_ rect: CGRect) -> [String: Double] { - [ - "x": rect.origin.x, - "y": rect.origin.y, - "width": rect.size.width, - "height": rect.size.height - ] - } - - func pointPayload(x: CGFloat, yFromTop: CGFloat) -> [String: Double] { - [ - "x": x, - "y": yFromTop - ] - } - - func doubleValue(_ value: Any?) -> Double? { - if let value = value as? Double { - return value - } - if let value = value as? NSNumber { - return value.doubleValue - } - return nil - } - - func pointFromPayload(_ key: String, in terminalPanel: TerminalPanel) -> NSPoint? { - guard let payload = tokenPointPayload?[key] as? [String: Any], - let x = doubleValue(payload["x"]), - let yFromTop = doubleValue(payload["y"]) else { - return nil - } - - let clampedX = min(max(CGFloat(x), 1), max(terminalPanel.hostedView.bounds.width - 1, 1)) - let clampedYFromTop = min( - max(CGFloat(yFromTop), 1), - max(terminalPanel.hostedView.bounds.height - 1, 1) - ) - return NSPoint( - x: clampedX, - y: terminalPanel.hostedView.bounds.height - clampedYFromTop - ) - } - - func pointForTokenColumnOffset(_ offset: Int, in terminalPanel: TerminalPanel) -> NSPoint? { - guard let selectionStart = pointFromPayload("tokenSelectionStartInTerminal", in: terminalPanel), - let tokenCellMetrics = tokenPointPayload?["tokenCellMetrics"] as? [String: Any], - let cellWidth = doubleValue(tokenCellMetrics["cellWidth"]) else { - return nil - } - - let unclampedX = selectionStart.x + (CGFloat(offset) * CGFloat(cellWidth)) - let clampedX = min(max(unclampedX, 1), max(terminalPanel.hostedView.bounds.width - 1, 1)) - return NSPoint(x: clampedX, y: selectionStart.y) - } - - func commandPoint( - from command: [String: Any], - defaultPayloadKey: String, - in terminalPanel: TerminalPanel - ) -> NSPoint? { - if let tokenColumnOffset = command["tokenColumnOffset"] as? Int { - return pointForTokenColumnOffset(tokenColumnOffset, in: terminalPanel) - } - if let tokenColumnOffset = command["tokenColumnOffset"] as? NSNumber { - return pointForTokenColumnOffset(tokenColumnOffset.intValue, in: terminalPanel) - } - return pointFromPayload(defaultPayloadKey, in: terminalPanel) - } - - func loadCommand(at path: String) -> [String: Any]? { - let url = URL(fileURLWithPath: path) - guard let data = try? Data(contentsOf: url), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return nil - } - return object - } - - func tokenPoints(in terminalPanel: TerminalPanel, visibleText: String) -> [String: Any]? { - guard let surface = terminalPanel.surface.surface else { return nil } - let bounds = terminalPanel.hostedView.bounds - guard bounds.width > 0, bounds.height > 0 else { return nil } - - let size = ghostty_surface_size(surface) - let rows = max(Int(size.rows), 1) - let cols = max(Int(size.columns), 1) - let debugCellSize = terminalPanel.hostedView.debugCellSize - let cellWidth = debugCellSize.width > 0 ? debugCellSize.width : CGFloat(size.cell_width_px) - let cellHeight = debugCellSize.height > 0 ? debugCellSize.height : CGFloat(size.cell_height_px) - guard cellWidth > 0, cellHeight > 0 else { return nil } - - let xInset = max(0, (bounds.width - (CGFloat(cols) * cellWidth)) / 2) - let yInset = max(0, (bounds.height - (CGFloat(rows) * cellHeight)) / 2) - let pointClampX: (CGFloat) -> CGFloat = { x in - min(bounds.width - 4, max(4, x)) - } - let pointClampY: (CGFloat) -> CGFloat = { y in - min(bounds.height - 4, max(4, y)) - } - - let rawVisibleLines = visibleText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - let visibleLines = rawVisibleLines.count > rows ? Array(rawVisibleLines.suffix(rows)) : rawVisibleLines - let rowOffset = max(0, rows - visibleLines.count) - - var matchedRowFromTop: Int? - var matchedColumnStart: Int? - var matchedColumnEnd: Int? - var matchedLine = "" - var matchingLines: [(lineIndex: Int, line: String, ranges: [Range])] = [] - - for (lineIndex, line) in visibleLines.enumerated() { - var searchStart = line.startIndex - var ranges: [Range] = [] - while searchStart < line.endIndex, - let range = line.range(of: displayToken, range: searchStart.. String { - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_.")) - let scalars = label.unicodeScalars.map { allowed.contains($0) ? Character($0) : "-" } - let cleaned = String(scalars).trimmingCharacters(in: CharacterSet(charactersIn: "-_.")) - return cleaned.isEmpty ? "capture" : cleaned - } - - @MainActor - func captureWindowSnapshotIfRequested(label: String, window: NSWindow) -> String? { - guard let screenshotDirectory, - !screenshotDirectory.isEmpty, - let contentView = window.contentView else { - return nil - } - let bounds = contentView.bounds - guard !bounds.isEmpty, - let bitmap = contentView.bitmapImageRepForCachingDisplay(in: bounds) else { - return nil - } - contentView.cacheDisplay(in: bounds, to: bitmap) - guard let data = bitmap.representation(using: .png, properties: [:]) else { - return nil - } - do { - let directoryURL = URL(fileURLWithPath: screenshotDirectory, isDirectory: true) - try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) - let sequence = String(format: "%03d", screenshotSequence) - screenshotSequence += 1 - let fileURL = directoryURL - .appendingPathComponent("\(sequence)-\(safeScreenshotLabel(label)).png") - try data.write(to: fileURL, options: .atomic) - return fileURL.path - } catch { - cmuxDebugLog("cmdclick.ui.snapshot failed label=\(label) error=\(error.localizedDescription)") - return nil - } - } - - func cmdClickUITestTerminalPanel(in workspace: Workspace?) -> TerminalPanel? { - guard let workspace else { return nil } - if let inputPanel = workspace.focusedTerminalInputTarget()?.panel { - return inputPanel - } - return workspace.panels.values - .compactMap { $0 as? TerminalPanel } - .first { panel in - panel.surface.isViewInWindow && - panel.hostedView.debugPortalVisibleInUI && - !panel.hostedView.debugPortalFrameInWindow.isEmpty - } - } - - @MainActor - func executePendingCommandIfNeeded( - workspace: Workspace, - terminalPanel: TerminalPanel, - window: NSWindow - ) { - guard let commandPath, - !commandPath.isEmpty, - let command = loadCommand(at: commandPath), - let commandID = command["id"] as? String, - commandID != lastHandledCommandID else { - return - } - - let action = (command["action"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - var payload: [String: Any] = [ - "lastCommandId": commandID, - "lastCommandAction": action, - "lastCommandSucceeded": "0" - ] - - switch action { - case "hover_token": - guard let hitPoint = commandPoint( - from: command, - defaultPayloadKey: "tokenHitPointInTerminal", - in: terminalPanel - ) else { - payload["lastCommandError"] = "Missing command point" - break - } - - let result = terminalPanel.hostedView.debugSimulateCommandHoverDetails(at: hitPoint) - payload["lastCommandResult"] = result - payload["lastCommandHoverActive"] = result["hoverActive"] - if let resolvedPath = result["resolvedPath"] as? String { - payload["lastCommandResolvedPath"] = resolvedPath - payload["lastCommandSucceeded"] = "1" - } else if let error = result["error"] as? String { - payload["lastCommandError"] = error - } else { - payload["lastCommandError"] = "Command hover did not resolve a path" - } - - case "cmd_click_token": - guard let hitPoint = commandPoint( - from: command, - defaultPayloadKey: "tokenHitPointInTerminal", - in: terminalPanel - ) else { - payload["lastCommandError"] = "Missing command point" - break - } - - let result = terminalPanel.hostedView.debugSimulateCommandClick(at: hitPoint) - payload["lastCommandResult"] = result - if let openedPath = result["openedPath"] as? String { - payload["lastCommandOpenedPath"] = openedPath - let canonicalOpenedPath = (openedPath as NSString).resolvingSymlinksInPath - let openedInFilePreview = workspace.panels.values.contains { panel in - guard let filePreview = panel as? FilePreviewPanel else { return false } - return (filePreview.filePath as NSString).resolvingSymlinksInPath == canonicalOpenedPath - } - let openedInMarkdownViewer = workspace.panels.values.contains { panel in - guard let markdown = panel as? MarkdownPanel else { return false } - return (markdown.filePath as NSString).resolvingSymlinksInPath == canonicalOpenedPath - } - payload["lastCommandOpenedInFilePreview"] = openedInFilePreview ? "1" : "0" - payload["lastCommandOpenedInMarkdownViewer"] = openedInMarkdownViewer ? "1" : "0" - payload["lastCommandSucceeded"] = "1" - } else if let error = result["error"] as? String { - payload["lastCommandError"] = error - } else { - payload["lastCommandError"] = "Command click did not open a path" - } - - case "stationary_cmd_click_token": - guard let hitPoint = commandPoint( - from: command, - defaultPayloadKey: "tokenHitPointInTerminal", - in: terminalPanel - ) else { - payload["lastCommandError"] = "Missing command point" - break - } - - let capturePath = ProcessInfo.processInfo.environment["CMUX_UI_TEST_CAPTURE_OPEN_URL_PATH"] - let beforeURLCount = capturePath.flatMap { try? String(contentsOfFile: $0, encoding: .utf8) }? - .split(separator: "\n").count ?? 0 - let result = terminalPanel.hostedView.debugSimulateStationaryCommandClick(at: hitPoint) - payload["lastCommandResult"] = result - let openedURLs = capturePath.flatMap { try? String(contentsOfFile: $0, encoding: .utf8) }? - .split(separator: "\n").map(String.init) ?? [] - if openedURLs.count > beforeURLCount, let openedURL = openedURLs.last { - payload["lastCommandOpenedURL"] = openedURL - payload["lastCommandSucceeded"] = "1" - } else if let error = result["error"] as? String { - payload["lastCommandError"] = error - } else { - payload["lastCommandError"] = "Stationary command click did not open a URL" - } - - case "select_token_and_hold_command": - guard let selectionStart = pointFromPayload("tokenSelectionStartInTerminal", in: terminalPanel), - let selectionEnd = pointFromPayload("tokenSelectionEndInTerminal", in: terminalPanel) else { - payload["lastCommandError"] = "Missing token selection points" - break - } - - let selectionActive = terminalPanel.hostedView.debugSimulateSelection( - from: selectionStart, - to: selectionEnd - ) - let hoverSuppressed = terminalPanel.hostedView.debugSimulateCommandHover(at: selectionEnd) - payload["lastCommandSelectionActive"] = selectionActive ? "1" : "0" - payload["lastCommandHoverSuppressed"] = hoverSuppressed ? "1" : "0" - if selectionActive && hoverSuppressed { - payload["lastCommandSucceeded"] = "1" - } else { - payload["lastCommandError"] = "Selection or hover suppression failed" - } - - case "capture_window": - let label = (command["label"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - if let path = captureWindowSnapshotIfRequested( - label: label?.isEmpty == false ? label! : "capture", - window: window - ) { - payload["lastCommandScreenshotPath"] = path - payload["lastCommandSucceeded"] = "1" - } else { - payload["lastCommandError"] = "Window screenshot capture unavailable" - } - - default: - payload["lastCommandError"] = "Unknown command action: \(action)" - } - - writeState( - terminalPanel: terminalPanel, - window: window, - ready: true, - additionalPayload: payload - ) - lastHandledCommandID = commandID - } - - @MainActor - func evaluate() { - guard !resolved else { return } - let currentTabManager = self.tabManager - let workspace = currentTabManager?.selectedWorkspace ?? currentTabManager?.tabs.first - let terminalPanel = cmdClickUITestTerminalPanel(in: workspace) - let mainWindow = terminalPanel?.surface.uiWindow - ?? currentTabManager.flatMap { self.windowId(for: $0).flatMap { self.mainWindow(for: $0) } } - if Date() >= deadline { - let textSnapshot = terminalPanel - .flatMap { TerminalController.shared.readTerminalTextForSnapshot(terminalPanel: $0, lineLimit: 200) } ?? "" - var timeoutPayload: [String: Any] = [:] - if let currentTabManager { - timeoutPayload["tabManager"] = debugManagerToken(currentTabManager) - timeoutPayload["workspaceCount"] = currentTabManager.tabs.count - } - let waitingFor = [ - workspace == nil ? "workspace" : nil, - terminalPanel == nil ? "terminalPanel" : nil, - mainWindow == nil ? "mainWindow" : nil - ] - .compactMap { $0 } - .joined(separator: ",") - if !waitingFor.isEmpty { - timeoutPayload["waitingFor"] = waitingFor - } - writeState( - terminalPanel: terminalPanel, - window: mainWindow, - ready: false, - setupError: "Timed out waiting for terminal cmd-click setup. text=\(textSnapshot)", - additionalPayload: timeoutPayload - ) - resolved = true - cleanup() - return - } - - if currentTabManager == nil { - writeTerminalCmdClickUITestData(at: manifestPath, updates: [ - "ready": "0", - "setupError": "Waiting for tab manager" - ]) - return - } - - guard let workspace, - let terminalPanel, - let mainWindow else { - var waitingPayload: [String: Any] = [ - "ready": "0", - "setupError": "Waiting for terminal workspace" - ] - if let currentTabManager { - waitingPayload["tabManager"] = debugManagerToken(currentTabManager) - waitingPayload["workspaceCount"] = currentTabManager.tabs.count - } - let waitingFor = [ - workspace == nil ? "workspace" : nil, - terminalPanel == nil ? "terminalPanel" : nil, - mainWindow == nil ? "mainWindow" : nil - ] - .compactMap { $0 } - .joined(separator: ",") - if !waitingFor.isEmpty { - waitingPayload["waitingFor"] = waitingFor - } - writeTerminalCmdClickUITestData(at: manifestPath, updates: waitingPayload) - return - } - - resizeWindowIfNeeded(mainWindow) - mainWindow.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - terminalPanel.focus() - - do { - try FileManager.default.createDirectory( - at: fixtureDirectoryURL, - withIntermediateDirectories: true - ) - try FileManager.default.createDirectory( - at: expectedFileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - if !FileManager.default.fileExists(atPath: expectedFileURL.path) { - try "fixture\n".write(to: expectedFileURL, atomically: true, encoding: .utf8) - } - if !FileManager.default.fileExists(atPath: siblingFileURL.path) { - try "fixture\n".write(to: siblingFileURL, atomically: true, encoding: .utf8) - } - for extraFileName in extraFileNames where !extraFileName.isEmpty { - let extraFileURL = fixtureDirectoryURL.appendingPathComponent(extraFileName) - try FileManager.default.createDirectory( - at: extraFileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - if !FileManager.default.fileExists(atPath: extraFileURL.path) { - try "fixture\n".write(to: extraFileURL, atomically: true, encoding: .utf8) - } - } - } catch { - writeState( - terminalPanel: terminalPanel, - window: mainWindow, - ready: false, - setupError: "Failed to create fixture: \(error.localizedDescription)" - ) - resolved = true - cleanup() - return - } - - workspace.updatePanelDirectory(panelId: terminalPanel.id, directory: fixtureDirectoryURL.path) - - let terminalFrame = terminalPanel.hostedView.debugPortalFrameInWindow - let terminalReady = terminalPanel.surface.surface != nil - let terminalVisible = terminalPanel.surface.isViewInWindow && - terminalPanel.hostedView.debugPortalVisibleInUI && - !terminalFrame.isEmpty && - terminalFrame.width > 0 && - terminalFrame.height > 0 - - if terminalReady && terminalVisible && !seeded { - seeded = true - sendTextWhenReady(shellCommand, to: workspace, beforeSend: { - workspace.updatePanelDirectory(panelId: terminalPanel.id, directory: fixtureDirectoryURL.path) - }) - } - - let visibleText = TerminalController.shared.readTerminalTextForSnapshot( - terminalPanel: terminalPanel, - lineLimit: 200 - ) ?? "" - let renderedTokenCount = max(0, visibleText.components(separatedBy: displayToken).count - 1) - let hasRenderedToken = renderedTokenCount >= 6 - if hasRenderedToken, - (tokenPointPayload?["tokenLayoutMatch"] as? String) != "1" { - tokenPointPayload = tokenPoints(in: terminalPanel, visibleText: visibleText) - } - let tokenLayoutReady = (tokenPointPayload?["tokenLayoutMatch"] as? String) == "1" - - writeState( - terminalPanel: terminalPanel, - window: mainWindow, - ready: terminalReady && terminalVisible && hasRenderedToken && tokenLayoutReady, - additionalPayload: [ - "seeded": seeded ? "1" : "0", - "hasRenderedToken": hasRenderedToken ? "1" : "0", - "renderedTokenCount": renderedTokenCount, - "visibleTextTail": String(visibleText.suffix(1200)) - ] - ) - - guard terminalReady, terminalVisible, hasRenderedToken, tokenLayoutReady else { return } - if commandPath?.isEmpty == false { - executePendingCommandIfNeeded( - workspace: workspace, - terminalPanel: terminalPanel, - window: mainWindow - ) - return - } - resolved = true - cleanup() - } - - observers.append(NotificationCenter.default.addObserver( - forName: NSWindow.didUpdateNotification, - object: nil, - queue: .main - ) { _ in - Task { @MainActor in evaluate() } - }) - observers.append(NotificationCenter.default.addObserver( - forName: .terminalSurfaceHostedViewDidMoveToWindow, - object: nil, - queue: .main - ) { _ in - Task { @MainActor in evaluate() } - }) - observers.append(NotificationCenter.default.addObserver( - forName: .terminalSurfaceDidBecomeReady, - object: nil, - queue: .main - ) { _ in - Task { @MainActor in evaluate() } - }) - observers.append(NotificationCenter.default.addObserver( - forName: .ghosttyDidFocusSurface, - object: nil, - queue: .main - ) { _ in - Task { @MainActor in evaluate() } - }) - let poller = DispatchSource.makeTimerSource(queue: .main) - poller.schedule(deadline: .now(), repeating: .milliseconds(100)) - poller.setEventHandler { - Task { @MainActor in evaluate() } - } - terminalCmdClickUITestPoller = poller - cmuxDebugLog("cmdclick.ui.setup poller_started manifest=\(manifestPath)") - poller.resume() - } - - private func writeTerminalCmdClickUITestData(at path: String, updates: [String: Any]) { - let url = URL(fileURLWithPath: path) - var payload: [String: Any] = [:] - if let data = try? Data(contentsOf: url), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - payload = object - } - for (key, value) in updates { - payload[key] = value - } - guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else { - cmuxDebugLog("cmdclick.ui.write skip reason=json path=\(path)") - return - } - do { - try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try data.write(to: url, options: .atomic) - } catch { - cmuxDebugLog("cmdclick.ui.write error path=\(path) error=\(error.localizedDescription)") - } - } +#if DEBUG private func scheduleUITestSocketSanityCheckIfNeeded() { let env = ProcessInfo.processInfo.environment guard env["CMUX_UI_TEST_SOCKET_SANITY"] == "1" else { return } diff --git a/Sources/Debug/UITests/TerminalCmdClickUITestConfiguration.swift b/Sources/Debug/UITests/TerminalCmdClickUITestConfiguration.swift new file mode 100644 index 000000000000..4e621f478bbb --- /dev/null +++ b/Sources/Debug/UITests/TerminalCmdClickUITestConfiguration.swift @@ -0,0 +1,18 @@ +#if DEBUG +import Foundation + +/// Captures the environment used to configure the terminal Cmd-click fixture. +struct TerminalCmdClickUITestConfiguration { + let environment: [String: String] + + init(environment: [String: String]) { + self.environment = environment + } + + var isEnabled: Bool { environment["CMUX_UI_TEST_TERMINAL_CMD_CLICK_SETUP"] == "1" } + + func value(for key: String) -> String? { + environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines) + } +} +#endif diff --git a/Sources/Debug/UITests/TerminalCmdClickUITestCoordinator.swift b/Sources/Debug/UITests/TerminalCmdClickUITestCoordinator.swift new file mode 100644 index 000000000000..642e2b3eb638 --- /dev/null +++ b/Sources/Debug/UITests/TerminalCmdClickUITestCoordinator.swift @@ -0,0 +1,810 @@ +#if DEBUG +import AppKit +import Foundation + +/// Coordinates the terminal Cmd-click UI-test fixture and its command protocol. +@MainActor +final class TerminalCmdClickUITestCoordinator { + struct Dependencies { + let environment: [String: String] + let notificationCenter: NotificationCenter + let tabManager: () -> TabManager? + let window: (TabManager) -> NSWindow? + let managerToken: (TabManager?) -> String + let sendText: (String, Workspace, (() -> Void)?) -> Void + } + + private let dependencies: Dependencies + private let manifestWriter: TerminalCmdClickUITestManifestWriter + private var didStart = false + private var pollerTimer: DispatchSourceTimer? + + init( + dependencies: Dependencies, + manifestWriter: TerminalCmdClickUITestManifestWriter = TerminalCmdClickUITestManifestWriter() + ) { + self.dependencies = dependencies + self.manifestWriter = manifestWriter + } + + deinit { + pollerTimer?.cancel() + } + + func startIfNeeded() { + guard !didStart else { return } + + let configuration = TerminalCmdClickUITestConfiguration(environment: dependencies.environment) + let env = configuration.environment + guard configuration.isEnabled else { + cmuxDebugLog("cmdclick.ui.setup skip reason=env_missing tag=\(env["CMUX_TAG"] ?? "nil")") + return + } + guard let manifestPath = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_PATH"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !manifestPath.isEmpty else { + cmuxDebugLog("cmdclick.ui.setup skip reason=missing_manifest_path") + return + } + didStart = true + guard let fixtureDirectory = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_FIXTURE_DIR"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !fixtureDirectory.isEmpty else { + cmuxDebugLog("cmdclick.ui.setup error reason=missing_fixture_dir manifest=\(manifestPath)") + manifestWriter.write(updates: [ + "setupError": "Missing fixture directory" + ], at: manifestPath) + return + } + let commandPath = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_COMMAND_PATH"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let screenshotDirectory = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_SCREENSHOT_DIR"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let displayMode = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_DISPLAY_MODE"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let lineFormat = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_LINE_FORMAT"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let linePrefix = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_LINE_PREFIX"] ?? "" + let displaySuffix = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_DISPLAY_SUFFIX"] ?? "" + let displayAsAbsolutePath = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_DISPLAY_AS_ABSOLUTE_PATH"] == "1" + if let rawOpenSupportedFiles = env["CMUX_UI_TEST_OPEN_SUPPORTED_FILES_IN_CMUX"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !rawOpenSupportedFiles.isEmpty { + FileRouteSettingsStore(defaults: .standard).setSupportedFileRouteEnabled(rawOpenSupportedFiles == "1") + } + if let rawOpenMarkdown = env["CMUX_UI_TEST_OPEN_MARKDOWN_IN_CMUX_VIEWER"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !rawOpenMarkdown.isEmpty { + FileRouteSettingsStore(defaults: .standard).setMarkdownRouteEnabled(rawOpenMarkdown == "1") + } + let extraFileNamesJSON = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_EXTRA_FILE_NAMES_JSON"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + + let fileName = env["CMUX_UI_TEST_TERMINAL_CMD_CLICK_FILE_NAME"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedFileName = (fileName?.isEmpty == false) ? fileName! : "Cmd Click Fixture.txt" + let fixtureDirectoryURL = URL(fileURLWithPath: fixtureDirectory, isDirectory: true) + let expectedFileURL = fixtureDirectoryURL.appendingPathComponent(resolvedFileName) + let siblingFileURL = fixtureDirectoryURL.appendingPathComponent("OtherFile") + let extraFileNames: [String] + if let extraFileNamesJSON, + let data = extraFileNamesJSON.data(using: .utf8), + let values = try? JSONSerialization.jsonObject(with: data) as? [String] { + extraFileNames = values + } else { + extraFileNames = [] + } + let escapedToken = resolvedFileName.replacingOccurrences(of: " ", with: "\\ ") + let baseDisplayToken = displayAsAbsolutePath ? expectedFileURL.path : resolvedFileName + let resolvedDisplayMode = (displayMode == "raw") ? "raw" : "escaped" + let resolvedLineFormat: String + switch lineFormat { + case "log": + resolvedLineFormat = "log" + case "alt_screen_log": + resolvedLineFormat = "alt_screen_log" + case "osc8": + resolvedLineFormat = "osc8" + default: + resolvedLineFormat = "grid" + } + cmuxDebugLog( + "cmdclick.ui.setup start manifest=\(manifestPath) fixture=\(fixtureDirectory) " + + "command=\(commandPath ?? "nil") display=\(resolvedDisplayMode) " + + "lineFormat=\(resolvedLineFormat) " + + "file=\(resolvedFileName)" + ) + func singleQuotedShellLiteral(_ text: String) -> String { + text.replacingOccurrences(of: "'", with: "'\"'\"'") + } + let displayToken: String + let shellCommand: String + switch resolvedLineFormat { + case "osc8": + displayToken = resolvedFileName + let escapedDisplayToken = singleQuotedShellLiteral(displayToken) + let escapedURL = singleQuotedShellLiteral(expectedFileURL.absoluteString) + shellCommand = "clear\rfor i in $(seq 1 48); do printf '\\033]8;;%s\\033\\\\%s\\033]8;;\\033\\\\\\n' '\(escapedURL)' '\(escapedDisplayToken)'; done\r" + case "log": + displayToken = "\(baseDisplayToken)\(displaySuffix)" + let blockLine = "\(linePrefix)\(displayToken)" + let shellBlockLine = singleQuotedShellLiteral(blockLine) + shellCommand = "clear\rfor i in $(seq 1 48); do printf '%s\\n' '\(shellBlockLine)'; done\r" + case "alt_screen_log": + displayToken = "\(baseDisplayToken)\(displaySuffix)" + let blockLine = "\(linePrefix)\(displayToken)" + let shellBlockLine = singleQuotedShellLiteral(blockLine) + shellCommand = "clear\rprintf '\\033[?1049h\\033[H\\033[2J'; for i in $(seq 1 48); do printf '%s\\n' '\(shellBlockLine)'; done\r" + default: + switch resolvedDisplayMode { + case "raw": + displayToken = "\(baseDisplayToken)\(displaySuffix)" + let blockLine = "\(displayToken) OtherFile" + let shellBlockLine = singleQuotedShellLiteral(blockLine) + shellCommand = "clear\rfor i in $(seq 1 48); do printf '%s\\n' '\(shellBlockLine)'; done\r" + default: + displayToken = "\(escapedToken)\(displaySuffix)" + let blockLine = Array(repeating: displayToken, count: 3).joined(separator: " ") + let shellBlockLine = singleQuotedShellLiteral(blockLine) + shellCommand = "clear\rfor i in $(seq 1 48); do printf '%s\\n' '\(shellBlockLine)'; done\r" + } + } + let deadline = Date().addingTimeInterval((commandPath?.isEmpty == false) ? 60.0 : 20.0) + var seeded = false + var resolved = false + var tokenPointPayload: [String: Any]? + var observers: [NSObjectProtocol] = [] + var lastHandledCommandID: String? + var screenshotSequence = 0 + + func rectPayload(_ rect: CGRect) -> [String: Double] { + [ + "x": rect.origin.x, + "y": rect.origin.y, + "width": rect.size.width, + "height": rect.size.height + ] + } + + func pointPayload(x: CGFloat, yFromTop: CGFloat) -> [String: Double] { + [ + "x": x, + "y": yFromTop + ] + } + + func doubleValue(_ value: Any?) -> Double? { + if let value = value as? Double { + return value + } + if let value = value as? NSNumber { + return value.doubleValue + } + return nil + } + + func pointFromPayload(_ key: String, in terminalPanel: TerminalPanel) -> NSPoint? { + guard let payload = tokenPointPayload?[key] as? [String: Any], + let x = doubleValue(payload["x"]), + let yFromTop = doubleValue(payload["y"]) else { + return nil + } + + let clampedX = min(max(CGFloat(x), 1), max(terminalPanel.hostedView.bounds.width - 1, 1)) + let clampedYFromTop = min( + max(CGFloat(yFromTop), 1), + max(terminalPanel.hostedView.bounds.height - 1, 1) + ) + return NSPoint( + x: clampedX, + y: terminalPanel.hostedView.bounds.height - clampedYFromTop + ) + } + + func pointForTokenColumnOffset(_ offset: Int, in terminalPanel: TerminalPanel) -> NSPoint? { + guard let selectionStart = pointFromPayload("tokenSelectionStartInTerminal", in: terminalPanel), + let tokenCellMetrics = tokenPointPayload?["tokenCellMetrics"] as? [String: Any], + let cellWidth = doubleValue(tokenCellMetrics["cellWidth"]) else { + return nil + } + + let unclampedX = selectionStart.x + (CGFloat(offset) * CGFloat(cellWidth)) + let clampedX = min(max(unclampedX, 1), max(terminalPanel.hostedView.bounds.width - 1, 1)) + return NSPoint(x: clampedX, y: selectionStart.y) + } + + func commandPoint( + from command: [String: Any], + defaultPayloadKey: String, + in terminalPanel: TerminalPanel + ) -> NSPoint? { + if let tokenColumnOffset = command["tokenColumnOffset"] as? Int { + return pointForTokenColumnOffset(tokenColumnOffset, in: terminalPanel) + } + if let tokenColumnOffset = command["tokenColumnOffset"] as? NSNumber { + return pointForTokenColumnOffset(tokenColumnOffset.intValue, in: terminalPanel) + } + return pointFromPayload(defaultPayloadKey, in: terminalPanel) + } + + func loadCommand(at path: String) -> [String: Any]? { + let url = URL(fileURLWithPath: path) + guard let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return object + } + + func tokenPoints(in terminalPanel: TerminalPanel, visibleText: String) -> [String: Any]? { + guard let surface = terminalPanel.surface.surface else { return nil } + let bounds = terminalPanel.hostedView.bounds + guard bounds.width > 0, bounds.height > 0 else { return nil } + + let size = ghostty_surface_size(surface) + let rows = max(Int(size.rows), 1) + let cols = max(Int(size.columns), 1) + let debugCellSize = terminalPanel.hostedView.debugCellSize + let cellWidth = debugCellSize.width > 0 ? debugCellSize.width : CGFloat(size.cell_width_px) + let cellHeight = debugCellSize.height > 0 ? debugCellSize.height : CGFloat(size.cell_height_px) + guard cellWidth > 0, cellHeight > 0 else { return nil } + + let xInset = max(0, (bounds.width - (CGFloat(cols) * cellWidth)) / 2) + let yInset = max(0, (bounds.height - (CGFloat(rows) * cellHeight)) / 2) + let pointClampX: (CGFloat) -> CGFloat = { x in + min(bounds.width - 4, max(4, x)) + } + let pointClampY: (CGFloat) -> CGFloat = { y in + min(bounds.height - 4, max(4, y)) + } + + let rawVisibleLines = visibleText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + let visibleLines = rawVisibleLines.count > rows ? Array(rawVisibleLines.suffix(rows)) : rawVisibleLines + let rowOffset = max(0, rows - visibleLines.count) + + var matchedRowFromTop: Int? + var matchedColumnStart: Int? + var matchedColumnEnd: Int? + var matchedLine = "" + var matchingLines: [(lineIndex: Int, line: String, ranges: [Range])] = [] + + for (lineIndex, line) in visibleLines.enumerated() { + var searchStart = line.startIndex + var ranges: [Range] = [] + while searchStart < line.endIndex, + let range = line.range(of: displayToken, range: searchStart.. String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_.")) + let scalars = label.unicodeScalars.map { allowed.contains($0) ? Character($0) : "-" } + let cleaned = String(scalars).trimmingCharacters(in: CharacterSet(charactersIn: "-_.")) + return cleaned.isEmpty ? "capture" : cleaned + } + + @MainActor + func captureWindowSnapshotIfRequested(label: String, window: NSWindow) -> String? { + guard let screenshotDirectory, + !screenshotDirectory.isEmpty, + let contentView = window.contentView else { + return nil + } + let bounds = contentView.bounds + guard !bounds.isEmpty, + let bitmap = contentView.bitmapImageRepForCachingDisplay(in: bounds) else { + return nil + } + contentView.cacheDisplay(in: bounds, to: bitmap) + guard let data = bitmap.representation(using: .png, properties: [:]) else { + return nil + } + do { + let directoryURL = URL(fileURLWithPath: screenshotDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) + let sequence = String(format: "%03d", screenshotSequence) + screenshotSequence += 1 + let fileURL = directoryURL + .appendingPathComponent("\(sequence)-\(safeScreenshotLabel(label)).png") + try data.write(to: fileURL, options: .atomic) + return fileURL.path + } catch { + cmuxDebugLog("cmdclick.ui.snapshot failed label=\(label) error=\(error.localizedDescription)") + return nil + } + } + + func cmdClickUITestTerminalPanel(in workspace: Workspace?) -> TerminalPanel? { + guard let workspace else { return nil } + if let inputPanel = workspace.focusedTerminalInputTarget()?.panel { + return inputPanel + } + return workspace.panels.values + .compactMap { $0 as? TerminalPanel } + .first { panel in + panel.surface.isViewInWindow && + panel.hostedView.debugPortalVisibleInUI && + !panel.hostedView.debugPortalFrameInWindow.isEmpty + } + } + + @MainActor + func executePendingCommandIfNeeded( + workspace: Workspace, + terminalPanel: TerminalPanel, + window: NSWindow + ) { + guard let commandPath, + !commandPath.isEmpty, + let command = loadCommand(at: commandPath), + let commandID = command["id"] as? String, + commandID != lastHandledCommandID else { + return + } + + let action = (command["action"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + var payload: [String: Any] = [ + "lastCommandId": commandID, + "lastCommandAction": action, + "lastCommandSucceeded": "0" + ] + + switch action { + case "hover_token": + guard let hitPoint = commandPoint( + from: command, + defaultPayloadKey: "tokenHitPointInTerminal", + in: terminalPanel + ) else { + payload["lastCommandError"] = "Missing command point" + break + } + + let result = terminalPanel.hostedView.debugSimulateCommandHoverDetails(at: hitPoint) + payload["lastCommandResult"] = result + payload["lastCommandHoverActive"] = result["hoverActive"] + if let resolvedPath = result["resolvedPath"] as? String { + payload["lastCommandResolvedPath"] = resolvedPath + payload["lastCommandSucceeded"] = "1" + } else if let error = result["error"] as? String { + payload["lastCommandError"] = error + } else { + payload["lastCommandError"] = "Command hover did not resolve a path" + } + + case "cmd_click_token": + guard let hitPoint = commandPoint( + from: command, + defaultPayloadKey: "tokenHitPointInTerminal", + in: terminalPanel + ) else { + payload["lastCommandError"] = "Missing command point" + break + } + + let result = terminalPanel.hostedView.debugSimulateCommandClick(at: hitPoint) + payload["lastCommandResult"] = result + if let openedPath = result["openedPath"] as? String { + payload["lastCommandOpenedPath"] = openedPath + let canonicalOpenedPath = (openedPath as NSString).resolvingSymlinksInPath + let openedInFilePreview = workspace.panels.values.contains { panel in + guard let filePreview = panel as? FilePreviewPanel else { return false } + return (filePreview.filePath as NSString).resolvingSymlinksInPath == canonicalOpenedPath + } + let openedInMarkdownViewer = workspace.panels.values.contains { panel in + guard let markdown = panel as? MarkdownPanel else { return false } + return (markdown.filePath as NSString).resolvingSymlinksInPath == canonicalOpenedPath + } + payload["lastCommandOpenedInFilePreview"] = openedInFilePreview ? "1" : "0" + payload["lastCommandOpenedInMarkdownViewer"] = openedInMarkdownViewer ? "1" : "0" + payload["lastCommandSucceeded"] = "1" + } else if let error = result["error"] as? String { + payload["lastCommandError"] = error + } else { + payload["lastCommandError"] = "Command click did not open a path" + } + + case "stationary_cmd_click_token": + guard let hitPoint = commandPoint( + from: command, + defaultPayloadKey: "tokenHitPointInTerminal", + in: terminalPanel + ) else { + payload["lastCommandError"] = "Missing command point" + break + } + + let capturePath = dependencies.environment["CMUX_UI_TEST_CAPTURE_OPEN_URL_PATH"] + let beforeURLCount = capturePath.flatMap { try? String(contentsOfFile: $0, encoding: .utf8) }? + .split(separator: "\n").count ?? 0 + let result = terminalPanel.hostedView.debugSimulateStationaryCommandClick(at: hitPoint) + payload["lastCommandResult"] = result + let openedURLs = capturePath.flatMap { try? String(contentsOfFile: $0, encoding: .utf8) }? + .split(separator: "\n").map(String.init) ?? [] + if openedURLs.count > beforeURLCount, let openedURL = openedURLs.last { + payload["lastCommandOpenedURL"] = openedURL + payload["lastCommandSucceeded"] = "1" + } else if let error = result["error"] as? String { + payload["lastCommandError"] = error + } else { + payload["lastCommandError"] = "Stationary command click did not open a URL" + } + + case "select_token_and_hold_command": + guard let selectionStart = pointFromPayload("tokenSelectionStartInTerminal", in: terminalPanel), + let selectionEnd = pointFromPayload("tokenSelectionEndInTerminal", in: terminalPanel) else { + payload["lastCommandError"] = "Missing token selection points" + break + } + + let selectionActive = terminalPanel.hostedView.debugSimulateSelection( + from: selectionStart, + to: selectionEnd + ) + let hoverSuppressed = terminalPanel.hostedView.debugSimulateCommandHover(at: selectionEnd) + payload["lastCommandSelectionActive"] = selectionActive ? "1" : "0" + payload["lastCommandHoverSuppressed"] = hoverSuppressed ? "1" : "0" + if selectionActive && hoverSuppressed { + payload["lastCommandSucceeded"] = "1" + } else { + payload["lastCommandError"] = "Selection or hover suppression failed" + } + + case "capture_window": + let label = (command["label"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let path = captureWindowSnapshotIfRequested( + label: label?.isEmpty == false ? label! : "capture", + window: window + ) { + payload["lastCommandScreenshotPath"] = path + payload["lastCommandSucceeded"] = "1" + } else { + payload["lastCommandError"] = "Window screenshot capture unavailable" + } + + default: + payload["lastCommandError"] = "Unknown command action: \(action)" + } + + writeState( + terminalPanel: terminalPanel, + window: window, + ready: true, + additionalPayload: payload + ) + lastHandledCommandID = commandID + } + + @MainActor + func evaluate() { + guard !resolved else { return } + let currentTabManager = dependencies.tabManager() + let workspace = currentTabManager?.selectedWorkspace ?? currentTabManager?.tabs.first + let terminalPanel = cmdClickUITestTerminalPanel(in: workspace) + let mainWindow = terminalPanel?.surface.uiWindow + ?? currentTabManager.flatMap { dependencies.window($0) } + if Date() >= deadline { + let textSnapshot = terminalPanel + .flatMap { TerminalController.shared.readTerminalTextForSnapshot(terminalPanel: $0, lineLimit: 200) } ?? "" + var timeoutPayload: [String: Any] = [:] + if let currentTabManager { + timeoutPayload["tabManager"] = dependencies.managerToken(currentTabManager) + timeoutPayload["workspaceCount"] = currentTabManager.tabs.count + } + let waitingFor = [ + workspace == nil ? "workspace" : nil, + terminalPanel == nil ? "terminalPanel" : nil, + mainWindow == nil ? "mainWindow" : nil + ] + .compactMap { $0 } + .joined(separator: ",") + if !waitingFor.isEmpty { + timeoutPayload["waitingFor"] = waitingFor + } + writeState( + terminalPanel: terminalPanel, + window: mainWindow, + ready: false, + setupError: "Timed out waiting for terminal cmd-click setup. text=\(textSnapshot)", + additionalPayload: timeoutPayload + ) + resolved = true + cleanup() + return + } + + if currentTabManager == nil { + manifestWriter.write(updates: [ + "ready": "0", + "setupError": "Waiting for tab manager" + ], at: manifestPath) + return + } + + guard let workspace, + let terminalPanel, + let mainWindow else { + var waitingPayload: [String: Any] = [ + "ready": "0", + "setupError": "Waiting for terminal workspace" + ] + if let currentTabManager { + waitingPayload["tabManager"] = dependencies.managerToken(currentTabManager) + waitingPayload["workspaceCount"] = currentTabManager.tabs.count + } + let waitingFor = [ + workspace == nil ? "workspace" : nil, + terminalPanel == nil ? "terminalPanel" : nil, + mainWindow == nil ? "mainWindow" : nil + ] + .compactMap { $0 } + .joined(separator: ",") + if !waitingFor.isEmpty { + waitingPayload["waitingFor"] = waitingFor + } + manifestWriter.write(updates: waitingPayload, at: manifestPath) + return + } + + resizeWindowIfNeeded(mainWindow) + mainWindow.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + terminalPanel.focus() + + do { + try FileManager.default.createDirectory( + at: fixtureDirectoryURL, + withIntermediateDirectories: true + ) + try FileManager.default.createDirectory( + at: expectedFileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if !FileManager.default.fileExists(atPath: expectedFileURL.path) { + try "fixture\n".write(to: expectedFileURL, atomically: true, encoding: .utf8) + } + if !FileManager.default.fileExists(atPath: siblingFileURL.path) { + try "fixture\n".write(to: siblingFileURL, atomically: true, encoding: .utf8) + } + for extraFileName in extraFileNames where !extraFileName.isEmpty { + let extraFileURL = fixtureDirectoryURL.appendingPathComponent(extraFileName) + try FileManager.default.createDirectory( + at: extraFileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if !FileManager.default.fileExists(atPath: extraFileURL.path) { + try "fixture\n".write(to: extraFileURL, atomically: true, encoding: .utf8) + } + } + } catch { + writeState( + terminalPanel: terminalPanel, + window: mainWindow, + ready: false, + setupError: "Failed to create fixture: \(error.localizedDescription)" + ) + resolved = true + cleanup() + return + } + + workspace.updatePanelDirectory(panelId: terminalPanel.id, directory: fixtureDirectoryURL.path) + + let terminalFrame = terminalPanel.hostedView.debugPortalFrameInWindow + let terminalReady = terminalPanel.surface.surface != nil + let terminalVisible = terminalPanel.surface.isViewInWindow && + terminalPanel.hostedView.debugPortalVisibleInUI && + !terminalFrame.isEmpty && + terminalFrame.width > 0 && + terminalFrame.height > 0 + + if terminalReady && terminalVisible && !seeded { + seeded = true + dependencies.sendText(shellCommand, workspace, { + workspace.updatePanelDirectory(panelId: terminalPanel.id, directory: fixtureDirectoryURL.path) + }) + } + + let visibleText = TerminalController.shared.readTerminalTextForSnapshot( + terminalPanel: terminalPanel, + lineLimit: 200 + ) ?? "" + let renderedTokenCount = max(0, visibleText.components(separatedBy: displayToken).count - 1) + let hasRenderedToken = renderedTokenCount >= 6 + if hasRenderedToken, + (tokenPointPayload?["tokenLayoutMatch"] as? String) != "1" { + tokenPointPayload = tokenPoints(in: terminalPanel, visibleText: visibleText) + } + let tokenLayoutReady = (tokenPointPayload?["tokenLayoutMatch"] as? String) == "1" + + writeState( + terminalPanel: terminalPanel, + window: mainWindow, + ready: terminalReady && terminalVisible && hasRenderedToken && tokenLayoutReady, + additionalPayload: [ + "seeded": seeded ? "1" : "0", + "hasRenderedToken": hasRenderedToken ? "1" : "0", + "renderedTokenCount": renderedTokenCount, + "visibleTextTail": String(visibleText.suffix(1200)) + ] + ) + + guard terminalReady, terminalVisible, hasRenderedToken, tokenLayoutReady else { return } + if commandPath?.isEmpty == false { + executePendingCommandIfNeeded( + workspace: workspace, + terminalPanel: terminalPanel, + window: mainWindow + ) + return + } + resolved = true + cleanup() + } + + observers.append(dependencies.notificationCenter.addObserver( + forName: NSWindow.didUpdateNotification, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in evaluate() } + }) + observers.append(dependencies.notificationCenter.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in evaluate() } + }) + observers.append(dependencies.notificationCenter.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in evaluate() } + }) + observers.append(dependencies.notificationCenter.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in evaluate() } + }) + let poller = DispatchSource.makeTimerSource(queue: .main) + poller.schedule(deadline: .now(), repeating: .milliseconds(100)) + poller.setEventHandler { + Task { @MainActor in evaluate() } + } + pollerTimer = poller + cmuxDebugLog("cmdclick.ui.setup poller_started manifest=\(manifestPath)") + poller.resume() + } +} +#endif diff --git a/Sources/Debug/UITests/TerminalCmdClickUITestManifestWriter.swift b/Sources/Debug/UITests/TerminalCmdClickUITestManifestWriter.swift new file mode 100644 index 000000000000..5ab16220be68 --- /dev/null +++ b/Sources/Debug/UITests/TerminalCmdClickUITestManifestWriter.swift @@ -0,0 +1,31 @@ +#if DEBUG +import Foundation + +/// Merges Cmd-click fixture state into the JSON manifest consumed by UI tests. +final class TerminalCmdClickUITestManifestWriter { + func write(updates: [String: Any], at path: String) { + let url = URL(fileURLWithPath: path) + var payload: [String: Any] = [:] + if let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + payload = object + } + for (key, value) in updates { + payload[key] = value + } + guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) else { + cmuxDebugLog("cmdclick.ui.write skip reason=json path=\(path)") + return + } + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: url, options: .atomic) + } catch { + cmuxDebugLog("cmdclick.ui.write error path=\(path) error=\(error.localizedDescription)") + } + } +} +#endif diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c0e450261217..43147b287afd 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2253,6 +2253,9 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources B8835A000000000000000003 /* TerminalClipboardInputSequencerTests+Replay.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8835A000000000000000004 /* TerminalClipboardInputSequencerTests+Replay.swift */; }; B8835700000000000000000D /* TerminalClipboardInputSequencerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8835700000000000000000E /* TerminalClipboardInputSequencerTests.swift */; }; B88357000000000000000013 /* TerminalClipboardRequestSurfaceIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88357000000000000000014 /* TerminalClipboardRequestSurfaceIdentity.swift */; }; + E7C100000000000000000003 /* TerminalCmdClickUITestConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C100000000000000000004 /* TerminalCmdClickUITestConfiguration.swift */; }; + E7C100000000000000000001 /* TerminalCmdClickUITestCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C100000000000000000002 /* TerminalCmdClickUITestCoordinator.swift */; }; + E7C100000000000000000005 /* TerminalCmdClickUITestManifestWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C100000000000000000006 /* TerminalCmdClickUITestManifestWriter.swift */; }; C2577000A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */; }; F87910190000000000000001 /* TerminalConfigurationReloadCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87910190000000000000002 /* TerminalConfigurationReloadCoordinator.swift */; }; F87930000000000000000001 /* TerminalConfigurationReloadEnqueueResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87930000000000000000002 /* TerminalConfigurationReloadEnqueueResult.swift */; }; @@ -5011,6 +5014,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B8835A000000000000000004 /* TerminalClipboardInputSequencerTests+Replay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalClipboardInputSequencerTests+Replay.swift"; sourceTree = ""; }; B8835700000000000000000E /* TerminalClipboardInputSequencerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalClipboardInputSequencerTests.swift; sourceTree = ""; }; B88357000000000000000014 /* TerminalClipboardRequestSurfaceIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalClipboardRequestSurfaceIdentity.swift; sourceTree = ""; }; + E7C100000000000000000004 /* TerminalCmdClickUITestConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Debug/UITests/TerminalCmdClickUITestConfiguration.swift"; sourceTree = ""; }; + E7C100000000000000000002 /* TerminalCmdClickUITestCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Debug/UITests/TerminalCmdClickUITestCoordinator.swift"; sourceTree = ""; }; + E7C100000000000000000006 /* TerminalCmdClickUITestManifestWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Debug/UITests/TerminalCmdClickUITestManifestWriter.swift"; sourceTree = ""; }; C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCmdClickUITests.swift; sourceTree = ""; }; F87910190000000000000002 /* TerminalConfigurationReloadCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalConfigurationReloadCoordinator.swift; sourceTree = ""; }; F87930000000000000000002 /* TerminalConfigurationReloadEnqueueResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalConfigurationReloadEnqueueResult.swift; sourceTree = ""; }; @@ -6853,6 +6859,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F4350A130000000000000001 /* AppBundleIconPersistencePolicy.swift */, D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, A5001090 /* AppDelegate.swift */, + E7C100000000000000000002 /* TerminalCmdClickUITestCoordinator.swift */, + E7C100000000000000000004 /* TerminalCmdClickUITestConfiguration.swift */, + E7C100000000000000000006 /* TerminalCmdClickUITestManifestWriter.swift */, C51A740000000000000000A2 /* AppDelegate+SimulatorShortcutRouting.swift */, D35B00000000000000000011 /* TerminalController+BrowserDesignMode.swift */, D35B00000000000000000014 /* TerminalController+AgentPromptDelivery.swift */, @@ -10305,6 +10314,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B8835A000000000000000001 /* TerminalClipboardInputSequencer+State.swift in Sources */, B88357000000000000000005 /* TerminalClipboardInputSequencer.swift in Sources */, B88357000000000000000013 /* TerminalClipboardRequestSurfaceIdentity.swift in Sources */, + E7C100000000000000000003 /* TerminalCmdClickUITestConfiguration.swift in Sources */, + E7C100000000000000000001 /* TerminalCmdClickUITestCoordinator.swift in Sources */, + E7C100000000000000000005 /* TerminalCmdClickUITestManifestWriter.swift in Sources */, F87910190000000000000001 /* TerminalConfigurationReloadCoordinator.swift in Sources */, F87930000000000000000001 /* TerminalConfigurationReloadEnqueueResult.swift in Sources */, F87920000000000000000005 /* TerminalConfigurationReloadPhase.swift in Sources */, From d94ae1644e5721139aa01739173a0aa583a5ad0b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 18 Sep 2026 18:15:01 -0700 Subject: [PATCH 2/3] refactor: extract typing diagnostics instrumentation --- Sources/AppDelegate.swift | 343 ------------------ .../Debug/CmuxMainRunLoopStallMonitor.swift | 89 +++++ .../Debug/CmuxMainThreadTurnProfiler.swift | 144 ++++++++ Sources/Debug/CmuxTypingTiming.swift | 119 ++++++ cmux.xcodeproj/project.pbxproj | 12 + 5 files changed, 364 insertions(+), 343 deletions(-) create mode 100644 Sources/Debug/CmuxMainRunLoopStallMonitor.swift create mode 100644 Sources/Debug/CmuxMainThreadTurnProfiler.swift create mode 100644 Sources/Debug/CmuxTypingTiming.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 4df6862339e1..08089a6e1bf3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -196,350 +196,7 @@ final class ConfiguredGroupActionAsyncWorkspaceObserver { } } -#if DEBUG -enum CmuxTypingTiming { - static let isEnabled: Bool = { - let environment = ProcessInfo.processInfo.environment - if environment["CMUX_TYPING_TIMING_LOGS"] == "1" || environment["CMUX_KEY_LATENCY_PROBE"] == "1" { - return true - } - let defaults = UserDefaults.standard - return defaults.bool(forKey: "cmuxTypingTimingLogs") || defaults.bool(forKey: "cmuxKeyLatencyProbe") - }() - static let isVerboseProbeEnabled: Bool = { - let environment = ProcessInfo.processInfo.environment - if environment["CMUX_KEY_LATENCY_PROBE"] == "1" { - return true - } - return UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe") - }() - private static let delayLogThresholdMs: Double = 6.0 - private static let durationLogThresholdMs: Double = 1.0 - - @inline(__always) - static func start() -> TimeInterval? { - guard isEnabled else { return nil } - return ProcessInfo.processInfo.systemUptime - } - - @inline(__always) - static func logEventDelay(path: String, event: NSEvent) { - guard isEnabled else { return } - guard event.timestamp > 0 else { return } - let delayMs = max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) - guard shouldLog(delayMs: delayMs, elapsedMs: nil) else { return } - cmuxDebugLog("typing.delay probe=\(path) delayMs=\(format(delayMs)) \(eventFields(event))") - } - - @inline(__always) - static func logDuration(path: String, startedAt: TimeInterval?, event: NSEvent? = nil, extra: String? = nil) { - CmuxMainThreadTurnProfiler.endMeasure(path, startedAt: startedAt) - guard let startedAt else { return } - let elapsedMs = max(0, (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0) - let delayMs: Double? = { - guard let event, event.timestamp > 0 else { return nil } - return max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) - }() - guard shouldLog(delayMs: delayMs, elapsedMs: elapsedMs) else { return } - var line = "typing.timing probe=\(path) elapsedMs=\(format(elapsedMs))" - if let event { - line += " \(eventFields(event))" - if let delayMs { - line += " delayMs=\(format(delayMs))" - } - } - if let extra, !extra.isEmpty { - line += " \(extra)" - } - cmuxDebugLog(line) - } - - @inline(__always) - static func logBreakdown( - path: String, - totalMs: Double, - event: NSEvent? = nil, - thresholdMs: Double = 2.0, - parts: [(String, Double)], - extra: String? = nil - ) { - guard isEnabled else { return } - let delayMs: Double? = { - guard let event, event.timestamp > 0 else { return nil } - return max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) - }() - let hasSlowPart = parts.contains { $0.1 >= thresholdMs } - guard isVerboseProbeEnabled || totalMs >= thresholdMs || hasSlowPart || (delayMs ?? 0) >= delayLogThresholdMs else { - return - } - var line = "typing.phase probe=\(path) totalMs=\(format(totalMs))" - if let event { - line += " \(eventFields(event))" - } - if let delayMs { - line += " delayMs=\(format(delayMs))" - } - for (name, value) in parts where isVerboseProbeEnabled || value >= 0.05 { - line += " \(name)=\(format(value))" - } - if let extra, !extra.isEmpty { - line += " \(extra)" - } - cmuxDebugLog(line) - } - - @inline(__always) - private static func eventFields(_ event: NSEvent) -> String { - "eventType=\(event.type.rawValue) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)" - } - - @inline(__always) - private static func shouldLog(delayMs: Double?, elapsedMs: Double?) -> Bool { - if isVerboseProbeEnabled { - return true - } - if let delayMs, delayMs >= delayLogThresholdMs { - return true - } - if let elapsedMs, elapsedMs >= durationLogThresholdMs { - return true - } - return false - } - - @inline(__always) - private static func format(_ value: Double) -> String { - String(format: "%.2f", value) - } -} - -final class CmuxMainRunLoopStallMonitor { - static let shared = CmuxMainRunLoopStallMonitor() - - private let thresholdMs: Double = 8.0 - private var observer: CFRunLoopObserver? - private var installed = false - private var lastActivity: CFRunLoopActivity? - private var lastTimestamp: TimeInterval? - - private init() {} - - func installIfNeeded() { - guard CmuxTypingTiming.isEnabled else { return } - guard !installed else { return } - - var context = CFRunLoopObserverContext( - version: 0, - info: Unmanaged.passUnretained(self).toOpaque(), - retain: nil, - release: nil, - copyDescription: nil - ) - - observer = CFRunLoopObserverCreate( - kCFAllocatorDefault, - CFRunLoopActivity.allActivities.rawValue, - true, - CFIndex.max, - { _, activity, info in - guard let info else { return } - let monitor = Unmanaged.fromOpaque(info).takeUnretainedValue() - monitor.handle(activity: activity) - }, - &context - ) - - guard let observer else { return } - CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) - installed = true - } - - private func handle(activity: CFRunLoopActivity) { - let now = ProcessInfo.processInfo.systemUptime - defer { - lastActivity = activity - lastTimestamp = now - } - - guard let lastActivity, let lastTimestamp else { return } - let elapsedMs = max(0, (now - lastTimestamp) * 1000.0) - guard elapsedMs >= thresholdMs else { return } - if lastActivity == .beforeWaiting && activity == .afterWaiting { - return - } - - let mode = CFRunLoopCopyCurrentMode(CFRunLoopGetMain()).map { String(describing: $0) } ?? "nil" - let firstResponder = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let currentEvent = NSApp.currentEvent.map { - "eventType=\($0.type.rawValue) keyCode=\($0.keyCode) mods=\($0.modifierFlags.rawValue)" - } ?? "event=nil" - cmuxDebugLog( - "runloop.stall gapMs=\(String(format: "%.2f", elapsedMs)) prev=\(label(for: lastActivity)) " + - "next=\(label(for: activity)) mode=\(mode) firstResponder=\(firstResponder) \(currentEvent)" - ) - } - - private func label(for activity: CFRunLoopActivity) -> String { - switch activity { - case .entry: - return "entry" - case .beforeTimers: - return "beforeTimers" - case .beforeSources: - return "beforeSources" - case .beforeWaiting: - return "beforeWaiting" - case .afterWaiting: - return "afterWaiting" - case .exit: - return "exit" - default: - return "unknown(\(activity.rawValue))" - } - } -} - -final class CmuxMainThreadTurnProfiler { - static let shared = CmuxMainThreadTurnProfiler() - - private struct BucketStats { - var count: Int = 0 - var totalMs: Double = 0 - var maxMs: Double = 0 - } - - private let trackedThresholdMs: Double = 3.0 - private let countThreshold: Int = 16 - private var observer: CFRunLoopObserver? - private var installed = false - private var turnStart: TimeInterval? - private var buckets: [String: BucketStats] = [:] - - private init() {} - - @inline(__always) - static func endMeasure(_ bucket: String, startedAt: TimeInterval?) { - guard let startedAt, CmuxTypingTiming.isEnabled, Thread.isMainThread else { return } - let elapsedMs = max(0, (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0) - shared.record(bucket: bucket, elapsedMs: elapsedMs, count: 1) - } - - func installIfNeeded() { - guard CmuxTypingTiming.isEnabled else { return } - guard !installed else { return } - - var context = CFRunLoopObserverContext( - version: 0, - info: Unmanaged.passUnretained(self).toOpaque(), - retain: nil, - release: nil, - copyDescription: nil - ) - - observer = CFRunLoopObserverCreate( - kCFAllocatorDefault, - CFRunLoopActivity.allActivities.rawValue, - true, - CFIndex.max, - { _, activity, info in - guard let info else { return } - let profiler = Unmanaged.fromOpaque(info).takeUnretainedValue() - profiler.handle(activity: activity) - }, - &context - ) - - guard let observer else { return } - CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) - installed = true - } - - private func handle(activity: CFRunLoopActivity) { - let now = ProcessInfo.processInfo.systemUptime - switch activity { - case .entry, .afterWaiting: - turnStart = now - buckets.removeAll(keepingCapacity: true) - case .beforeWaiting, .exit: - flushTurn(at: now, nextActivity: activity) - default: - break - } - } - - private func record(bucket: String, elapsedMs: Double, count: Int) { - if turnStart == nil { - turnStart = ProcessInfo.processInfo.systemUptime - } - var stats = buckets[bucket, default: BucketStats()] - stats.count += count - stats.totalMs += elapsedMs - stats.maxMs = max(stats.maxMs, elapsedMs) - buckets[bucket] = stats - } - - private func flushTurn(at now: TimeInterval, nextActivity: CFRunLoopActivity) { - defer { - turnStart = nil - buckets.removeAll(keepingCapacity: true) - } - - guard let turnStart else { return } - guard !buckets.isEmpty else { return } - - let turnMs = max(0, (now - turnStart) * 1000.0) - let trackedMs = buckets.values.reduce(0) { $0 + $1.totalMs } - let totalCount = buckets.values.reduce(0) { $0 + $1.count } - guard trackedMs >= trackedThresholdMs || totalCount >= countThreshold else { return } - - let mode = CFRunLoopCopyCurrentMode(CFRunLoopGetMain()).map { String(describing: $0) } ?? "nil" - let firstResponder = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let eventSummary = NSApp.currentEvent.map { - "eventType=\($0.type.rawValue) keyCode=\($0.keyCode) mods=\($0.modifierFlags.rawValue)" - } ?? "event=nil" - let bucketSummary = buckets - .sorted { - if abs($0.value.totalMs - $1.value.totalMs) > 0.01 { - return $0.value.totalMs > $1.value.totalMs - } - return $0.value.count > $1.value.count - } - .prefix(8) - .map { key, value in - if value.totalMs > 0.05 || value.maxMs > 0.05 { - return "\(key)=\(value.count)/\(String(format: "%.2f", value.totalMs))/\(String(format: "%.2f", value.maxMs))" - } - return "\(key)=\(value.count)" - } - .joined(separator: " ") - cmuxDebugLog( - "main.turn.work turnMs=\(String(format: "%.2f", turnMs)) trackedMs=\(String(format: "%.2f", trackedMs)) totalCount=\(totalCount) " + - "next=\(label(for: nextActivity)) mode=\(mode) firstResponder=\(firstResponder) \(eventSummary) " + - "\(bucketSummary)" - ) - } - - private func label(for activity: CFRunLoopActivity) -> String { - switch activity { - case .entry: - return "entry" - case .beforeTimers: - return "beforeTimers" - case .beforeSources: - return "beforeSources" - case .beforeWaiting: - return "beforeWaiting" - case .afterWaiting: - return "afterWaiting" - case .exit: - return "exit" - default: - return "unknown(\(activity.rawValue))" - } - } -} -#endif @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation, NSMenuDelegate, CmuxConfigStoreReloadEnvironment { diff --git a/Sources/Debug/CmuxMainRunLoopStallMonitor.swift b/Sources/Debug/CmuxMainRunLoopStallMonitor.swift new file mode 100644 index 000000000000..fafcb0ae920d --- /dev/null +++ b/Sources/Debug/CmuxMainRunLoopStallMonitor.swift @@ -0,0 +1,89 @@ +#if DEBUG +import AppKit + +final class CmuxMainRunLoopStallMonitor { + static let shared = CmuxMainRunLoopStallMonitor() + + private let thresholdMs: Double = 8.0 + private var observer: CFRunLoopObserver? + private var installed = false + private var lastActivity: CFRunLoopActivity? + private var lastTimestamp: TimeInterval? + + private init() {} + + func installIfNeeded() { + guard CmuxTypingTiming.isEnabled else { return } + guard !installed else { return } + + var context = CFRunLoopObserverContext( + version: 0, + info: Unmanaged.passUnretained(self).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + + observer = CFRunLoopObserverCreate( + kCFAllocatorDefault, + CFRunLoopActivity.allActivities.rawValue, + true, + CFIndex.max, + { _, activity, info in + guard let info else { return } + let monitor = Unmanaged.fromOpaque(info).takeUnretainedValue() + monitor.handle(activity: activity) + }, + &context + ) + + guard let observer else { return } + CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) + installed = true + } + + private func handle(activity: CFRunLoopActivity) { + let now = ProcessInfo.processInfo.systemUptime + defer { + lastActivity = activity + lastTimestamp = now + } + + guard let lastActivity, let lastTimestamp else { return } + let elapsedMs = max(0, (now - lastTimestamp) * 1000.0) + guard elapsedMs >= thresholdMs else { return } + if lastActivity == .beforeWaiting && activity == .afterWaiting { + return + } + + let mode = CFRunLoopCopyCurrentMode(CFRunLoopGetMain()).map { String(describing: $0) } ?? "nil" + let firstResponder = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let currentEvent = NSApp.currentEvent.map { + "eventType=\($0.type.rawValue) keyCode=\($0.keyCode) mods=\($0.modifierFlags.rawValue)" + } ?? "event=nil" + cmuxDebugLog( + "runloop.stall gapMs=\(String(format: "%.2f", elapsedMs)) prev=\(label(for: lastActivity)) " + + "next=\(label(for: activity)) mode=\(mode) firstResponder=\(firstResponder) \(currentEvent)" + ) + } + + private func label(for activity: CFRunLoopActivity) -> String { + switch activity { + case .entry: + return "entry" + case .beforeTimers: + return "beforeTimers" + case .beforeSources: + return "beforeSources" + case .beforeWaiting: + return "beforeWaiting" + case .afterWaiting: + return "afterWaiting" + case .exit: + return "exit" + default: + return "unknown(\(activity.rawValue))" + } + } +} +#endif diff --git a/Sources/Debug/CmuxMainThreadTurnProfiler.swift b/Sources/Debug/CmuxMainThreadTurnProfiler.swift new file mode 100644 index 000000000000..4e1c294f3f80 --- /dev/null +++ b/Sources/Debug/CmuxMainThreadTurnProfiler.swift @@ -0,0 +1,144 @@ +#if DEBUG +import AppKit + +final class CmuxMainThreadTurnProfiler { + static let shared = CmuxMainThreadTurnProfiler() + + private struct BucketStats { + var count: Int = 0 + var totalMs: Double = 0 + var maxMs: Double = 0 + } + + private let trackedThresholdMs: Double = 3.0 + private let countThreshold: Int = 16 + private var observer: CFRunLoopObserver? + private var installed = false + private var turnStart: TimeInterval? + private var buckets: [String: BucketStats] = [:] + + private init() {} + + @inline(__always) + static func endMeasure(_ bucket: String, startedAt: TimeInterval?) { + guard let startedAt, CmuxTypingTiming.isEnabled, Thread.isMainThread else { return } + let elapsedMs = max(0, (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0) + shared.record(bucket: bucket, elapsedMs: elapsedMs, count: 1) + } + + func installIfNeeded() { + guard CmuxTypingTiming.isEnabled else { return } + guard !installed else { return } + + var context = CFRunLoopObserverContext( + version: 0, + info: Unmanaged.passUnretained(self).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + + observer = CFRunLoopObserverCreate( + kCFAllocatorDefault, + CFRunLoopActivity.allActivities.rawValue, + true, + CFIndex.max, + { _, activity, info in + guard let info else { return } + let profiler = Unmanaged.fromOpaque(info).takeUnretainedValue() + profiler.handle(activity: activity) + }, + &context + ) + + guard let observer else { return } + CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) + installed = true + } + + private func handle(activity: CFRunLoopActivity) { + let now = ProcessInfo.processInfo.systemUptime + switch activity { + case .entry, .afterWaiting: + turnStart = now + buckets.removeAll(keepingCapacity: true) + case .beforeWaiting, .exit: + flushTurn(at: now, nextActivity: activity) + default: + break + } + } + + private func record(bucket: String, elapsedMs: Double, count: Int) { + if turnStart == nil { + turnStart = ProcessInfo.processInfo.systemUptime + } + var stats = buckets[bucket, default: BucketStats()] + stats.count += count + stats.totalMs += elapsedMs + stats.maxMs = max(stats.maxMs, elapsedMs) + buckets[bucket] = stats + } + + private func flushTurn(at now: TimeInterval, nextActivity: CFRunLoopActivity) { + defer { + turnStart = nil + buckets.removeAll(keepingCapacity: true) + } + + guard let turnStart else { return } + guard !buckets.isEmpty else { return } + + let turnMs = max(0, (now - turnStart) * 1000.0) + let trackedMs = buckets.values.reduce(0) { $0 + $1.totalMs } + let totalCount = buckets.values.reduce(0) { $0 + $1.count } + guard trackedMs >= trackedThresholdMs || totalCount >= countThreshold else { return } + + let mode = CFRunLoopCopyCurrentMode(CFRunLoopGetMain()).map { String(describing: $0) } ?? "nil" + let firstResponder = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let eventSummary = NSApp.currentEvent.map { + "eventType=\($0.type.rawValue) keyCode=\($0.keyCode) mods=\($0.modifierFlags.rawValue)" + } ?? "event=nil" + let bucketSummary = buckets + .sorted { + if abs($0.value.totalMs - $1.value.totalMs) > 0.01 { + return $0.value.totalMs > $1.value.totalMs + } + return $0.value.count > $1.value.count + } + .prefix(8) + .map { key, value in + if value.totalMs > 0.05 || value.maxMs > 0.05 { + return "\(key)=\(value.count)/\(String(format: "%.2f", value.totalMs))/\(String(format: "%.2f", value.maxMs))" + } + return "\(key)=\(value.count)" + } + .joined(separator: " ") + + cmuxDebugLog( + "main.turn.work turnMs=\(String(format: "%.2f", turnMs)) trackedMs=\(String(format: "%.2f", trackedMs)) totalCount=\(totalCount) " + + "next=\(label(for: nextActivity)) mode=\(mode) firstResponder=\(firstResponder) \(eventSummary) " + + "\(bucketSummary)" + ) + } + + private func label(for activity: CFRunLoopActivity) -> String { + switch activity { + case .entry: + return "entry" + case .beforeTimers: + return "beforeTimers" + case .beforeSources: + return "beforeSources" + case .beforeWaiting: + return "beforeWaiting" + case .afterWaiting: + return "afterWaiting" + case .exit: + return "exit" + default: + return "unknown(\(activity.rawValue))" + } + } +} +#endif diff --git a/Sources/Debug/CmuxTypingTiming.swift b/Sources/Debug/CmuxTypingTiming.swift new file mode 100644 index 000000000000..7eab6a4f18fc --- /dev/null +++ b/Sources/Debug/CmuxTypingTiming.swift @@ -0,0 +1,119 @@ +#if DEBUG +import AppKit + +enum CmuxTypingTiming { + static let isEnabled: Bool = { + let environment = ProcessInfo.processInfo.environment + if environment["CMUX_TYPING_TIMING_LOGS"] == "1" || environment["CMUX_KEY_LATENCY_PROBE"] == "1" { + return true + } + let defaults = UserDefaults.standard + return defaults.bool(forKey: "cmuxTypingTimingLogs") || defaults.bool(forKey: "cmuxKeyLatencyProbe") + }() + static let isVerboseProbeEnabled: Bool = { + let environment = ProcessInfo.processInfo.environment + if environment["CMUX_KEY_LATENCY_PROBE"] == "1" { + return true + } + return UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe") + }() + private static let delayLogThresholdMs: Double = 6.0 + private static let durationLogThresholdMs: Double = 1.0 + + @inline(__always) + static func start() -> TimeInterval? { + guard isEnabled else { return nil } + return ProcessInfo.processInfo.systemUptime + } + + @inline(__always) + static func logEventDelay(path: String, event: NSEvent) { + guard isEnabled else { return } + guard event.timestamp > 0 else { return } + let delayMs = max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) + guard shouldLog(delayMs: delayMs, elapsedMs: nil) else { return } + cmuxDebugLog("typing.delay probe=\(path) delayMs=\(format(delayMs)) \(eventFields(event))") + } + + @inline(__always) + static func logDuration(path: String, startedAt: TimeInterval?, event: NSEvent? = nil, extra: String? = nil) { + CmuxMainThreadTurnProfiler.endMeasure(path, startedAt: startedAt) + guard let startedAt else { return } + let elapsedMs = max(0, (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0) + let delayMs: Double? = { + guard let event, event.timestamp > 0 else { return nil } + return max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) + }() + guard shouldLog(delayMs: delayMs, elapsedMs: elapsedMs) else { return } + var line = "typing.timing probe=\(path) elapsedMs=\(format(elapsedMs))" + if let event { + line += " \(eventFields(event))" + if let delayMs { + line += " delayMs=\(format(delayMs))" + } + } + if let extra, !extra.isEmpty { + line += " \(extra)" + } + cmuxDebugLog(line) + } + + @inline(__always) + static func logBreakdown( + path: String, + totalMs: Double, + event: NSEvent? = nil, + thresholdMs: Double = 2.0, + parts: [(String, Double)], + extra: String? = nil + ) { + guard isEnabled else { return } + let delayMs: Double? = { + guard let event, event.timestamp > 0 else { return nil } + return max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000.0) + }() + let hasSlowPart = parts.contains { $0.1 >= thresholdMs } + guard isVerboseProbeEnabled || totalMs >= thresholdMs || hasSlowPart || (delayMs ?? 0) >= delayLogThresholdMs else { + return + } + var line = "typing.phase probe=\(path) totalMs=\(format(totalMs))" + if let event { + line += " \(eventFields(event))" + } + if let delayMs { + line += " delayMs=\(format(delayMs))" + } + for (name, value) in parts where isVerboseProbeEnabled || value >= 0.05 { + line += " \(name)=\(format(value))" + } + if let extra, !extra.isEmpty { + line += " \(extra)" + } + cmuxDebugLog(line) + } + + @inline(__always) + private static func eventFields(_ event: NSEvent) -> String { + "eventType=\(event.type.rawValue) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)" + } + + @inline(__always) + private static func shouldLog(delayMs: Double?, elapsedMs: Double?) -> Bool { + if isVerboseProbeEnabled { + return true + } + if let delayMs, delayMs >= delayLogThresholdMs { + return true + } + if let elapsedMs, elapsedMs >= durationLogThresholdMs { + return true + } + return false + } + + @inline(__always) + private static func format(_ value: Double) -> String { + String(format: "%.2f", value) + } +} +#endif diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 43147b287afd..8a181e67eac0 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -776,6 +776,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 1A0B0C0D0E0F101112132013 /* CmuxIrohTransport in Frameworks */ = {isa = PBXBuildFile; productRef = 1A0B0C0D0E0F101112132012 /* CmuxIrohTransport */; }; 1A0B0C0D0E0F101112132014 /* CmuxIrohTransport in Frameworks */ = {isa = PBXBuildFile; productRef = 1A0B0C0D0E0F101112132012 /* CmuxIrohTransport */; }; E7E00000000000000000000B /* CmuxLifecycleEventPublishing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7E00000000000000000000C /* CmuxLifecycleEventPublishing.swift */; }; + E7D100000000000000000003 /* CmuxMainRunLoopStallMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7D100000000000000000004 /* CmuxMainRunLoopStallMonitor.swift */; }; + E7D100000000000000000005 /* CmuxMainThreadTurnProfiler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7D100000000000000000006 /* CmuxMainThreadTurnProfiler.swift */; }; 2F0C07000000000000000002 /* CmuxMainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0C07000000000000000001 /* CmuxMainWindow.swift */; }; D36090010000000000000005 /* CmuxMainWindowConstrainFrameTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36090010000000000000006 /* CmuxMainWindowConstrainFrameTests.swift */; }; D36090020000000000000005 /* CmuxMainWindowFullScreenCapabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36090020000000000000006 /* CmuxMainWindowFullScreenCapabilityTests.swift */; }; @@ -866,6 +868,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C7A508000000000000000002 /* CmuxTopSnapshotScopeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A508000000000000000001 /* CmuxTopSnapshotScopeCache.swift */; }; C7A5090000000000000005A2 /* CmuxTopSnapshotScopeCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A5090000000000000005A1 /* CmuxTopSnapshotScopeCacheTests.swift */; }; C7A509000000000000000002 /* CmuxTopSnapshotScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A509000000000000000001 /* CmuxTopSnapshotScopeTests.swift */; }; + E7D100000000000000000001 /* CmuxTypingTiming.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7D100000000000000000002 /* CmuxTypingTiming.swift */; }; CDFEED0300000000CDFEED03 /* CmuxUpdater in Frameworks */ = {isa = PBXBuildFile; productRef = CDFEED0200000000CDFEED02 /* CmuxUpdater */; }; CDFEED0600000000CDFEED06 /* CmuxUpdaterUI in Frameworks */ = {isa = PBXBuildFile; productRef = CDFEED0500000000CDFEED05 /* CmuxUpdaterUI */; }; 9520A0029520A0029520A002 /* CmuxVaultAgentRegistration+Hermes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9520B0029520B0029520B002 /* CmuxVaultAgentRegistration+Hermes.swift */; }; @@ -3595,6 +3598,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE34020000000000000004 /* CmuxHelpResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CmuxHelpResource.swift; sourceTree = ""; }; C0DE46010000000000000002 /* CMUXInstalledExtensionSidebarHostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXInstalledExtensionSidebarHostView.swift; sourceTree = ""; }; E7E00000000000000000000C /* CmuxLifecycleEventPublishing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxLifecycleEventPublishing.swift; sourceTree = ""; }; + E7D100000000000000000004 /* CmuxMainRunLoopStallMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Debug/CmuxMainRunLoopStallMonitor.swift"; sourceTree = ""; }; + E7D100000000000000000006 /* CmuxMainThreadTurnProfiler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Debug/CmuxMainThreadTurnProfiler.swift"; sourceTree = ""; }; 2F0C07000000000000000001 /* CmuxMainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CmuxMainWindow.swift; sourceTree = ""; }; D36090010000000000000006 /* CmuxMainWindowConstrainFrameTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxMainWindowConstrainFrameTests.swift; sourceTree = ""; }; D36090020000000000000006 /* CmuxMainWindowFullScreenCapabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxMainWindowFullScreenCapabilityTests.swift; sourceTree = ""; }; @@ -3650,6 +3655,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C7A508000000000000000001 /* CmuxTopSnapshotScopeCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopSnapshotScopeCache.swift; sourceTree = ""; }; C7A5090000000000000005A1 /* CmuxTopSnapshotScopeCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopSnapshotScopeCacheTests.swift; sourceTree = ""; }; C7A509000000000000000001 /* CmuxTopSnapshotScopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopSnapshotScopeTests.swift; sourceTree = ""; }; + E7D100000000000000000002 /* CmuxTypingTiming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Debug/CmuxTypingTiming.swift"; sourceTree = ""; }; 7E7E6EF344A568AC7FEE3715 /* cmuxUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = cmuxUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9520B0029520B0029520B002 /* CmuxVaultAgentRegistration+Hermes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxVaultAgentRegistration+Hermes.swift"; sourceTree = ""; }; 858200000000000000000005 /* CmuxVaultAgentRegistration+RegisteredResumeKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxVaultAgentRegistration+RegisteredResumeKind.swift"; sourceTree = ""; }; @@ -6862,6 +6868,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E7C100000000000000000002 /* TerminalCmdClickUITestCoordinator.swift */, E7C100000000000000000004 /* TerminalCmdClickUITestConfiguration.swift */, E7C100000000000000000006 /* TerminalCmdClickUITestManifestWriter.swift */, + E7D100000000000000000002 /* CmuxTypingTiming.swift */, + E7D100000000000000000004 /* CmuxMainRunLoopStallMonitor.swift */, + E7D100000000000000000006 /* CmuxMainThreadTurnProfiler.swift */, C51A740000000000000000A2 /* AppDelegate+SimulatorShortcutRouting.swift */, D35B00000000000000000011 /* TerminalController+BrowserDesignMode.swift */, D35B00000000000000000014 /* TerminalController+AgentPromptDelivery.swift */, @@ -9348,6 +9357,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE34020000000000000002 /* CmuxHelpResource.swift in Sources */, C0DE46010000000000000001 /* CMUXInstalledExtensionSidebarHostView.swift in Sources */, E7E00000000000000000000B /* CmuxLifecycleEventPublishing.swift in Sources */, + E7D100000000000000000003 /* CmuxMainRunLoopStallMonitor.swift in Sources */, + E7D100000000000000000005 /* CmuxMainThreadTurnProfiler.swift in Sources */, 2F0C07000000000000000002 /* CmuxMainWindow.swift in Sources */, CA1F0A01CA1F0A01CA1F0A01 /* CmuxModalAlertPresentation.swift in Sources */, C54860110000000000000001 /* CmuxNavigationResolution.swift in Sources */, @@ -9388,6 +9399,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C7A512000000000000000002 /* CmuxTopProcessSnapshotCache.swift in Sources */, C7A501000000000000000002 /* CmuxTopSnapshot.swift in Sources */, C7A508000000000000000002 /* CmuxTopSnapshotScopeCache.swift in Sources */, + E7D100000000000000000001 /* CmuxTypingTiming.swift in Sources */, 9520A0029520A0029520A002 /* CmuxVaultAgentRegistration+Hermes.swift in Sources */, 858200000000000000000006 /* CmuxVaultAgentRegistration+RegisteredResumeKind.swift in Sources */, 9520A0059520A0059520A005 /* CmuxVaultAgentRegistry+ProcessMatching.swift in Sources */, From b165f81523422d1c021105a37a525f61678878e0 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 18 Sep 2026 18:16:07 -0700 Subject: [PATCH 3/3] refactor: extract configured group workspace observer --- ...redGroupActionAsyncWorkspaceObserver.swift | 119 ++++++++++++++++++ Sources/AppDelegate.swift | 118 ----------------- cmux.xcodeproj/project.pbxproj | 4 + 3 files changed, 123 insertions(+), 118 deletions(-) create mode 100644 Sources/App/ConfiguredGroupActionAsyncWorkspaceObserver.swift diff --git a/Sources/App/ConfiguredGroupActionAsyncWorkspaceObserver.swift b/Sources/App/ConfiguredGroupActionAsyncWorkspaceObserver.swift new file mode 100644 index 000000000000..d5f728549d66 --- /dev/null +++ b/Sources/App/ConfiguredGroupActionAsyncWorkspaceObserver.swift @@ -0,0 +1,119 @@ +import Foundation +import Combine + +/// Short-lived helper that watches for the next workspace to appear in a +/// TabManager and joins it to a target group. Used by group `+` context-menu +/// actions whose underlying executor creates the workspace asynchronously +/// (cloudVM in particular launches `cmux vm base open` and returns immediately). +/// Subscribes to `tabManager.tabsPublisher` (the legacy Combine bridge fed by +/// every `tabs` mutation, regardless of whether a NotificationCenter event +/// fired) so VM workspaces, dropped attaches, or any other slow async path +/// is caught. Self-clears on first match, group disappearance, or a process +/// completion signal that either names the created workspace or reports launch +/// failure. +@MainActor +final class ConfiguredGroupActionAsyncWorkspaceObserver { + static var pending: [ObjectIdentifier: ConfiguredGroupActionAsyncWorkspaceObserver] = [:] + private let id = UUID() + private weak var tabManager: TabManager? + private let storedKey: ObjectIdentifier + private let groupId: UUID + private let placement: WorkspaceGroupNewPlacement + private let referenceWorkspaceId: UUID? + private var knownIds: Set + private var subscription: AnyCancellable? + + @discardableResult + static func install( + tabManager: TabManager, + groupId: UUID, + knownIds: Set, + placement: WorkspaceGroupNewPlacement, + referenceWorkspaceId: UUID? + ) -> UUID { + let key = ObjectIdentifier(tabManager) + pending[key]?.dispose() + let watcher = ConfiguredGroupActionAsyncWorkspaceObserver( + tabManager: tabManager, + groupId: groupId, + placement: placement, + referenceWorkspaceId: referenceWorkspaceId, + knownIds: knownIds + ) + pending[key] = watcher + watcher.subscription = tabManager.tabsPublisher + .receive(on: DispatchQueue.main) + .sink { [weak watcher] tabs in + watcher?.checkForNewWorkspace(in: tabs) + } + return watcher.id + } + + static func disposePending(tabManager: TabManager, observerId: UUID) { + let key = ObjectIdentifier(tabManager) + guard pending[key]?.id == observerId else { return } + pending[key]?.dispose() + } + + static func finishPending(tabManager: TabManager, observerId: UUID, workspaceId: UUID?) { + let key = ObjectIdentifier(tabManager) + guard let watcher = pending[key], watcher.id == observerId else { return } + watcher.finish(workspaceId: workspaceId) + } + + private init( + tabManager: TabManager, + groupId: UUID, + placement: WorkspaceGroupNewPlacement, + referenceWorkspaceId: UUID?, + knownIds: Set + ) { + self.tabManager = tabManager + self.storedKey = ObjectIdentifier(tabManager) + self.groupId = groupId + self.placement = placement + self.referenceWorkspaceId = referenceWorkspaceId + self.knownIds = knownIds + } + + private func checkForNewWorkspace(in tabs: [Workspace]) { + guard let tabManager else { dispose(); return } + guard tabManager.workspaceGroups.contains(where: { $0.id == groupId }) else { + dispose() + return + } + for tab in tabs where !knownIds.contains(tab.id) { + tabManager.addWorkspaceToGroup( + workspaceId: tab.id, + groupId: groupId, + placement: placement, + referenceWorkspaceId: referenceWorkspaceId + ) + dispose() + return + } + } + + private func finish(workspaceId: UUID?) { + defer { dispose() } + guard let workspaceId, let tabManager else { return } + guard tabManager.workspaceGroups.contains(where: { $0.id == groupId }) else { return } + guard tabManager.tabs.contains(where: { $0.id == workspaceId }) else { return } + tabManager.addWorkspaceToGroup( + workspaceId: workspaceId, + groupId: groupId, + placement: placement, + referenceWorkspaceId: referenceWorkspaceId + ) + } + + private func dispose() { + subscription?.cancel() + subscription = nil + // Remove by the key recorded at install time. The weak `tabManager` + // may already be nil here (window closed mid-watch), and walking it + // would silently leak the entry in the static `pending` dictionary + // for the rest of the app session. + Self.pending.removeValue(forKey: storedKey) + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 08089a6e1bf3..bdbcf8689e35 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -79,124 +79,6 @@ final class CmuxDebugWindowsCoordinator { #endif } -/// Short-lived helper that watches for the next workspace to appear in a -/// TabManager and joins it to a target group. Used by group `+` context-menu -/// actions whose underlying executor creates the workspace asynchronously -/// (cloudVM in particular launches `cmux vm base open` and returns immediately). -/// Subscribes to `tabManager.tabsPublisher` (the legacy Combine bridge fed by -/// every `tabs` mutation, regardless of whether a NotificationCenter event -/// fired) so VM workspaces, dropped attaches, or any other slow async path -/// is caught. Self-clears on first match, group disappearance, or a process -/// completion signal that either names the created workspace or reports launch -/// failure. -@MainActor -final class ConfiguredGroupActionAsyncWorkspaceObserver { - static var pending: [ObjectIdentifier: ConfiguredGroupActionAsyncWorkspaceObserver] = [:] - private let id = UUID() - private weak var tabManager: TabManager? - private let storedKey: ObjectIdentifier - private let groupId: UUID - private let placement: WorkspaceGroupNewPlacement - private let referenceWorkspaceId: UUID? - private var knownIds: Set - private var subscription: AnyCancellable? - - @discardableResult - static func install( - tabManager: TabManager, - groupId: UUID, - knownIds: Set, - placement: WorkspaceGroupNewPlacement, - referenceWorkspaceId: UUID? - ) -> UUID { - let key = ObjectIdentifier(tabManager) - pending[key]?.dispose() - let watcher = ConfiguredGroupActionAsyncWorkspaceObserver( - tabManager: tabManager, - groupId: groupId, - placement: placement, - referenceWorkspaceId: referenceWorkspaceId, - knownIds: knownIds - ) - pending[key] = watcher - watcher.subscription = tabManager.tabsPublisher - .receive(on: DispatchQueue.main) - .sink { [weak watcher] tabs in - watcher?.checkForNewWorkspace(in: tabs) - } - return watcher.id - } - - static func disposePending(tabManager: TabManager, observerId: UUID) { - let key = ObjectIdentifier(tabManager) - guard pending[key]?.id == observerId else { return } - pending[key]?.dispose() - } - - static func finishPending(tabManager: TabManager, observerId: UUID, workspaceId: UUID?) { - let key = ObjectIdentifier(tabManager) - guard let watcher = pending[key], watcher.id == observerId else { return } - watcher.finish(workspaceId: workspaceId) - } - - private init( - tabManager: TabManager, - groupId: UUID, - placement: WorkspaceGroupNewPlacement, - referenceWorkspaceId: UUID?, - knownIds: Set - ) { - self.tabManager = tabManager - self.storedKey = ObjectIdentifier(tabManager) - self.groupId = groupId - self.placement = placement - self.referenceWorkspaceId = referenceWorkspaceId - self.knownIds = knownIds - } - - private func checkForNewWorkspace(in tabs: [Workspace]) { - guard let tabManager else { dispose(); return } - guard tabManager.workspaceGroups.contains(where: { $0.id == groupId }) else { - dispose() - return - } - for tab in tabs where !knownIds.contains(tab.id) { - tabManager.addWorkspaceToGroup( - workspaceId: tab.id, - groupId: groupId, - placement: placement, - referenceWorkspaceId: referenceWorkspaceId - ) - dispose() - return - } - } - - private func finish(workspaceId: UUID?) { - defer { dispose() } - guard let workspaceId, let tabManager else { return } - guard tabManager.workspaceGroups.contains(where: { $0.id == groupId }) else { return } - guard tabManager.tabs.contains(where: { $0.id == workspaceId }) else { return } - tabManager.addWorkspaceToGroup( - workspaceId: workspaceId, - groupId: groupId, - placement: placement, - referenceWorkspaceId: referenceWorkspaceId - ) - } - - private func dispose() { - subscription?.cancel() - subscription = nil - // Remove by the key recorded at install time. The weak `tabManager` - // may already be nil here (window closed mid-watch), and walking it - // would silently leak the entry in the static `pending` dictionary - // for the rest of the app session. - Self.pending.removeValue(forKey: storedKey) - } -} - - @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation, NSMenuDelegate, CmuxConfigStoreReloadEnvironment { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 8a181e67eac0..21102cc08fa9 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -933,6 +933,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A91C0D100000000000000002 /* CompletedRestoredAgentInputRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91C0D100000000000000001 /* CompletedRestoredAgentInputRoutingTests.swift */; }; 3023A1013023A1013023A101 /* ConfigSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3023B1013023B1013023B101 /* ConfigSettingsView.swift */; }; 3023A1003023A1003023A100 /* ConfigSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3023B1003023B1003023B100 /* ConfigSource.swift */; }; + E7E100000000000000000001 /* ConfiguredGroupActionAsyncWorkspaceObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7E100000000000000000002 /* ConfiguredGroupActionAsyncWorkspaceObserver.swift */; }; D1F0A01100000000000000B1 /* ConnectivityInvalidationSubscriberCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F0A01100000000000000B2 /* ConnectivityInvalidationSubscriberCoordinator.swift */; }; C0DE43000000000000000005 /* ContentView+AgentChatCommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE43000000000000000006 /* ContentView+AgentChatCommandPalette.swift */; }; C0DEA7710000000000000001 /* ContentView+AuthCommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEA7710000000000000002 /* ContentView+AuthCommandPalette.swift */; }; @@ -3715,6 +3716,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A91C0D100000000000000001 /* CompletedRestoredAgentInputRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletedRestoredAgentInputRoutingTests.swift; sourceTree = ""; }; 3023B1013023B1013023B101 /* ConfigSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings/ConfigSettingsView.swift; sourceTree = ""; }; 3023B1003023B1003023B100 /* ConfigSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings/ConfigSource.swift; sourceTree = ""; }; + E7E100000000000000000002 /* ConfiguredGroupActionAsyncWorkspaceObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App/ConfiguredGroupActionAsyncWorkspaceObserver.swift"; sourceTree = ""; }; D1F0A01100000000000000B2 /* ConnectivityInvalidationSubscriberCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityInvalidationSubscriberCoordinator.swift; sourceTree = ""; }; C0DE43000000000000000006 /* ContentView+AgentChatCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+AgentChatCommandPalette.swift"; sourceTree = ""; }; C0DEA7710000000000000002 /* ContentView+AuthCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+AuthCommandPalette.swift"; sourceTree = ""; }; @@ -6871,6 +6873,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E7D100000000000000000002 /* CmuxTypingTiming.swift */, E7D100000000000000000004 /* CmuxMainRunLoopStallMonitor.swift */, E7D100000000000000000006 /* CmuxMainThreadTurnProfiler.swift */, + E7E100000000000000000002 /* ConfiguredGroupActionAsyncWorkspaceObserver.swift */, C51A740000000000000000A2 /* AppDelegate+SimulatorShortcutRouting.swift */, D35B00000000000000000011 /* TerminalController+BrowserDesignMode.swift */, D35B00000000000000000014 /* TerminalController+AgentPromptDelivery.swift */, @@ -9425,6 +9428,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C1713002C1713002C1713002 /* CommandPaletteShortcutRouting.swift in Sources */, 3023A1013023A1013023A101 /* ConfigSettingsView.swift in Sources */, 3023A1003023A1003023A100 /* ConfigSource.swift in Sources */, + E7E100000000000000000001 /* ConfiguredGroupActionAsyncWorkspaceObserver.swift in Sources */, D1F0A01100000000000000B1 /* ConnectivityInvalidationSubscriberCoordinator.swift in Sources */, C0DE43000000000000000005 /* ContentView+AgentChatCommandPalette.swift in Sources */, C0DEA7710000000000000001 /* ContentView+AuthCommandPalette.swift in Sources */,