diff --git a/CLAUDE.md b/CLAUDE.md index 9e22303e..8a8e4e0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,6 +128,10 @@ This makes it visible in the GitHub PR UI (Commits tab, check statuses) that the - **Custom UTTypes** for drag-and-drop must be declared in `Resources/Info.plist` under `UTExportedTypeDeclarations` (e.g. `com.splittabbar.tabtransfer`, `com.cmux.sidebar-tab-reorder`). - Do not add an app-level display link or manual `ghostty_surface_draw` loop; rely on Ghostty wakeups/renderer to avoid typing lag. +- **Typing-latency-sensitive paths** (read carefully before touching these areas): + - `WindowTerminalHostView.hitTest()` in `TerminalWindowPortal.swift`: called on every event including keyboard. All divider/sidebar/drag routing is gated to pointer events only. Do not add work outside the `isPointerEvent` guard. + - `TabItemView` in `ContentView.swift`: uses `Equatable` conformance + `.equatable()` to skip body re-evaluation during typing. Do not add `@EnvironmentObject`, `@ObservedObject` (besides `tab`), or `@Binding` properties without updating the `==` function. Do not remove `.equatable()` from the ForEach call site. Do not read `tabManager` or `notificationStore` in the body; use the precomputed `let` parameters instead. + - `TerminalSurface.forceRefresh()` in `GhosttyTerminalView.swift`: called on every keystroke. Do not add allocations, file I/O, or formatting here. - **Terminal find layering contract:** `SurfaceSearchOverlay` must be mounted from `GhosttySurfaceScrollView` in `Sources/GhosttyTerminalView.swift` (AppKit portal layer), not from SwiftUI panel containers such as `Sources/Panels/TerminalPanelView.swift`. Portal-hosted terminal views can sit above SwiftUI during split/workspace churn. - **Submodule safety:** When modifying a submodule (ghostty, vendor/bonsplit, etc.), always push the submodule commit to its remote `main` branch BEFORE committing the updated pointer in the parent repo. Never commit on a detached HEAD or temporary branch — the commit will be orphaned and lost. Verify with: `cd && git merge-base --is-ancestor HEAD origin/main`. - **All user-facing strings must be localized.** Use `String(localized: "key.name", defaultValue: "English text")` for every string shown in the UI (labels, buttons, menus, dialogs, tooltips, error messages). Keys go in `Resources/Localizable.xcstrings` with translations for all supported languages (currently English and Japanese). Never use bare string literals in SwiftUI `Text()`, `Button()`, alert titles, etc. diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 61272b0c..8571d18c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -788,6 +788,23 @@ } } }, + "debug.menu.openStressWorkspacesWithLoadedSurfaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Stress Workspaces and Load All Terminals" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "負荷テスト用ワークスペースを開いてすべてのターミナルを読み込む" + } + } + } + }, "debug.devBuildBanner.title": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 73831654..da65e01b 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9,6 +9,351 @@ import Combine import ObjectiveC.runtime import Darwin +#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 } + dlog("typing.delay path=\(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 path=\(path) elapsedMs=\(format(elapsedMs))" + if let event { + line += " \(eventFields(event))" + if let delayMs { + line += " delayMs=\(format(delayMs))" + } + } + if let extra, !extra.isEmpty { + line += " \(extra)" + } + dlog(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 path=\(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)" + } + dlog(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" + dlog( + "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: " ") + + dlog( + "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 + enum FinderServicePathResolver { private static func canonicalDirectoryPath(_ path: String) -> String { guard path.count > 1 else { return path } @@ -1594,6 +1939,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } method_exchangeImplementations(originalMethod, swizzledMethod) }() + private static let didInstallApplicationSendEventSwizzle: Void = { + let targetClass: AnyClass = NSApplication.self + let originalSelector = #selector(NSApplication.sendEvent(_:)) + let swizzledSelector = #selector(NSApplication.cmux_applicationSendEvent(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() #if DEBUG private var didSetupJumpUnreadUITest = false @@ -1650,6 +2005,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var didAttemptStartupSessionRestore = false private var isApplyingStartupSessionRestore = false private var sessionAutosaveTimer: DispatchSourceTimer? + private var sessionAutosaveTickInFlight = false + private var sessionAutosaveDeferredRetryPending = false private var socketListenerHealthTimer: DispatchSourceTimer? private var socketListenerHealthCheckInFlight = false private static let socketListenerHealthCheckInterval: DispatchTimeInterval = .seconds(2) @@ -1668,6 +2025,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private var lastSessionAutosaveFingerprint: Int? private var lastSessionAutosavePersistedAt: Date = .distantPast + private var lastTypingActivityAt: TimeInterval = 0 private var didHandleExplicitOpenIntentAtStartup = false private var isTerminatingApp = false private var didInstallLifecycleSnapshotObservers = false @@ -1681,6 +2039,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var commandPaletteSnapshotByWindowId: [UUID: CommandPaletteDebugSnapshot] = [:] private static let commandPaletteRequestGraceInterval: TimeInterval = 1.25 private static let commandPalettePendingOpenMaxAge: TimeInterval = 8.0 + private static let sessionAutosaveTypingQuietPeriod: TimeInterval = 0.65 var updateViewModel: UpdateViewModel { updateController.viewModel @@ -1753,6 +2112,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG writeUITestDiagnosticsIfNeeded(stage: "didFinishLaunching") + CmuxMainRunLoopStallMonitor.shared.installIfNeeded() + CmuxMainThreadTurnProfiler.shared.installIfNeeded() DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in self?.writeUITestDiagnosticsIfNeeded(stage: "after1s") } @@ -2474,28 +2835,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent Self.shouldRunSessionAutosaveTick(isTerminatingApp: self.isTerminatingApp) else { return } - let now = Date() - let autosaveFingerprint = self.sessionAutosaveFingerprint(includeScrollback: false) - if Self.shouldSkipSessionAutosaveForUnchangedFingerprint( - isTerminatingApp: self.isTerminatingApp, - includeScrollback: false, - previousFingerprint: self.lastSessionAutosaveFingerprint, - currentFingerprint: autosaveFingerprint, - lastPersistedAt: self.lastSessionAutosavePersistedAt, - now: now - ) { -#if DEBUG - dlog("session.save.skipped reason=unchanged_autosave_fingerprint includeScrollback=0") -#endif - return - } - - _ = self.saveSessionSnapshot(includeScrollback: false) - self.updateSessionAutosaveSaveState( - includeScrollback: false, - persistedAt: now, - fingerprint: autosaveFingerprint - ) + self.runSessionAutosaveTick(source: "timer") } sessionAutosaveTimer = timer timer.resume() @@ -2504,6 +2844,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func stopSessionAutosaveTimer() { sessionAutosaveTimer?.cancel() sessionAutosaveTimer = nil + sessionAutosaveTickInFlight = false + sessionAutosaveDeferredRetryPending = false } private func installLifecycleSnapshotObserversIfNeeded() { @@ -2719,6 +3061,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent isTerminatingApp: isTerminatingApp, includeScrollback: includeScrollback ) +#if DEBUG + let timingStart = CmuxTypingTiming.start() + defer { + CmuxTypingTiming.logDuration( + path: "session.saveSnapshot", + startedAt: timingStart, + extra: "includeScrollback=\(includeScrollback ? 1 : 0) removeWhenEmpty=\(removeWhenEmpty ? 1 : 0) sync=\(writeSynchronously ? 1 : 0)" + ) + } +#endif guard let snapshot = buildSessionSnapshot(includeScrollback: includeScrollback) else { persistSessionSnapshot( @@ -2770,6 +3122,113 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent !isTerminatingApp } + private func remainingSessionAutosaveTypingQuietPeriod( + nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime + ) -> TimeInterval? { + guard lastTypingActivityAt > 0 else { return nil } + let elapsed = nowUptime - lastTypingActivityAt + guard elapsed < Self.sessionAutosaveTypingQuietPeriod else { return nil } + return Self.sessionAutosaveTypingQuietPeriod - elapsed + } + + private func scheduleDeferredSessionAutosaveRetry(after delay: TimeInterval) { + guard delay.isFinite, delay > 0 else { return } + guard !sessionAutosaveDeferredRetryPending else { return } + sessionAutosaveDeferredRetryPending = true + sessionPersistenceQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.sessionAutosaveDeferredRetryPending = false + self.runSessionAutosaveTick(source: "typingQuietRetry") + } + } + } + + private func runSessionAutosaveTick(source: String) { + guard Self.shouldRunSessionAutosaveTick(isTerminatingApp: isTerminatingApp) else { return } + guard !sessionAutosaveTickInFlight else { return } + if let remainingQuietPeriod = remainingSessionAutosaveTypingQuietPeriod() { +#if DEBUG + dlog( + "session.save.skipped reason=typing_recent includeScrollback=0 source=\(source) " + + "retryMs=\(Int((remainingQuietPeriod * 1000).rounded()))" + ) +#endif + scheduleDeferredSessionAutosaveRetry(after: remainingQuietPeriod) + return + } + + sessionAutosaveTickInFlight = true +#if DEBUG + let timingStart = CmuxTypingTiming.start() + let phaseStart = ProcessInfo.processInfo.systemUptime + var fingerprintMs: Double = 0 + var saveMs: Double = 0 + defer { + sessionAutosaveTickInFlight = false + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseStart) * 1000.0 + CmuxTypingTiming.logBreakdown( + path: "session.autosaveTick.phase", + totalMs: totalMs, + thresholdMs: 2.0, + parts: [ + ("fingerprintMs", fingerprintMs), + ("saveMs", saveMs), + ], + extra: "source=\(source)" + ) + CmuxTypingTiming.logDuration( + path: "session.autosaveTick", + startedAt: timingStart, + extra: "source=\(source)" + ) + } +#else + defer { sessionAutosaveTickInFlight = false } +#endif + + let now = Date() +#if DEBUG + let fingerprintStart = ProcessInfo.processInfo.systemUptime +#endif + let autosaveFingerprint = sessionAutosaveFingerprint(includeScrollback: false) +#if DEBUG + fingerprintMs = (ProcessInfo.processInfo.systemUptime - fingerprintStart) * 1000.0 +#endif + if Self.shouldSkipSessionAutosaveForUnchangedFingerprint( + isTerminatingApp: isTerminatingApp, + includeScrollback: false, + previousFingerprint: lastSessionAutosaveFingerprint, + currentFingerprint: autosaveFingerprint, + lastPersistedAt: lastSessionAutosavePersistedAt, + now: now + ) { +#if DEBUG + dlog( + "session.save.skipped reason=unchanged_autosave_fingerprint includeScrollback=0 source=\(source)" + ) +#endif + return + } + +#if DEBUG + let saveStart = ProcessInfo.processInfo.systemUptime +#endif + _ = saveSessionSnapshot(includeScrollback: false) +#if DEBUG + saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0 +#endif + updateSessionAutosaveSaveState( + includeScrollback: false, + persistedAt: now, + fingerprint: autosaveFingerprint + ) + } + + fileprivate func recordTypingActivity() { + lastTypingActivityAt = ProcessInfo.processInfo.systemUptime + } + nonisolated static func shouldWriteSessionSnapshotSynchronously( isTerminatingApp: Bool, includeScrollback: Bool @@ -5193,6 +5652,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private let debugStressPaneCount = 4 private let debugStressTabsPerPane = 4 private let debugStressYieldInterval = 4 + private let debugStressSurfaceLoadTimeoutSeconds: TimeInterval = 10.0 + private let debugStressSurfaceLoadPollNanoseconds: UInt64 = 25_000_000 @objc func openDebugScrollbackTab(_ sender: Any?) { guard let tabManager else { return } @@ -5305,14 +5766,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } let creationElapsedMs = (ProcessInfo.processInfo.systemUptime - totalStart) * 1000.0 - let primeStats = await self.primeDebugStressWorkspacesForSurfaceLoad(created) - // Avoid synchronous "load all surfaces" waiting in this command path. - // Waiting for every background surface to be ready creates sustained - // main-actor churn and can starve typing responsiveness. - let loadStats = DebugStressSurfaceLoadStats( - pendingSurfaces: self.pendingDebugTerminalSurfaceCount(in: created), - attempts: 0, - elapsedMs: 0 + let loadStats = await self.loadAllDebugStressWorkspacesForTerminalSurfaceReadiness( + created, + tabManager: tabManager ) let totalElapsedMs = (ProcessInfo.processInfo.systemUptime - totalStart) * 1000.0 let avgWorkspaceMs = created.isEmpty ? 0 : (cumulativeWorkspaceMs / Double(created.count)) @@ -5326,15 +5782,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent dlog( "stress.setup.done createMs=\(String(format: "%.2f", creationElapsedMs)) " + - "primeMs=\(String(format: "%.2f", primeStats.elapsedMs)) primedTabs=\(primeStats.activatedTabs) " + - "waitMs=\(String(format: "%.2f", loadStats.elapsedMs)) totalMs=\(String(format: "%.2f", totalElapsedMs)) " + + "loadMs=\(String(format: "%.2f", loadStats.elapsedMs)) loadedPanels=\(loadStats.loadedPanels) " + + "loadFailures=\(loadStats.failedPanels) totalMs=\(String(format: "%.2f", totalElapsedMs)) " + "workspaceAvgMs=\(String(format: "%.2f", avgWorkspaceMs)) workspaceWorstMs=\(String(format: "%.2f", worstWorkspaceMs)) " + "workspaceSlowCount=\(slowWorkspaceCount) waitAttempts=\(loadStats.attempts) " + "pendingSurfaces=\(loadStats.pendingSurfaces) expectedSurfaces=\(expectedSurfaceCount)" ) NSLog( - "Debug stress workspaces: created=%d panesPerWorkspace=%d tabsPerPane=%d expectedSurfaces=%d layoutFailures=%d pendingSurfaces=%d createMs=%.2f primeMs=%.2f primedTabs=%d waitMs=%.2f totalMs=%.2f workspaceAvgMs=%.2f workspaceWorstMs=%.2f waitAttempts=%d", + "Debug stress workspaces: created=%d panesPerWorkspace=%d tabsPerPane=%d expectedSurfaces=%d layoutFailures=%d pendingSurfaces=%d createMs=%.2f loadMs=%.2f loadedPanels=%d failedPanels=%d totalMs=%.2f workspaceAvgMs=%.2f workspaceWorstMs=%.2f waitAttempts=%d", self.debugStressWorkspaceCount, self.debugStressPaneCount, self.debugStressTabsPerPane, @@ -5342,9 +5798,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent layoutFailures, loadStats.pendingSurfaces, creationElapsedMs, - primeStats.elapsedMs, - primeStats.activatedTabs, loadStats.elapsedMs, + loadStats.loadedPanels, + loadStats.failedPanels, totalElapsedMs, avgWorkspaceMs, worstWorkspaceMs, @@ -5353,40 +5809,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - private struct DebugStressSurfacePrimeStats { - let activatedTabs: Int - let elapsedMs: Double - } - - private func primeDebugStressWorkspacesForSurfaceLoad( - _ workspaces: [Workspace] - ) async -> DebugStressSurfacePrimeStats { - guard !workspaces.isEmpty else { - return DebugStressSurfacePrimeStats(activatedTabs: 0, elapsedMs: 0) - } - - let primeStart = ProcessInfo.processInfo.systemUptime - var activatedTabs = 0 - - for (index, workspace) in workspaces.enumerated() { - activatedTabs += workspace.panels.values.reduce(into: 0) { count, panel in - if panel is TerminalPanel { - count += 1 - } - } - - if (index + 1) % debugStressYieldInterval == 0 || index == workspaces.count - 1 { - dlog( - "stress.setup.mount idx=\(index + 1)/\(workspaces.count) activatedTabs=\(activatedTabs)" - ) - await Task.yield() - } - } - - let elapsedMs = (ProcessInfo.processInfo.systemUptime - primeStart) * 1000.0 - return DebugStressSurfacePrimeStats(activatedTabs: activatedTabs, elapsedMs: elapsedMs) - } - private func configureDebugStressWorkspaceLayout( _ workspace: Workspace, paneCount: Int, @@ -5445,10 +5867,217 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private struct DebugStressSurfaceLoadStats { let pendingSurfaces: Int + let loadedPanels: Int + let failedPanels: Int let attempts: Int let elapsedMs: Double } + private struct DebugStressTerminalLoadTarget { + let workspace: Workspace + let paneId: PaneID + let tabId: TabID + let panelId: UUID + } + + private func loadAllDebugStressWorkspacesForTerminalSurfaceReadiness( + _ workspaces: [Workspace], + tabManager: TabManager + ) async -> DebugStressSurfaceLoadStats { + guard !workspaces.isEmpty else { + return DebugStressSurfaceLoadStats( + pendingSurfaces: 0, + loadedPanels: 0, + failedPanels: 0, + attempts: 0, + elapsedMs: 0 + ) + } + + let retainedWorkspaceIds = Set(workspaces.map(\.id)) + let loadStart = ProcessInfo.processInfo.systemUptime + var attempts = 0 + var queuedTargets: [DebugStressTerminalLoadTarget] = [] + queuedTargets.reserveCapacity( + workspaces.count * debugStressPaneCount * debugStressTabsPerPane + ) + + tabManager.retainDebugWorkspaceLoads(for: retainedWorkspaceIds) + defer { tabManager.releaseDebugWorkspaceLoads(for: retainedWorkspaceIds) } + + await Task.yield() + forceDebugStressVisibleLayout() + let mountedWorkspaceCount = await waitForDebugStressMountedWorkspaces(workspaces) + + for (workspaceIndex, workspace) in workspaces.enumerated() { + for paneId in workspace.bonsplitController.allPaneIds { + for tab in workspace.bonsplitController.tabs(inPane: paneId) { + guard let panelId = workspace.panelIdFromSurfaceId(tab.id), + workspace.panel(for: tab.id) is TerminalPanel else { + continue + } + if workspace.preloadTerminalPanelForDebugStress(tabId: tab.id, inPane: paneId) != nil { + queuedTargets.append( + DebugStressTerminalLoadTarget( + workspace: workspace, + paneId: paneId, + tabId: tab.id, + panelId: panelId + ) + ) + attempts += 1 + } + } + } + + dlog( + "stress.setup.queue workspace=\(workspaceIndex + 1)/\(workspaces.count) " + + "mounted=\(mountedWorkspaceCount)/\(workspaces.count) queued=\(queuedTargets.count)" + ) + await Task.yield() + } + + let waitResult = await waitForDebugStressTerminalPanelSurfaces(queuedTargets) + attempts += waitResult.attempts + let failedPanels = waitResult.pendingTargets.count + let loadedPanels = max(0, queuedTargets.count - failedPanels) + for target in waitResult.pendingTargets { + dlog( + "stress.setup.surfaceTimeout workspace=\(target.workspace.id.uuidString.prefix(5)) " + + "panel=\(target.panelId.uuidString.prefix(5)) pane=\(target.paneId.id.uuidString.prefix(5))" + ) + } + + let elapsedMs = (ProcessInfo.processInfo.systemUptime - loadStart) * 1000.0 + return DebugStressSurfaceLoadStats( + pendingSurfaces: pendingDebugTerminalSurfaceCount(in: workspaces), + loadedPanels: loadedPanels, + failedPanels: failedPanels, + attempts: attempts, + elapsedMs: elapsedMs + ) + } + + private func waitForDebugStressMountedWorkspaces(_ workspaces: [Workspace]) async -> Int { + guard !workspaces.isEmpty else { return 0 } + var mountedWorkspaceCount = 0 + let selectedWorkspaceId = tabManager?.selectedTabId + + for _ in 0..<4 { + forceDebugStressVisibleLayout() + mountedWorkspaceCount = 0 + for workspace in workspaces { + if workspace.id == selectedWorkspaceId { + workspace.scheduleDebugStressTerminalGeometryReconcile() + } else { + workspace.requestBackgroundTerminalSurfaceStartIfNeeded() + } + if workspace.panels.values.contains(where: { panel in + guard let terminalPanel = panel as? TerminalPanel else { return false } + return terminalPanel.hostedView.superview != nil || terminalPanel.surface.surface != nil + }) { + mountedWorkspaceCount += 1 + } + } + if mountedWorkspaceCount == workspaces.count { + break + } + await Task.yield() + try? await Task.sleep(nanoseconds: debugStressSurfaceLoadPollNanoseconds) + } + + dlog("stress.setup.mount mounted=\(mountedWorkspaceCount)/\(workspaces.count)") + return mountedWorkspaceCount + } + + private func waitForDebugStressTerminalPanelSurfaces( + _ targets: [DebugStressTerminalLoadTarget] + ) async -> (pendingTargets: [DebugStressTerminalLoadTarget], attempts: Int) { + guard !targets.isEmpty else { + return (pendingTargets: [], attempts: 0) + } + + let deadline = Date().addingTimeInterval(debugStressSurfaceLoadTimeoutSeconds) + let selectedWorkspaceId = tabManager?.selectedTabId + var pendingTargets = targets + var attempts = 0 + var pass = 0 + + while !pendingTargets.isEmpty, Date() < deadline { + pass += 1 + forceDebugStressVisibleLayout() + + var nextPending: [DebugStressTerminalLoadTarget] = [] + nextPending.reserveCapacity(pendingTargets.count) + var restartedThisPass = 0 + + for (targetIndex, target) in pendingTargets.enumerated() { + guard let terminalPanel = target.workspace.panel(for: target.tabId) as? TerminalPanel else { + nextPending.append(target) + continue + } + if terminalPanel.surface.surface != nil { + continue + } + + let hostedView = terminalPanel.hostedView + let shouldReconcileVisibleSelection = + target.workspace.id == selectedWorkspaceId && + hostedView.window != nil && + hostedView.superview != nil + + if shouldReconcileVisibleSelection { + target.workspace.scheduleDebugStressTerminalGeometryReconcile() + if pass == 1 || (pass % 4) == 0 { + if target.workspace.preloadTerminalPanelForDebugStress( + tabId: target.tabId, + inPane: target.paneId + ) != nil { + restartedThisPass += 1 + attempts += 1 + } + } else { + terminalPanel.requestViewReattach() + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + } + } else { + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + } + nextPending.append(target) + + if ((targetIndex + 1) % 16) == 0 { + await Task.yield() + } + } + + if nextPending.count != pendingTargets.count || restartedThisPass > 0 || pass == 1 || (pass % 8) == 0 { + dlog( + "stress.setup.await pass=\(pass) pending=\(nextPending.count) " + + "restarted=\(restartedThisPass)" + ) + } + try? await Task.sleep(nanoseconds: debugStressSurfaceLoadPollNanoseconds) + pendingTargets = nextPending + } + + return (pendingTargets: pendingTargets, attempts: attempts) + } + + private func forceDebugStressVisibleLayout() { + if let activeWindow = NSApp.keyWindow ?? NSApp.mainWindow { + activeWindow.contentView?.layoutSubtreeIfNeeded() + activeWindow.contentView?.displayIfNeeded() + return + } + + for (windowIndex, window) in NSApp.windows.enumerated() { + window.contentView?.layoutSubtreeIfNeeded() + if windowIndex == 0 { + window.contentView?.displayIfNeeded() + } + } + } + private func pendingDebugTerminalSurfaceCount(in workspaces: [Workspace]) -> Int { var pending = 0 for workspace in workspaces { @@ -6737,6 +7366,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif private func installWindowResponderSwizzles() { + _ = Self.didInstallApplicationSendEventSwizzle _ = Self.didInstallWindowKeyEquivalentSwizzle _ = Self.didInstallWindowFirstResponderSwizzle _ = Self.didInstallWindowSendEventSwizzle @@ -6748,13 +7378,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard let self else { return event } if event.type == .keyDown { #if DEBUG - if (ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1" - || UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe")), - event.timestamp > 0 { - let delayMs = max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000) - let delayText = String(format: "%.2f", delayMs) - dlog("key.latency path=appMonitor ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)") - } + let phaseTotalStart = ProcessInfo.processInfo.systemUptime + let preludeStart = ProcessInfo.processInfo.systemUptime + var preludeMs: Double = 0 + var shortcutMs: Double = 0 + CmuxTypingTiming.logEventDelay(path: "appMonitor", event: event) let shortcutMonitorTraceEnabled = ProcessInfo.processInfo.environment["CMUX_SHORTCUT_MONITOR_TRACE"] == "1" || UserDefaults.standard.bool(forKey: "cmuxShortcutMonitorTrace") @@ -6767,16 +7395,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let probeKind = self.developerToolsShortcutProbeKind(event: event) { self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event) } + preludeMs = (ProcessInfo.processInfo.systemUptime - preludeStart) * 1000.0 + let shortcutTimingStart = CmuxTypingTiming.start() #endif let shortcutStart = ProcessInfo.processInfo.systemUptime let handledByShortcut = self.handleCustomShortcut(event: event) #if DEBUG + shortcutMs = (ProcessInfo.processInfo.systemUptime - shortcutStart) * 1000.0 + CmuxTypingTiming.logDuration( + path: "appMonitor.handleCustomShortcut", + startedAt: shortcutTimingStart, + event: event, + extra: "handled=\(handledByShortcut ? 1 : 0)" + ) let shortcutElapsedMs = (ProcessInfo.processInfo.systemUptime - shortcutStart) * 1000.0 self.logSlowShortcutMonitorLatencyIfNeeded( event: event, handledByShortcut: handledByShortcut, elapsedMs: shortcutElapsedMs ) + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 + CmuxTypingTiming.logBreakdown( + path: "appMonitor.phase", + totalMs: totalMs, + event: event, + thresholdMs: 0.75, + parts: [ + ("preludeMs", preludeMs), + ("shortcutMs", shortcutMs), + ], + extra: "handled=\(handledByShortcut ? 1 : 0)" + ) #endif if handledByShortcut { #if DEBUG @@ -10022,6 +10671,36 @@ private final class CmuxFieldEditorOwningWebViewBox: NSObject { } } +private extension NSApplication { + @objc func cmux_applicationSendEvent(_ event: NSEvent) { +#if DEBUG + let typingTimingStart = event.type == .keyDown ? CmuxTypingTiming.start() : nil + let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 + if event.type == .keyDown { + CmuxTypingTiming.logEventDelay(path: "app.sendEvent", event: event) + } + defer { + if event.type == .keyDown { + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 + CmuxTypingTiming.logBreakdown( + path: "app.sendEvent.phase", + totalMs: totalMs, + event: event, + thresholdMs: 1.0, + parts: [("dispatchMs", totalMs)] + ) + CmuxTypingTiming.logDuration( + path: "app.sendEvent", + startedAt: typingTimingStart, + event: event + ) + } + } +#endif + cmux_applicationSendEvent(event) + } +} + private extension NSWindow { @objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool { if cmuxIsWindowFirstResponderBypassActive() { @@ -10120,12 +10799,70 @@ private extension NSWindow { } @objc func cmux_sendEvent(_ event: NSEvent) { +#if DEBUG + let typingTimingStart = event.type == .keyDown ? CmuxTypingTiming.start() : nil + let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 + var contextSetupMs: Double = 0 + var folderGuardMs: Double = 0 + var originalDispatchMs: Double = 0 + let typingTimingExtra: String? = { + guard event.type == .keyDown else { return nil } + let responderWebView = self.firstResponder.flatMap { + Self.cmuxOwningWebView(for: $0, in: self, event: event) + } + let hitWebView = Self.cmuxHitViewForEventDispatch(in: self, event: event).flatMap { + Self.cmuxOwningWebView(for: $0) + } + let firstResponderType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + return "browser=\((responderWebView != nil || hitWebView != nil) ? 1 : 0) firstResponder=\(firstResponderType)" + }() + if event.type == .keyDown { + CmuxTypingTiming.logEventDelay(path: "window.sendEvent", event: event) + } +#endif + // recordTypingActivity must run in all builds so runSessionAutosaveTick + // can honor the typing quiet period in release. + if event.type == .keyDown { + AppDelegate.shared?.recordTypingActivity() + } +#if DEBUG + defer { + if event.type == .keyDown { + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 + CmuxTypingTiming.logBreakdown( + path: "window.sendEvent.phase", + totalMs: totalMs, + event: event, + thresholdMs: 1.0, + parts: [ + ("contextSetupMs", contextSetupMs), + ("folderGuardMs", folderGuardMs), + ("originalDispatchMs", originalDispatchMs), + ], + extra: typingTimingExtra + ) + CmuxTypingTiming.logDuration( + path: "window.sendEvent", + startedAt: typingTimingStart, + event: event, + extra: typingTimingExtra + ) + } + } + let contextSetupStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 +#endif let previousContextEvent = cmuxFirstResponderGuardCurrentEventContext let previousContextHitView = cmuxFirstResponderGuardHitViewContext let previousContextWindowNumber = cmuxFirstResponderGuardContextWindowNumber cmuxFirstResponderGuardCurrentEventContext = event cmuxFirstResponderGuardHitViewContext = Self.cmuxHitViewForEventDispatch(in: self, event: event) cmuxFirstResponderGuardContextWindowNumber = self.windowNumber +#if DEBUG + if event.type == .keyDown { + contextSetupMs = (ProcessInfo.processInfo.systemUptime - contextSetupStart) * 1000.0 + } + let folderGuardStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 +#endif defer { cmuxFirstResponderGuardCurrentEventContext = previousContextEvent cmuxFirstResponderGuardHitViewContext = previousContextHitView @@ -10134,9 +10871,24 @@ private extension NSWindow { guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event), let contentView = self.contentView else { +#if DEBUG + if event.type == .keyDown { + folderGuardMs = (ProcessInfo.processInfo.systemUptime - folderGuardStart) * 1000.0 + let originalDispatchStart = ProcessInfo.processInfo.systemUptime + cmux_sendEvent(event) + originalDispatchMs = (ProcessInfo.processInfo.systemUptime - originalDispatchStart) * 1000.0 + return + } +#endif cmux_sendEvent(event) return } +#if DEBUG + if event.type == .keyDown { + folderGuardMs = (ProcessInfo.processInfo.systemUptime - folderGuardStart) * 1000.0 + } + let originalDispatchStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 +#endif let contentPoint = contentView.convert(event.locationInWindow, from: nil) let hitView = contentView.hitTest(contentPoint) @@ -10151,6 +10903,11 @@ private extension NSWindow { #endif cmux_sendEvent(event) +#if DEBUG + if event.type == .keyDown { + originalDispatchMs = (ProcessInfo.processInfo.systemUptime - originalDispatchStart) * 1000.0 + } +#endif if previousMovableState { isMovable = previousMovableState @@ -10163,6 +10920,14 @@ private extension NSWindow { @objc func cmux_performKeyEquivalent(with event: NSEvent) -> Bool { #if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + defer { + CmuxTypingTiming.logDuration( + path: "window.performKeyEquivalent", + startedAt: typingTimingStart, + event: event + ) + } let frType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" dlog("performKeyEquiv: \(Self.keyDescription(event)) fr=\(frType)") #endif diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 13ed236e..77309180 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1527,6 +1527,11 @@ struct ContentView: View { static let workspaceShouldPin = "workspace.shouldPin" static let workspaceHasPullRequests = "workspace.hasPullRequests" static let workspaceHasSplits = "workspace.hasSplits" + static let workspaceHasPeers = "workspace.hasPeers" + static let workspaceHasAbove = "workspace.hasAbove" + static let workspaceHasBelow = "workspace.hasBelow" + static let workspaceHasUnread = "workspace.hasUnread" + static let workspaceHasRead = "workspace.hasRead" static let hasFocusedPanel = "panel.hasFocus" static let panelName = "panel.name" @@ -2319,6 +2324,10 @@ struct ContentView: View { reconcileMountedWorkspaceIds() }) + view = AnyView(view.onReceive(tabManager.$debugPinnedWorkspaceLoadIds) { _ in + reconcileMountedWorkspaceIds() + }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, tabId == tabManager.selectedTabId else { return } @@ -2699,7 +2708,9 @@ struct ContentView: View { let orderedTabIds = currentTabs.map { $0.id } let effectiveSelectedId = selectedId ?? tabManager.selectedTabId let handoffPinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? [] - let pinnedIds = handoffPinnedIds.union(tabManager.pendingBackgroundWorkspaceLoadIds) + let pinnedIds = handoffPinnedIds + .union(tabManager.pendingBackgroundWorkspaceLoadIds) + .union(tabManager.debugPinnedWorkspaceLoadIds) let isCycleHot = tabManager.isWorkspaceCycleHot let shouldKeepHandoffPair = isCycleHot && !handoffPinnedIds.isEmpty let baseMaxMounted = shouldKeepHandoffPair @@ -4027,6 +4038,21 @@ struct ContentView: View { CommandPaletteContextKeys.workspaceHasSplits, workspace.bonsplitController.allPaneIds.count > 1 ) + let workspaceIndex = tabManager.tabs.firstIndex { $0.id == workspace.id } + snapshot.setBool(CommandPaletteContextKeys.workspaceHasPeers, tabManager.tabs.count > 1) + snapshot.setBool(CommandPaletteContextKeys.workspaceHasAbove, (workspaceIndex ?? 0) > 0) + snapshot.setBool( + CommandPaletteContextKeys.workspaceHasBelow, + (workspaceIndex ?? tabManager.tabs.count - 1) < tabManager.tabs.count - 1 + ) + snapshot.setBool( + CommandPaletteContextKeys.workspaceHasUnread, + notificationStore.notifications.contains { $0.tabId == workspace.id && !$0.isRead } + ) + snapshot.setBool( + CommandPaletteContextKeys.workspaceHasRead, + notificationStore.notifications.contains { $0.tabId == workspace.id && $0.isRead } + ) } if let panelContext = focusedPanelContext { @@ -4320,6 +4346,86 @@ struct ContentView: View { when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.moveWorkspaceUp", + title: constant(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")), + subtitle: workspaceSubtitle, + keywords: ["workspace", "move", "up", "reorder"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }, + enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasAbove) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.moveWorkspaceDown", + title: constant(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")), + subtitle: workspaceSubtitle, + keywords: ["workspace", "move", "down", "reorder"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }, + enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasBelow) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.moveWorkspaceToTop", + title: constant(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")), + subtitle: workspaceSubtitle, + keywords: ["workspace", "move", "top", "reorder"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }, + enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasAbove) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closeOtherWorkspaces", + title: constant(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")), + subtitle: workspaceSubtitle, + keywords: ["close", "other", "workspaces", "reset", "workspace"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }, + enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasPeers) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closeWorkspacesBelow", + title: constant(String(localized: "contextMenu.closeWorkspacesBelow", defaultValue: "Close Workspaces Below")), + subtitle: workspaceSubtitle, + keywords: ["close", "below", "workspaces", "workspace"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }, + enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasBelow) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closeWorkspacesAbove", + title: constant(String(localized: "contextMenu.closeWorkspacesAbove", defaultValue: "Close Workspaces Above")), + subtitle: workspaceSubtitle, + keywords: ["close", "above", "workspaces", "workspace"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }, + enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasAbove) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.markWorkspaceRead", + title: constant(String(localized: "contextMenu.markWorkspaceRead", defaultValue: "Mark Workspace as Read")), + subtitle: workspaceSubtitle, + keywords: ["workspace", "read", "notification", "inbox"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }, + enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasUnread) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.markWorkspaceUnread", + title: constant(String(localized: "contextMenu.markWorkspaceUnread", defaultValue: "Mark Workspace as Unread")), + subtitle: workspaceSubtitle, + keywords: ["workspace", "unread", "notification", "inbox"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) }, + enablement: { $0.bool(CommandPaletteContextKeys.workspaceHasRead) } + ) + ) contributions.append( CommandPaletteCommandContribution( @@ -4796,6 +4902,43 @@ struct ContentView: View { registry.register(commandId: "palette.previousWorkspace") { tabManager.selectPreviousTab() } + registry.register(commandId: "palette.moveWorkspaceUp") { + moveSelectedWorkspace(by: -1) + } + registry.register(commandId: "palette.moveWorkspaceDown") { + moveSelectedWorkspace(by: 1) + } + registry.register(commandId: "palette.moveWorkspaceToTop") { + guard let workspace = tabManager.selectedWorkspace else { + NSSound.beep() + return + } + tabManager.moveTabsToTop([workspace.id]) + tabManager.selectWorkspace(workspace) + } + registry.register(commandId: "palette.closeOtherWorkspaces") { + closeOtherSelectedWorkspaces() + } + registry.register(commandId: "palette.closeWorkspacesBelow") { + closeSelectedWorkspacesBelow() + } + registry.register(commandId: "palette.closeWorkspacesAbove") { + closeSelectedWorkspacesAbove() + } + registry.register(commandId: "palette.markWorkspaceRead") { + guard let workspaceId = tabManager.selectedWorkspace?.id else { + NSSound.beep() + return + } + notificationStore.markRead(forTabId: workspaceId) + } + registry.register(commandId: "palette.markWorkspaceUnread") { + guard let workspaceId = tabManager.selectedWorkspace?.id else { + NSSound.beep() + return + } + notificationStore.markUnread(forTabId: workspaceId) + } registry.register(commandId: "palette.renameTab") { beginRenameTabFlow() @@ -5796,6 +5939,48 @@ struct ContentView: View { ) } + private func selectedWorkspaceIndex() -> Int? { + guard let workspace = tabManager.selectedWorkspace else { return nil } + return tabManager.tabs.firstIndex { $0.id == workspace.id } + } + + private func moveSelectedWorkspace(by delta: Int) { + guard let workspace = tabManager.selectedWorkspace, + let currentIndex = selectedWorkspaceIndex() else { return } + let targetIndex = currentIndex + delta + guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return } + _ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: targetIndex) + tabManager.selectWorkspace(workspace) + } + + private func closeWorkspaceIds(_ workspaceIds: [UUID], allowPinned: Bool) { + for workspaceId in workspaceIds { + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { continue } + guard allowPinned || !workspace.isPinned else { continue } + tabManager.closeWorkspaceWithConfirmation(workspace) + } + } + + private func closeOtherSelectedWorkspaces() { + guard let workspace = tabManager.selectedWorkspace else { return } + let workspaceIds = tabManager.tabs.compactMap { $0.id == workspace.id ? nil : $0.id } + closeWorkspaceIds(workspaceIds, allowPinned: false) + } + + private func closeSelectedWorkspacesBelow() { + guard let workspace = tabManager.selectedWorkspace, + let anchorIndex = selectedWorkspaceIndex() else { return } + let workspaceIds = tabManager.tabs.suffix(from: anchorIndex + 1).map(\.id) + closeWorkspaceIds(workspaceIds, allowPinned: false) + } + + private func closeSelectedWorkspacesAbove() { + guard let workspace = tabManager.selectedWorkspace, + let anchorIndex = selectedWorkspaceIndex() else { return } + let workspaceIds = tabManager.tabs.prefix(upTo: anchorIndex).map(\.id) + closeWorkspaceIds(workspaceIds, allowPinned: false) + } + private func beginRenameWorkspaceFlow() { guard let workspace = tabManager.selectedWorkspace else { NSSound.beep() @@ -6960,6 +7145,7 @@ struct VerticalTabsSidebar: View { @ObservedObject var updateViewModel: UpdateViewModel let onSendFeedback: () -> Void @EnvironmentObject var tabManager: TabManager + @EnvironmentObject var notificationStore: TerminalNotificationStore @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? @@ -6985,10 +7171,21 @@ struct VerticalTabsSidebar: View { LazyVStack(spacing: tabRowSpacing) { ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in TabItemView( + tabManager: tabManager, + notificationStore: notificationStore, tab: tab, index: index, + isActive: tabManager.selectedTabId == tab.id, + tabCount: tabManager.tabs.count, + unreadCount: notificationStore.unreadCount(forTabId: tab.id), + latestNotificationText: { + guard let notification = notificationStore.latestNotification(forTabId: tab.id) else { return nil } + let text = notification.body.isEmpty ? notification.title : notification.body + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + }(), rowSpacing: tabRowSpacing, - selection: $selection, + setSelectionToTabs: { selection = .tabs }, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed, @@ -6996,6 +7193,7 @@ struct VerticalTabsSidebar: View { draggedTabId: $draggedTabId, dropIndicator: $dropIndicator ) + .equatable() } } .padding(.vertical, 8) @@ -9231,14 +9429,40 @@ enum SidebarWorkspaceShortcutHintMetrics { #endif } -private struct TabItemView: View { - @EnvironmentObject var tabManager: TabManager - @EnvironmentObject var notificationStore: TerminalNotificationStore +// PERF: TabItemView is Equatable so SwiftUI skips body re-evaluation when +// the parent rebuilds with unchanged values. Without this, every TabManager +// or NotificationStore publish causes ALL tab items to re-evaluate (~18% of +// main thread during typing). If you add new properties, update == below. +// Do NOT add @EnvironmentObject or new @Binding without updating ==. +// Do NOT remove .equatable() from the ForEach call site in VerticalTabsSidebar. +private struct TabItemView: View, Equatable { + // Closures, Bindings, and object references are excluded from == + // because they're recreated every parent eval but don't affect rendering. + nonisolated static func == (lhs: TabItemView, rhs: TabItemView) -> Bool { + lhs.tab === rhs.tab && + lhs.index == rhs.index && + lhs.isActive == rhs.isActive && + lhs.tabCount == rhs.tabCount && + lhs.unreadCount == rhs.unreadCount && + lhs.latestNotificationText == rhs.latestNotificationText && + lhs.rowSpacing == rhs.rowSpacing && + lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints + } + + // Use plain references instead of @EnvironmentObject to avoid subscribing + // to ALL changes on these objects. Body reads use precomputed parameters; + // action handlers use the plain references without triggering re-evaluation. + let tabManager: TabManager + let notificationStore: TerminalNotificationStore @Environment(\.colorScheme) private var colorScheme @ObservedObject var tab: Tab let index: Int + let isActive: Bool + let tabCount: Int + let unreadCount: Int + let latestNotificationText: String? let rowSpacing: CGFloat - @Binding var selection: SidebarSelection + let setSelectionToTabs: () -> Void @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? let showsModifierShortcutHints: Bool @@ -9264,10 +9488,6 @@ private struct TabItemView: View { @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) private var activeTabIndicatorStyleRaw = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue - var isActive: Bool { - tabManager.selectedTabId == tab.id - } - var isMultiSelected: Bool { selectedTabIds.contains(tab.id) } @@ -9340,11 +9560,11 @@ private struct TabItemView: View { } private var workspaceShortcutDigit: Int? { - WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabManager.tabs.count) + WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabCount) } private var showCloseButton: Bool { - isHovering && tabManager.tabs.count > 1 && !(showsModifierShortcutHints || alwaysShowShortcutHints) + isHovering && tabCount > 1 && !(showsModifierShortcutHints || alwaysShowShortcutHints) } private var workspaceShortcutLabel: String? { @@ -9429,7 +9649,6 @@ private struct TabItemView: View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { - let unreadCount = notificationStore.unreadCount(forTabId: tab.id) if unreadCount > 0 { ZStack { Circle() @@ -10023,7 +10242,7 @@ private struct TabItemView: View { } private var accessibilityTitle: String { - String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(tabManager.tabs.count)") + String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(tabCount)") } private func moveBy(_ delta: Int) { @@ -10033,7 +10252,7 @@ private struct TabItemView: View { selectedTabIds = [tab.id] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id } tabManager.selectTab(tab) - selection = .tabs + setSelectionToTabs() } private func updateSelection() { @@ -10078,7 +10297,7 @@ private struct TabItemView: View { surfaceId: tabManager.focusedSurfaceId(for: tab.id) ) } - selection = .tabs + setSelectionToTabs() } private func contextTargetIds() -> [UUID] { @@ -10205,12 +10424,8 @@ private struct TabItemView: View { syncSelectionAfterMutation() } - private var latestNotificationText: String? { - guard let notification = notificationStore.latestNotification(forTabId: tab.id) else { return nil } - let text = notification.body.isEmpty ? notification.title : notification.body - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } + // latestNotificationText is now passed as a parameter from the parent view + // to avoid subscribing to notificationStore changes in every TabItemView. private func branchDirectoryRow( gitSummary: String?, diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index b75247ee..8acc1753 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3074,16 +3074,7 @@ final class TerminalSurface: Identifiable, ObservableObject { viewState = "NO_ATTACHED_VIEW hasSurface=\(hasSurface)" } #if DEBUG - let ts = ISO8601DateFormatter().string(from: Date()) - let line = "[\(ts)] forceRefresh: \(id) reason=\(reason) \(viewState)\n" - let logPath = "/tmp/cmux-refresh-debug.log" - if let handle = FileHandle(forWritingAtPath: logPath) { - handle.seekToEndOfFile() - handle.write(line.data(using: .utf8)!) - handle.closeFile() - } else { - FileManager.default.createFile(atPath: logPath, contents: line.data(using: .utf8)) - } + dlog("forceRefresh: \(id) reason=\(reason) \(viewState)") #endif guard let view = attachedView, let surface, @@ -4346,10 +4337,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { #if DEBUG private func recordKeyLatency(path: String, event: NSEvent) { guard Self.keyLatencyProbeEnabled else { return } - guard event.timestamp > 0 else { return } - let delayMs = max(0, (CACurrentMediaTime() - event.timestamp) * 1000) - let delayText = String(format: "%.2f", delayMs) - dlog("key.latency path=\(path) ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)") + CmuxTypingTiming.logEventDelay(path: path, event: event) } #endif @@ -4366,6 +4354,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func performKeyEquivalent(with event: NSEvent) -> Bool { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + defer { + CmuxTypingTiming.logDuration( + path: "terminal.performKeyEquivalent", + startedAt: typingTimingStart, + event: event + ) + } +#endif guard event.type == .keyDown else { return false } guard let fr = window?.firstResponder as? NSView, fr === self || fr.isDescendant(of: self) else { return false } @@ -4487,15 +4485,59 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } override func keyDown(with event: NSEvent) { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + let phaseTotalStart = ProcessInfo.processInfo.systemUptime + var ensureSurfaceMs: Double = 0 + var dismissNotificationMs: Double = 0 + var keyboardCopyModeMs: Double = 0 + var interpretMs: Double = 0 + var syncPreeditMs: Double = 0 + var ghosttySendMs: Double = 0 + var refreshMs: Double = 0 + defer { + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 + CmuxTypingTiming.logBreakdown( + path: "terminal.keyDown.phase", + totalMs: totalMs, + event: event, + thresholdMs: 1.0, + parts: [ + ("ensureSurfaceMs", ensureSurfaceMs), + ("dismissNotificationMs", dismissNotificationMs), + ("keyboardCopyModeMs", keyboardCopyModeMs), + ("interpretMs", interpretMs), + ("syncPreeditMs", syncPreeditMs), + ("ghosttySendMs", ghosttySendMs), + ("refreshMs", refreshMs), + ], + extra: "marked=\(hasMarkedText() ? 1 : 0)" + ) + CmuxTypingTiming.logDuration(path: "terminal.keyDown", startedAt: typingTimingStart, event: event) + } + let ensureSurfaceStart = ProcessInfo.processInfo.systemUptime +#endif guard let surface = ensureSurfaceReadyForInput() else { +#if DEBUG + ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0 +#endif super.keyDown(with: event) return } +#if DEBUG + ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0 +#endif if let terminalSurface { +#if DEBUG + let dismissNotificationStart = ProcessInfo.processInfo.systemUptime +#endif AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction( tabId: terminalSurface.tabId, surfaceId: terminalSurface.id ) +#if DEBUG + dismissNotificationMs = (ProcessInfo.processInfo.systemUptime - dismissNotificationStart) * 1000.0 +#endif } if event.keyCode != 53 { endFindEscapeSuppression() @@ -4503,10 +4545,19 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if shouldConsumeSuppressedFindEscape(event) { return } +#if DEBUG + let keyboardCopyModeStart = ProcessInfo.processInfo.systemUptime +#endif if handleKeyboardCopyModeIfNeeded(event, surface: surface) { +#if DEBUG + keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0 +#endif keyboardCopyModeConsumedKeyUps.insert(event.keyCode) return } +#if DEBUG + keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0 +#endif #if DEBUG recordKeyLatency(path: "keyDown", event: event) #endif @@ -4544,12 +4595,36 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let handled: Bool if text.isEmpty { keyEvent.text = nil + #if DEBUG + let ghosttySendStart = ProcessInfo.processInfo.systemUptime + handled = sendTimedGhosttyKey( + surface, + keyEvent, + path: "terminal.keyDown.ctrlGhosttySend", + event: event + ) + ghosttySendMs = (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 + #else handled = ghostty_surface_key(surface, keyEvent) + #endif } else { + #if DEBUG + let sendTimingStart = CmuxTypingTiming.start() + let ghosttySendStart = ProcessInfo.processInfo.systemUptime + #endif handled = text.withCString { ptr in keyEvent.text = ptr return ghostty_surface_key(surface, keyEvent) } + #if DEBUG + ghosttySendMs = (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 + CmuxTypingTiming.logDuration( + path: "terminal.keyDown.ctrlGhosttySend", + startedAt: sendTimingStart, + event: event, + extra: "handled=\(handled ? 1 : 0)" + ) + #endif } #if DEBUG dlog( @@ -4626,18 +4701,42 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } // Let the input system handle the event (for IME, dead keys, etc.) +#if DEBUG + let interpretTimingStart = CmuxTypingTiming.start() + let interpretPhaseStart = ProcessInfo.processInfo.systemUptime +#endif interpretKeyEvents([translationEvent]) +#if DEBUG + interpretMs = (ProcessInfo.processInfo.systemUptime - interpretPhaseStart) * 1000.0 + CmuxTypingTiming.logDuration( + path: "terminal.keyDown.interpretKeyEvents", + startedAt: interpretTimingStart, + event: event + ) +#endif // If the keyboard layout changed, an input method grabbed the event. // Sync preedit and return without sending the key to Ghostty. if !markedTextBefore, let kbBefore = keyboardIdBefore, kbBefore != KeyboardLayout.id { +#if DEBUG + let syncPreeditStart = ProcessInfo.processInfo.systemUptime +#endif syncPreedit(clearIfNeeded: markedTextBefore) +#if DEBUG + syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0 +#endif return } // Sync the preedit state with Ghostty so it can render the IME // composition overlay (e.g. for Korean, Japanese, Chinese input). +#if DEBUG + let syncPreeditStart = ProcessInfo.processInfo.systemUptime +#endif syncPreedit(clearIfNeeded: markedTextBefore) +#if DEBUG + syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0 +#endif // Build the key event var keyEvent = ghostty_input_key_s() @@ -4667,13 +4766,37 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { for text in accumulatedText { if shouldSendText(text) { shouldRefreshAfterTextInput = true +#if DEBUG + let sendTimingStart = CmuxTypingTiming.start() + let ghosttySendStart = ProcessInfo.processInfo.systemUptime +#endif text.withCString { ptr in keyEvent.text = ptr _ = ghostty_surface_key(surface, keyEvent) } +#if DEBUG + ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 + CmuxTypingTiming.logDuration( + path: "terminal.keyDown.accumulatedGhosttySend", + startedAt: sendTimingStart, + event: event, + extra: "textBytes=\(text.utf8.count)" + ) +#endif } else { keyEvent.text = nil + #if DEBUG + let ghosttySendStart = ProcessInfo.processInfo.systemUptime + _ = sendTimedGhosttyKey( + surface, + keyEvent, + path: "terminal.keyDown.accumulatedGhosttySend", + event: event + ) + ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 + #else _ = ghostty_surface_key(surface, keyEvent) + #endif } } } else { @@ -4688,22 +4811,63 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { if let text = textForKeyEvent(translationEvent) { if shouldSendText(text), !suppressShiftSpaceFallbackText { shouldRefreshAfterTextInput = true +#if DEBUG + let sendTimingStart = CmuxTypingTiming.start() + let ghosttySendStart = ProcessInfo.processInfo.systemUptime +#endif text.withCString { ptr in keyEvent.text = ptr _ = ghostty_surface_key(surface, keyEvent) } +#if DEBUG + ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 + CmuxTypingTiming.logDuration( + path: "terminal.keyDown.ghosttySend", + startedAt: sendTimingStart, + event: event, + extra: "textBytes=\(text.utf8.count)" + ) +#endif } else { keyEvent.text = nil + #if DEBUG + let ghosttySendStart = ProcessInfo.processInfo.systemUptime + _ = sendTimedGhosttyKey( + surface, + keyEvent, + path: "terminal.keyDown.ghosttySend", + event: event + ) + ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 + #else _ = ghostty_surface_key(surface, keyEvent) + #endif } } else { keyEvent.text = nil + #if DEBUG + let ghosttySendStart = ProcessInfo.processInfo.systemUptime + _ = sendTimedGhosttyKey( + surface, + keyEvent, + path: "terminal.keyDown.ghosttySend", + event: event + ) + ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0 + #else _ = ghostty_surface_key(surface, keyEvent) + #endif } } if shouldRefreshAfterTextInput { +#if DEBUG + let refreshStart = ProcessInfo.processInfo.systemUptime +#endif terminalSurface?.forceRefresh(reason: "keyDown.textInput") +#if DEBUG + refreshMs = (ProcessInfo.processInfo.systemUptime - refreshStart) * 1000.0 +#endif } // Rendering is driven by Ghostty's wakeups/renderer. @@ -4717,6 +4881,29 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return ghostty_surface_key(surface, keyEvent) } +#if DEBUG + @discardableResult + private func sendTimedGhosttyKey( + _ surface: ghostty_surface_t, + _ keyEvent: ghostty_input_key_s, + path: String, + event: NSEvent? = nil, + extra: String? = nil + ) -> Bool { + let timingStart = CmuxTypingTiming.start() + let handled = sendGhosttyKey(surface, keyEvent) + let baseExtra = "handled=\(handled ? 1 : 0)" + let mergedExtra: String + if let extra, !extra.isEmpty { + mergedExtra = "\(baseExtra) \(extra)" + } else { + mergedExtra = baseExtra + } + CmuxTypingTiming.logDuration(path: path, startedAt: timingStart, event: event, extra: mergedExtra) + return handled + } +#endif + override func keyUp(with event: NSEvent) { guard let surface = ensureSurfaceReadyForInput() else { super.keyUp(with: event) @@ -7494,6 +7681,9 @@ final class GhosttySurfaceScrollView: NSView { extension GhosttyNSView: NSTextInputClient { fileprivate func sendTextToSurface(_ chars: String) { guard let surface = surface else { return } +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() +#endif #if DEBUG cmuxWriteChildExitProbe( [ @@ -7513,6 +7703,13 @@ extension GhosttyNSView: NSTextInputClient { keyEvent.composing = false _ = ghostty_surface_key(surface, keyEvent) } +#if DEBUG + CmuxTypingTiming.logDuration( + path: "terminal.sendTextToSurface", + startedAt: typingTimingStart, + extra: "textBytes=\(chars.utf8.count)" + ) +#endif } func hasMarkedText() -> Bool { @@ -7529,6 +7726,16 @@ extension GhosttyNSView: NSTextInputClient { } func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + defer { + CmuxTypingTiming.logDuration( + path: "terminal.setMarkedText", + startedAt: typingTimingStart, + extra: "markedLength=\(markedText.length)" + ) + } +#endif switch string { case let v as NSAttributedString: markedText = NSMutableAttributedString(attributedString: v) @@ -7547,6 +7754,17 @@ extension GhosttyNSView: NSTextInputClient { } func unmarkText() { +#if DEBUG + let hadMarkedText = markedText.length > 0 + let typingTimingStart = CmuxTypingTiming.start() + defer { + CmuxTypingTiming.logDuration( + path: "terminal.unmarkText", + startedAt: typingTimingStart, + extra: "hadMarkedText=\(hadMarkedText ? 1 : 0)" + ) + } +#endif if markedText.length > 0 { markedText.mutableString.setString("") syncPreedit() @@ -7557,6 +7775,16 @@ extension GhosttyNSView: NSTextInputClient { /// This tells Ghostty about IME composition text so it can render the /// preedit overlay (e.g. for Korean, Japanese, Chinese input). private func syncPreedit(clearIfNeeded: Bool = true) { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + defer { + CmuxTypingTiming.logDuration( + path: "terminal.syncPreedit", + startedAt: typingTimingStart, + extra: "markedLength=\(markedText.length) clearIfNeeded=\(clearIfNeeded ? 1 : 0)" + ) + } +#endif guard let surface = surface else { return } if markedText.length > 0 { @@ -7624,6 +7852,17 @@ extension GhosttyNSView: NSTextInputClient { } func insertText(_ string: Any, replacementRange: NSRange) { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + defer { + CmuxTypingTiming.logDuration( + path: "terminal.insertText", + startedAt: typingTimingStart, + event: NSApp.currentEvent, + extra: "replacementLocation=\(replacementRange.location) replacementLength=\(replacementRange.length)" + ) + } +#endif // Get the string value var chars = "" switch string { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index f1c4bbae..d11a67cf 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -1288,6 +1288,17 @@ struct BrowserPanelView: View { } private func refreshSuggestions() { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + defer { + let trimmedQuery = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines) + CmuxTypingTiming.logDuration( + path: "browser.omnibar.refreshSuggestions", + startedAt: typingTimingStart, + extra: "focused=\(addressBarFocused ? 1 : 0) queryLen=\(trimmedQuery.utf8.count) suggestionCount=\(omnibarState.suggestions.count)" + ) + } +#endif suggestionTask?.cancel() suggestionTask = nil isLoadingRemoteSuggestions = false @@ -2702,6 +2713,18 @@ private final class OmnibarNativeTextField: NSTextField { } override func keyDown(with event: NSEvent) { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + var route = "super" + defer { + CmuxTypingTiming.logDuration( + path: "browser.omnibar.keyDown", + startedAt: typingTimingStart, + event: event, + extra: "route=\(route)" + ) + } +#endif // Reset shift-click anchor on any keyboard input so that a subsequent // Shift+click uses the post-keyboard selection as its anchor, not a // stale value from a prior mouse interaction. @@ -2711,20 +2734,46 @@ private final class OmnibarNativeTextField: NSTextField { return } if onHandleKeyEvent?(event, currentEditor() as? NSTextView) == true { +#if DEBUG + route = "custom" +#endif return } super.keyDown(with: event) } override func performKeyEquivalent(with event: NSEvent) -> Bool { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + var handled = false + defer { + CmuxTypingTiming.logDuration( + path: "browser.omnibar.performKeyEquivalent", + startedAt: typingTimingStart, + event: event, + extra: "handled=\(handled ? 1 : 0)" + ) + } +#endif shiftClickAnchor = nil if (currentEditor() as? NSTextView)?.hasMarkedText() == true { - return super.performKeyEquivalent(with: event) + let result = super.performKeyEquivalent(with: event) +#if DEBUG + handled = result +#endif + return result } if onHandleKeyEvent?(event, currentEditor() as? NSTextView) == true { +#if DEBUG + handled = true +#endif return true } - return super.performKeyEquivalent(with: event) + let result = super.performKeyEquivalent(with: event) +#if DEBUG + handled = result +#endif + return result } } @@ -2947,6 +2996,17 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { } func controlTextDidChange(_ obj: Notification) { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + defer { + CmuxTypingTiming.logDuration( + path: "browser.omnibar.controlTextDidChange", + startedAt: typingTimingStart, + event: NSApp.currentEvent, + extra: "programmatic=\(isProgrammaticMutation ? 1 : 0)" + ) + } +#endif guard !isProgrammaticMutation else { return } guard let field = obj.object as? NSTextField else { return } let editor = field.currentEditor() as? NSTextView @@ -2960,36 +3020,69 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { } func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + var handled = false + defer { + CmuxTypingTiming.logDuration( + path: "browser.omnibar.doCommandBy", + startedAt: typingTimingStart, + event: NSApp.currentEvent, + extra: "handled=\(handled ? 1 : 0) selector=\(NSStringFromSelector(commandSelector))" + ) + } +#endif switch commandSelector { case #selector(NSResponder.moveDown(_:)): parent.onMoveSelection(+1) +#if DEBUG + handled = true +#endif return true case #selector(NSResponder.moveUp(_:)): parent.onMoveSelection(-1) +#if DEBUG + handled = true +#endif return true case #selector(NSResponder.insertNewline(_:)): let currentFlags = NSApp.currentEvent?.modifierFlags ?? [] guard browserOmnibarShouldSubmitOnReturn(flags: currentFlags) else { return false } parent.onSubmit() +#if DEBUG + handled = true +#endif return true case #selector(NSResponder.cancelOperation(_:)): parent.onEscape() +#if DEBUG + handled = true +#endif return true case #selector(NSResponder.moveRight(_:)), #selector(NSResponder.moveToEndOfLine(_:)): if parent.inlineCompletion != nil { parent.onAcceptInlineCompletion() +#if DEBUG + handled = true +#endif return true } return false case #selector(NSResponder.insertTab(_:)): if parent.inlineCompletion != nil { parent.onAcceptInlineCompletion() +#if DEBUG + handled = true +#endif return true } return false case #selector(NSResponder.deleteBackward(_:)): if suffixSelectionMatchesInline(textView, inline: parent.inlineCompletion) { parent.onDeleteBackwardWithInlineSelection() +#if DEBUG + handled = true +#endif return true } return false @@ -3057,6 +3150,18 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { } func handleKeyEvent(_ event: NSEvent, editor: NSTextView?) -> Bool { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + var handled = false + defer { + CmuxTypingTiming.logDuration( + path: "browser.omnibar.handleKeyEvent", + startedAt: typingTimingStart, + event: event, + extra: "handled=\(handled ? 1 : 0)" + ) + } +#endif let keyCode = event.keyCode let modifiers = event.modifierFlags.intersection([.command, .control, .shift, .option, .function]) let lowered = event.charactersIgnoringModifiers?.lowercased() ?? "" @@ -3065,16 +3170,25 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { // Cmd/Ctrl+N and Cmd/Ctrl+P should repeat while held. if hasCommandOrControl, lowered == "n" { parent.onMoveSelection(+1) +#if DEBUG + handled = true +#endif return true } if hasCommandOrControl, lowered == "p" { parent.onMoveSelection(-1) +#if DEBUG + handled = true +#endif return true } // Shift+Delete removes the selected history suggestion when possible. if modifiers.contains(.shift), (keyCode == 51 || keyCode == 117) { parent.onDeleteSelectedSuggestion() +#if DEBUG + handled = true +#endif return true } @@ -3082,30 +3196,51 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable { case 36, 76: // Return / keypad Enter guard browserOmnibarShouldSubmitOnReturn(flags: event.modifierFlags) else { return false } parent.onSubmit() +#if DEBUG + handled = true +#endif return true case 53: // Escape parent.onEscape() +#if DEBUG + handled = true +#endif return true case 125: // Down parent.onMoveSelection(+1) +#if DEBUG + handled = true +#endif return true case 126: // Up parent.onMoveSelection(-1) +#if DEBUG + handled = true +#endif return true case 124, 119: // Right arrow / End if parent.inlineCompletion != nil { parent.onAcceptInlineCompletion() +#if DEBUG + handled = true +#endif return true } case 48: // Tab if parent.inlineCompletion != nil { parent.onAcceptInlineCompletion() +#if DEBUG + handled = true +#endif return true } case 51: // Backspace if let inline = parent.inlineCompletion, (suffixSelectionMatchesInline(editor, inline: inline) || selectionIsTypedPrefixBoundary(editor, inline: inline)) { parent.onDeleteBackwardWithInlineSelection() +#if DEBUG + handled = true +#endif return true } default: diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index ed00bbd9..f2947851 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -114,6 +114,18 @@ final class CmuxWebView: WKWebView { } override func performKeyEquivalent(with event: NSEvent) -> Bool { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + var handled = false + defer { + CmuxTypingTiming.logDuration( + path: "browser.web.performKeyEquivalent", + startedAt: typingTimingStart, + event: event, + extra: "handled=\(handled ? 1 : 0)" + ) + } +#endif if event.keyCode == 36 || event.keyCode == 76 { // Always bypass app/menu key-equivalent routing for Return/Enter so WebKit // receives the keyDown path used by form submission handlers. @@ -124,28 +136,57 @@ final class CmuxWebView: WKWebView { // Menu/app shortcut routing is only needed for Command equivalents // (New Tab, Close Tab, tab switching, split commands, etc). guard flags.contains(.command) else { - return super.performKeyEquivalent(with: event) + let result = super.performKeyEquivalent(with: event) +#if DEBUG + handled = result +#endif + return result } // Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc). if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { +#if DEBUG + handled = true +#endif return true } // Handle app-level shortcuts that are not menu-backed (for example split commands). // Without this, WebKit can consume Cmd-based shortcuts before the app monitor sees them. if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { +#if DEBUG + handled = true +#endif return true } - return super.performKeyEquivalent(with: event) + let result = super.performKeyEquivalent(with: event) +#if DEBUG + handled = result +#endif + return result } override func keyDown(with event: NSEvent) { +#if DEBUG + let typingTimingStart = CmuxTypingTiming.start() + var route = "super" + defer { + CmuxTypingTiming.logDuration( + path: "browser.web.keyDown", + startedAt: typingTimingStart, + event: event, + extra: "route=\(route)" + ) + } +#endif // Some Cmd-based key paths in WebKit don't consistently invoke performKeyEquivalent. // Route them through the same app-level shortcut handler as a fallback. if event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command), AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { +#if DEBUG + route = "appShortcut" +#endif return } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index a9e17328..6cfc01d1 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -570,6 +570,7 @@ class TabManager: ObservableObject { @Published var tabs: [Workspace] = [] @Published private(set) var isWorkspaceCycleHot: Bool = false @Published private(set) var pendingBackgroundWorkspaceLoadIds: Set = [] + @Published private(set) var debugPinnedWorkspaceLoadIds: Set = [] /// Global monotonically increasing counter for CMUX_PORT ordinal assignment. /// Static so port ranges don't overlap across multiple windows (each window has its own TabManager). @@ -1021,10 +1022,25 @@ class TabManager: ObservableObject { guard pendingBackgroundWorkspaceLoadIds.remove(workspaceId) != nil else { return } } + func retainDebugWorkspaceLoads(for workspaceIds: Set) { + guard !workspaceIds.isEmpty else { return } + debugPinnedWorkspaceLoadIds.formUnion(workspaceIds) + } + + func releaseDebugWorkspaceLoads(for workspaceIds: Set) { + guard !workspaceIds.isEmpty else { return } + debugPinnedWorkspaceLoadIds.subtract(workspaceIds) + } + func pruneBackgroundWorkspaceLoads(existingIds: Set) { let pruned = pendingBackgroundWorkspaceLoadIds.intersection(existingIds) - guard pruned != pendingBackgroundWorkspaceLoadIds else { return } - pendingBackgroundWorkspaceLoadIds = pruned + if pruned != pendingBackgroundWorkspaceLoadIds { + pendingBackgroundWorkspaceLoadIds = pruned + } + let retained = debugPinnedWorkspaceLoadIds.intersection(existingIds) + if retained != debugPinnedWorkspaceLoadIds { + debugPinnedWorkspaceLoadIds = retained + } } // Keep addTab as convenience alias diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 80c0d2ba..a0b890f0 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -129,44 +129,69 @@ final class WindowTerminalHostView: NSView { clearActiveDividerCursor(restoreArrow: true) } + // PERF: hitTest is called on EVERY event including keyboard. Keep non-pointer + // path minimal. Do not add work outside the isPointerEvent guard. override func hitTest(_ point: NSPoint) -> NSView? { - updateDividerCursor(at: point) - - if shouldPassThroughToSidebarResizer(at: point) { - return nil + let currentEvent = NSApp.currentEvent + let isPointerEvent: Bool + switch currentEvent?.type { + case .mouseMoved, .mouseEntered, .mouseExited, + .leftMouseDown, .leftMouseUp, .leftMouseDragged, + .rightMouseDown, .rightMouseUp, .rightMouseDragged, + .otherMouseDown, .otherMouseUp, .otherMouseDragged, + .scrollWheel, .cursorUpdate: + isPointerEvent = true + default: + isPointerEvent = false } - if shouldPassThroughToSplitDivider(at: point) { - return nil - } + if isPointerEvent { + if shouldPassThroughToSidebarResizer(at: point) { + clearActiveDividerCursor(restoreArrow: false) + return nil + } - let dragPasteboardTypes = NSPasteboard(name: .drag).types - let eventType = NSApp.currentEvent?.type - let shouldPassThrough = DragOverlayRoutingPolicy.shouldPassThroughPortalHitTesting( - pasteboardTypes: dragPasteboardTypes, - eventType: eventType - ) - if shouldPassThrough { + // Compute divider hit once and reuse for both cursor update and pass-through. + if let kind = splitDividerCursorKind(at: point) { + activeDividerCursorKind = kind + kind.cursor.set() + return nil + } + + clearActiveDividerCursor(restoreArrow: true) + + let dragPasteboardTypes = NSPasteboard(name: .drag).types + let eventType = currentEvent?.type + let shouldPassThrough = DragOverlayRoutingPolicy.shouldPassThroughPortalHitTesting( + pasteboardTypes: dragPasteboardTypes, + eventType: eventType + ) + if shouldPassThrough { +#if DEBUG + logDragRouteDecision( + passThrough: true, + eventType: eventType, + pasteboardTypes: dragPasteboardTypes, + hitView: nil + ) +#endif + return nil + } + + let hitView = super.hitTest(point) #if DEBUG logDragRouteDecision( - passThrough: true, - eventType: eventType, + passThrough: false, + eventType: currentEvent?.type, pasteboardTypes: dragPasteboardTypes, - hitView: nil + hitView: hitView ) #endif - return nil + return hitView === self ? nil : hitView } + // Non-pointer event: skip divider/drag routing, just do standard hit testing. let hitView = super.hitTest(point) -#if DEBUG - logDragRouteDecision( - passThrough: false, - eventType: eventType, - pasteboardTypes: dragPasteboardTypes, - hitView: hitView - ) -#endif return hitView === self ? nil : hitView } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index e538a235..8d38a901 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3252,6 +3252,7 @@ final class Workspace: Identifiable, ObservableObject { /// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels) private var isProgrammaticSplit = false + private var debugStressPreloadSelectionDepth = 0 /// Last terminal panel used as an inheritance source (typically last focused terminal). private var lastTerminalConfigInheritancePanelId: UUID? @@ -3287,6 +3288,10 @@ final class Workspace: Identifiable, ObservableObject { return panel } + func effectiveSelectedPanelId(inPane paneId: PaneID) -> UUID? { + bonsplitController.selectedTab(inPane: paneId).flatMap { panelIdFromSurfaceId($0.id) } + } + enum FocusPanelTrigger { case standard case terminalFirstResponder @@ -3836,6 +3841,36 @@ final class Workspace: Identifiable, ObservableObject { } } + @discardableResult + func preloadTerminalPanelForDebugStress( + tabId: TabID, + inPane paneId: PaneID + ) -> TerminalPanel? { + guard let panelId = panelIdFromSurfaceId(tabId), + let terminalPanel = panels[panelId] as? TerminalPanel else { + return nil + } + + debugStressPreloadSelectionDepth += 1 + defer { debugStressPreloadSelectionDepth -= 1 } + let isVisibleSelection = + bonsplitController.focusedPaneId == paneId && + bonsplitController.selectedTab(inPane: paneId)?.id == tabId && + terminalPanel.hostedView.window != nil && + terminalPanel.hostedView.superview != nil + + if isVisibleSelection { + terminalPanel.requestViewReattach() + scheduleTerminalGeometryReconcile() + } + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + return terminalPanel + } + + func scheduleDebugStressTerminalGeometryReconcile() { + scheduleTerminalGeometryReconcile() + } + func hasLoadedTerminalSurface() -> Bool { let terminalPanels = panels.values.compactMap { $0 as? TerminalPanel } guard !terminalPanels.isEmpty else { return true } @@ -6673,23 +6708,37 @@ extension Workspace: BonsplitDelegate { return } - // Focus the selected panel - guard let panelId = panelIdFromSurfaceId(selectedTabId), - let panel = panels[panelId] else { + // Focus the selected panel, but keep the previously focused terminal active while a + // newly created split terminal is still unattached. + guard let selectedPanelId = panelIdFromSurfaceId(selectedTabId) else { + return + } + let effectiveFocusedPanelId = effectiveSelectedPanelId(inPane: focusedPane) ?? selectedPanelId + guard let panel = panels[effectiveFocusedPanelId] else { + return + } + + if debugStressPreloadSelectionDepth > 0 { + if let terminalPanel = panel as? TerminalPanel { + terminalPanel.requestViewReattach() + scheduleTerminalGeometryReconcile() + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + } return } if shouldTreatCurrentEventAsExplicitFocusIntent() { - markExplicitFocusIntent(on: panelId) + markExplicitFocusIntent(on: effectiveFocusedPanelId) } let activationIntent = focusIntent ?? panel.preferredFocusIntentForActivation() panel.prepareFocusIntentForActivation(activationIntent) + let panelId = effectiveFocusedPanelId - syncPinnedStateForTab(selectedTabId, panelId: panelId) - syncUnreadBadgeStateForPanel(panelId) + syncPinnedStateForTab(selectedTabId, panelId: selectedPanelId) + syncUnreadBadgeStateForPanel(selectedPanelId) // Unfocus all other panels - for (id, p) in panels where id != panelId { + for (id, p) in panels where id != effectiveFocusedPanelId { p.unfocus() } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index a85a5e94..e2c65a34 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -322,6 +322,15 @@ struct cmuxApp: App { appDelegate.openDebugColorComparisonWorkspaces(nil) } + Button( + String( + localized: "debug.menu.openStressWorkspacesWithLoadedSurfaces", + defaultValue: "Open Stress Workspaces and Load All Terminals" + ) + ) { + appDelegate.openDebugStressWorkspacesWithLoadedSurfaces(nil) + } + Divider() Menu("Debug Windows") { Button("Debug Window Controls…") { @@ -462,6 +471,10 @@ struct cmuxApp: App { closeTabOrWindow() } + Menu(String(localized: "commandPalette.switcher.workspaceLabel", defaultValue: "Workspace")) { + workspaceCommandMenuContent(manager: activeTabManager) + } + Button(String(localized: "menu.file.reopenClosedBrowserPanel", defaultValue: "Reopen Closed Browser Panel")) { _ = activeTabManager.reopenMostRecentlyClosedBrowserPanel() } @@ -819,6 +832,199 @@ struct cmuxApp: App { _ = tabManager.createBrowserSplit(direction: direction) } + private func selectedWorkspaceIndex(in manager: TabManager, workspaceId: UUID) -> Int? { + manager.tabs.firstIndex { $0.id == workspaceId } + } + + private func selectedWorkspaceWindowMoveTargets(in manager: TabManager) -> [AppDelegate.WindowMoveTarget] { + let referenceWindowId = AppDelegate.shared?.windowId(for: manager) + return AppDelegate.shared?.windowMoveTargets(referenceWindowId: referenceWindowId) ?? [] + } + + private func toggleSelectedWorkspacePinned(in manager: TabManager) { + guard let workspace = manager.selectedWorkspace else { return } + manager.setPinned(workspace, pinned: !workspace.isPinned) + } + + private func clearSelectedWorkspaceCustomName(in manager: TabManager) { + guard let workspace = manager.selectedWorkspace else { return } + manager.clearCustomTitle(tabId: workspace.id) + } + + private func moveSelectedWorkspace(in manager: TabManager, by delta: Int) { + guard let workspace = manager.selectedWorkspace, + let currentIndex = selectedWorkspaceIndex(in: manager, workspaceId: workspace.id) else { return } + let targetIndex = currentIndex + delta + guard targetIndex >= 0, targetIndex < manager.tabs.count else { return } + _ = manager.reorderWorkspace(tabId: workspace.id, toIndex: targetIndex) + manager.selectWorkspace(workspace) + } + + private func moveSelectedWorkspaceToTop(in manager: TabManager) { + guard let workspace = manager.selectedWorkspace else { return } + manager.moveTabsToTop([workspace.id]) + manager.selectWorkspace(workspace) + } + + private func moveSelectedWorkspace(in manager: TabManager, toWindow windowId: UUID) { + guard let workspace = manager.selectedWorkspace else { return } + _ = AppDelegate.shared?.moveWorkspaceToWindow(workspaceId: workspace.id, windowId: windowId, focus: true) + } + + private func moveSelectedWorkspaceToNewWindow(in manager: TabManager) { + guard let workspace = manager.selectedWorkspace else { return } + _ = AppDelegate.shared?.moveWorkspaceToNewWindow(workspaceId: workspace.id, focus: true) + } + + private func closeWorkspaceIds( + _ workspaceIds: [UUID], + in manager: TabManager, + allowPinned: Bool + ) { + for workspaceId in workspaceIds { + guard let workspace = manager.tabs.first(where: { $0.id == workspaceId }) else { continue } + guard allowPinned || !workspace.isPinned else { continue } + manager.closeWorkspaceWithConfirmation(workspace) + } + } + + private func closeOtherSelectedWorkspacePeers(in manager: TabManager) { + guard let workspace = manager.selectedWorkspace else { return } + let workspaceIds = manager.tabs.compactMap { $0.id == workspace.id ? nil : $0.id } + closeWorkspaceIds(workspaceIds, in: manager, allowPinned: false) + } + + private func closeSelectedWorkspacesBelow(in manager: TabManager) { + guard let workspace = manager.selectedWorkspace, + let anchorIndex = selectedWorkspaceIndex(in: manager, workspaceId: workspace.id) else { return } + let workspaceIds = manager.tabs.suffix(from: anchorIndex + 1).map(\.id) + closeWorkspaceIds(workspaceIds, in: manager, allowPinned: false) + } + + private func closeSelectedWorkspacesAbove(in manager: TabManager) { + guard let workspace = manager.selectedWorkspace, + let anchorIndex = selectedWorkspaceIndex(in: manager, workspaceId: workspace.id) else { return } + let workspaceIds = manager.tabs.prefix(upTo: anchorIndex).map(\.id) + closeWorkspaceIds(workspaceIds, in: manager, allowPinned: false) + } + + private func selectedWorkspaceHasUnreadNotifications(in manager: TabManager) -> Bool { + guard let workspaceId = manager.selectedWorkspace?.id else { return false } + return notificationStore.notifications.contains { $0.tabId == workspaceId && !$0.isRead } + } + + private func selectedWorkspaceHasReadNotifications(in manager: TabManager) -> Bool { + guard let workspaceId = manager.selectedWorkspace?.id else { return false } + return notificationStore.notifications.contains { $0.tabId == workspaceId && $0.isRead } + } + + private func markSelectedWorkspaceRead(in manager: TabManager) { + guard let workspaceId = manager.selectedWorkspace?.id else { return } + notificationStore.markRead(forTabId: workspaceId) + } + + private func markSelectedWorkspaceUnread(in manager: TabManager) { + guard let workspaceId = manager.selectedWorkspace?.id else { return } + notificationStore.markUnread(forTabId: workspaceId) + } + + @ViewBuilder + private func workspaceCommandMenuContent(manager: TabManager) -> some View { + let workspace = manager.selectedWorkspace + let workspaceIndex = workspace.flatMap { selectedWorkspaceIndex(in: manager, workspaceId: $0.id) } + let windowMoveTargets = selectedWorkspaceWindowMoveTargets(in: manager) + + Button( + workspace?.isPinned == true + ? String(localized: "contextMenu.unpinWorkspace", defaultValue: "Unpin Workspace") + : String(localized: "contextMenu.pinWorkspace", defaultValue: "Pin Workspace") + ) { + toggleSelectedWorkspacePinned(in: manager) + } + .disabled(workspace == nil) + + Button(String(localized: "menu.view.renameWorkspace", defaultValue: "Rename Workspace…")) { + _ = AppDelegate.shared?.requestRenameWorkspaceViaCommandPalette() + } + .disabled(workspace == nil) + + if workspace?.hasCustomTitle == true { + Button(String(localized: "contextMenu.removeCustomWorkspaceName", defaultValue: "Remove Custom Workspace Name")) { + clearSelectedWorkspaceCustomName(in: manager) + } + } + + Divider() + + Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) { + moveSelectedWorkspace(in: manager, by: -1) + } + .disabled(workspaceIndex == nil || workspaceIndex == 0) + + Button(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")) { + moveSelectedWorkspace(in: manager, by: 1) + } + .disabled(workspaceIndex == nil || workspaceIndex == manager.tabs.count - 1) + + Button(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")) { + moveSelectedWorkspaceToTop(in: manager) + } + .disabled(workspace == nil || workspaceIndex == 0) + + Menu(String(localized: "contextMenu.moveWorkspaceToWindow", defaultValue: "Move Workspace to Window")) { + Button(String(localized: "contextMenu.newWindow", defaultValue: "New Window")) { + moveSelectedWorkspaceToNewWindow(in: manager) + } + .disabled(workspace == nil) + + if !windowMoveTargets.isEmpty { + Divider() + } + + ForEach(windowMoveTargets) { target in + Button(target.label) { + moveSelectedWorkspace(in: manager, toWindow: target.windowId) + } + .disabled(target.isCurrentWindow || workspace == nil) + } + } + .disabled(workspace == nil) + + Divider() + + Button(String(localized: "menu.file.closeWorkspace", defaultValue: "Close Workspace")) { + manager.closeCurrentWorkspaceWithConfirmation() + } + .disabled(workspace == nil) + + Button(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")) { + closeOtherSelectedWorkspacePeers(in: manager) + } + .disabled(workspace == nil || manager.tabs.count <= 1) + + Button(String(localized: "contextMenu.closeWorkspacesBelow", defaultValue: "Close Workspaces Below")) { + closeSelectedWorkspacesBelow(in: manager) + } + .disabled(workspaceIndex == nil || workspaceIndex == manager.tabs.count - 1) + + Button(String(localized: "contextMenu.closeWorkspacesAbove", defaultValue: "Close Workspaces Above")) { + closeSelectedWorkspacesAbove(in: manager) + } + .disabled(workspaceIndex == nil || workspaceIndex == 0) + + Divider() + + Button(String(localized: "contextMenu.markWorkspaceRead", defaultValue: "Mark Workspace as Read")) { + markSelectedWorkspaceRead(in: manager) + } + .disabled(!selectedWorkspaceHasUnreadNotifications(in: manager)) + + Button(String(localized: "contextMenu.markWorkspaceUnread", defaultValue: "Mark Workspace as Unread")) { + markSelectedWorkspaceUnread(in: manager) + } + .disabled(!selectedWorkspaceHasReadNotifications(in: manager)) + } + @ViewBuilder private func splitCommandButton(title: String, shortcut: StoredShortcut, action: @escaping () -> Void) -> some View { if let key = shortcut.keyEquivalent {