From b13bf098fc2af5f4cc58aa5294872da0f20ae1f8 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:51:22 -0300 Subject: [PATCH 1/9] refactor(contentview): extract TabItemView.swift (mechanical move) Move TabItemView and its exclusive helper-view cluster (SidebarWorkspaceDescriptionText, SidebarMarkdownRenderer, SidebarMetadataRows/EntryRow/MarkdownBlocks/BlockRow) out of ContentView.swift into a new file, verbatim. Access-level widening: TabItemView was file-private (private struct); widened to internal (dropped the private modifier) because VerticalTabsSidebar, which stays in ContentView.swift, constructs it at its ForEach call site. No other behavior change. Equatable conformance, precomputed let parameters, and the .equatable() call site are untouched. Refs #94. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/ContentView.swift | 1950 ------------------------ Sources/TabItemView.swift | 1965 +++++++++++++++++++++++++ 3 files changed, 1969 insertions(+), 1950 deletions(-) create mode 100644 Sources/TabItemView.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 5ba0f619fc7..c973b5ea48c 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ A5FF0007 /* SettingDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0017 /* SettingDefinition.swift */; }; A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; A5FF0005 /* ContentView+CommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0015 /* ContentView+CommandPalette.swift */; }; + NRCV00000000000000000005 /* TabItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000006 /* TabItemView.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; }; A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; @@ -235,6 +236,7 @@ A5FF0017 /* SettingDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingDefinition.swift; sourceTree = ""; }; A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A5FF0015 /* ContentView+CommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+CommandPalette.swift"; sourceTree = ""; }; + NRCV00000000000000000006 /* TabItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItemView.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; @@ -504,6 +506,7 @@ A5FF0017 /* SettingDefinition.swift */, A5001012 /* ContentView.swift */, A5FF0015 /* ContentView+CommandPalette.swift */, + NRCV00000000000000000006 /* TabItemView.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, @@ -834,6 +837,7 @@ A5FF0007 /* SettingDefinition.swift in Sources */, A5001002 /* ContentView.swift in Sources */, A5FF0005 /* ContentView+CommandPalette.swift in Sources */, + NRCV00000000000000000005 /* TabItemView.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index e17214dc0e9..a49d2eec076 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10255,1956 +10255,6 @@ enum SidebarTrailingAccessoryWidthPolicy { } } -// 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. -// Reactive workspace state inside the row must not rely on parent diffs alone: -// `.equatable()` can otherwise leave sidebar badges/details stale until an -// unrelated parent change sneaks through. Keep the workspace reference plain -// and bridge only sidebar-visible workspace changes into local state. -// 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 { - private static let workspaceObservationCoalesceInterval: RunLoop.SchedulerTimeType.Stride = .milliseconds(40) - - // 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.workspaceShortcutDigit == rhs.workspaceShortcutDigit && - lhs.workspaceShortcutModifierSymbol == rhs.workspaceShortcutModifierSymbol && - lhs.canCloseWorkspace == rhs.canCloseWorkspace && - lhs.accessibilityWorkspaceCount == rhs.accessibilityWorkspaceCount && - lhs.unreadCount == rhs.unreadCount && - lhs.latestNotificationText == rhs.latestNotificationText && - lhs.rowSpacing == rhs.rowSpacing && - lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints && - lhs.contextMenuWorkspaceIds == rhs.contextMenuWorkspaceIds && - lhs.remoteContextMenuWorkspaceIds == rhs.remoteContextMenuWorkspaceIds && - lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting && - lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected && - lhs.settings == rhs.settings - } - - // 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 - let tab: Tab - let index: Int - let isActive: Bool - let workspaceShortcutDigit: Int? - let workspaceShortcutModifierSymbol: String - let canCloseWorkspace: Bool - let accessibilityWorkspaceCount: Int - let unreadCount: Int - let latestNotificationText: String? - let rowSpacing: CGFloat - let setSelectionToTabs: () -> Void - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - let showsModifierShortcutHints: Bool - let dragAutoScrollController: SidebarDragAutoScrollController - @Binding var draggedTabId: UUID? - @Binding var dropIndicator: SidebarDropIndicator? - let contextMenuWorkspaceIds: [UUID] - let remoteContextMenuWorkspaceIds: [UUID] - let allRemoteContextMenuTargetsConnecting: Bool - let allRemoteContextMenuTargetsDisconnected: Bool - let settings: SidebarTabItemSettingsSnapshot - @State private var workspaceObservationGeneration: UInt64 = 0 - @State private var isHovering = false - @State private var rowHeight: CGFloat = 1 - // Cached results of the expensive bonsplit tree walk + branch/dir/PR snapshot. - // Updated only by the debounced publisher, onAppear, and settings changes — - // NOT by the immediate publisher (title keystrokes). This prevents the tree - // walk from running on every keystroke in single-panel workspaces. - @State private var cachedOrderedPanelIds: [UUID]? = nil - @State private var cachedBranchDirectoryLines: [VerticalBranchDirectoryLine] = [] - @State private var cachedPullRequestRows: [PullRequestDisplay] = [] - - var isMultiSelected: Bool { - selectedTabIds.contains(tab.id) - } - - private var isBeingDragged: Bool { - draggedTabId == tab.id - } - - private var sidebarShortcutHintXOffset: Double { - settings.sidebarShortcutHintXOffset - } - - private var sidebarShortcutHintYOffset: Double { - settings.sidebarShortcutHintYOffset - } - - private var alwaysShowShortcutHints: Bool { - settings.alwaysShowShortcutHints - } - - private var sidebarShowGitBranch: Bool { - settings.showsGitBranch - } - - private var sidebarBranchVerticalLayout: Bool { - settings.usesVerticalBranchLayout - } - - private var sidebarShowGitBranchIcon: Bool { - settings.showsGitBranchIcon - } - - private var sidebarShowSSH: Bool { - settings.showsSSH - } - - private var activeTabIndicatorStyle: SidebarActiveTabIndicatorStyle { - settings.activeTabIndicatorStyle - } - - private var sidebarSelectionColorHex: String? { - settings.selectionColorHex - } - - private var sidebarNotificationBadgeColorHex: String? { - settings.notificationBadgeColorHex - } - - private var openSidebarPullRequestLinksInProgramaBrowser: Bool { - settings.openPullRequestLinksInProgramaBrowser - } - - private var openSidebarPortLinksInProgramaBrowser: Bool { - settings.openPortLinksInProgramaBrowser - } - - private var titleFontWeight: Font.Weight { - .semibold - } - - private var showsLeadingRail: Bool { - explicitRailColor != nil - } - - private var activeBorderLineWidth: CGFloat { - switch activeTabIndicatorStyle { - case .leftRail: - return 0 - case .solidFill: - return isActive ? 1.5 : 0 - } - } - - private var activeBorderColor: Color { - guard isActive else { return .clear } - switch activeTabIndicatorStyle { - case .leftRail: - return .clear - case .solidFill: - return Color.primary.opacity(0.5) - } - } - - private var usesInvertedActiveForeground: Bool { - isActive - } - - private var activePrimaryTextColor: Color { - usesInvertedActiveForeground - ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 1.0)) - : .primary - } - - private func activeSecondaryColor(_ opacity: Double = 0.75) -> Color { - usesInvertedActiveForeground - ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat(opacity))) - : .secondary - } - - private var activeUnreadBadgeFillColor: Color { - if let hex = sidebarNotificationBadgeColorHex, let nsColor = NSColor(hex: hex) { - return Color(nsColor: nsColor) - } - return usesInvertedActiveForeground ? Color.white.opacity(0.25) : programaAccentColor() - } - - private var activeProgressTrackColor: Color { - usesInvertedActiveForeground ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2) - } - - private var activeProgressFillColor: Color { - usesInvertedActiveForeground ? Color.white.opacity(0.8) : programaAccentColor() - } - - private var shortcutHintEmphasis: Double { - usesInvertedActiveForeground ? 1.0 : 0.9 - } - - private var showCloseButton: Bool { - isHovering && canCloseWorkspace && !(showsModifierShortcutHints || alwaysShowShortcutHints) - } - - private var workspaceShortcutLabel: String? { - guard let workspaceShortcutDigit else { return nil } - return "\(workspaceShortcutModifierSymbol)\(workspaceShortcutDigit)" - } - - private var showsWorkspaceShortcutHint: Bool { - (showsModifierShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil - } - - private var trailingAccessoryWidth: CGFloat { - SidebarTrailingAccessoryWidthPolicy.width( - canCloseWorkspace: canCloseWorkspace, - showsWorkspaceShortcutHint: showsWorkspaceShortcutHint, - workspaceShortcutLabel: workspaceShortcutLabel, - debugXOffset: sidebarShortcutHintXOffset - ) - } - - private var remoteWorkspaceSidebarText: String? { - guard tab.hasActiveRemoteTerminalSessions else { return nil } - let trimmedTarget = tab.remoteDisplayTarget?.trimmingCharacters(in: .whitespacesAndNewlines) - if let trimmedTarget, !trimmedTarget.isEmpty { - return trimmedTarget - } - return String(localized: "sidebar.remote.subtitleFallback", defaultValue: "SSH workspace") - } - - private var copyableSidebarSSHError: String? { - let fallbackTarget = tab.remoteDisplayTarget ?? String( - localized: "sidebar.remote.help.targetFallback", - defaultValue: "remote host" - ) - let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) - if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty { - let entry = SidebarRemoteErrorCopyEntry( - workspaceTitle: tab.title, - target: fallbackTarget, - detail: trimmedDetail - ) - return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) - } - if let statusValue = tab.statusEntries["remote.error"]?.value - .trimmingCharacters(in: .whitespacesAndNewlines), - !statusValue.isEmpty { - let entry = SidebarRemoteErrorCopyEntry( - workspaceTitle: tab.title, - target: fallbackTarget, - detail: statusValue - ) - return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) - } - return nil - } - - private var remoteConnectionStatusText: String { - switch tab.remoteConnectionState { - case .connected: - return String(localized: "remote.status.connected", defaultValue: "Connected") - case .connecting: - return String(localized: "remote.status.connecting", defaultValue: "Connecting") - case .error: - return String(localized: "remote.status.error", defaultValue: "Error") - case .disconnected: - return String(localized: "remote.status.disconnected", defaultValue: "Disconnected") - } - } - - @ViewBuilder - private var remoteWorkspaceSection: some View { - if sidebarShowSSH, let remoteWorkspaceSidebarText { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(remoteWorkspaceSidebarText) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.8)) - .lineLimit(1) - .truncationMode(.middle) - - Spacer(minLength: 0) - - Text(remoteConnectionStatusText) - .font(.system(size: 9, weight: .medium)) - .foregroundColor(activeSecondaryColor(0.58)) - .lineLimit(1) - } - } - .padding(.top, latestNotificationText == nil ? 1 : 2) - .safeHelp(remoteStateHelpText) - } - } - - private func copyTextToPasteboard(_ text: String) { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(text, forType: .string) - } - - private var visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility { - settings.visibleAuxiliaryDetails - } - - var body: some View { - let _ = workspaceObservationGeneration - let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace") - let protectedWorkspaceTooltip = String( - localized: "sidebar.pinnedWorkspaceProtected.tooltip", - defaultValue: "Pinned workspace. Closing requires confirmation." - ) - let closeButtonTooltip = tab.isPinned - ? protectedWorkspaceTooltip - : KeyboardShortcutSettings.Action.closeWorkspace.tooltip(closeWorkspaceTooltip) - let accessibilityHintText = String(localized: "sidebar.workspace.accessibilityHint", defaultValue: "Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions.") - let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") - let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") - let latestNotificationSubtitle = latestNotificationText - let effectiveSubtitle = latestNotificationSubtitle - let detailVisibility = visibleAuxiliaryDetails - // Read from cache — updated only by the debounced publisher, onAppear, - // and settings changes. Title-keystroke re-renders skip this tree walk. - let orderedPanelIds: [UUID]? = cachedOrderedPanelIds - let branchDirectoryLines: [VerticalBranchDirectoryLine] = cachedBranchDirectoryLines - let pullRequestRows: [PullRequestDisplay] = cachedPullRequestRows - let compactGitBranchSummaryText: String? = { - guard detailVisibility.showsBranchDirectory, - !sidebarBranchVerticalLayout, - sidebarShowGitBranch, - let orderedPanelIds else { - return nil - } - return gitBranchSummaryText(orderedPanelIds: orderedPanelIds) - }() - let compactDirectorySummaryText: String? = { - guard detailVisibility.showsBranchDirectory, - !sidebarBranchVerticalLayout, - let orderedPanelIds else { - return nil - } - return directorySummaryText(orderedPanelIds: orderedPanelIds) - }() - let compactBranchDirectoryRow = branchDirectoryRow( - gitSummary: compactGitBranchSummaryText, - directorySummary: compactDirectorySummaryText - ) - let branchLinesContainBranch = sidebarShowGitBranch && branchDirectoryLines.contains { $0.branch != nil } - - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - if unreadCount > 0 { - ZStack { - Circle() - .fill(activeUnreadBadgeFillColor) - Text("\(unreadCount)") - .font(.system(size: 9, weight: .semibold)) - .foregroundColor(.white) - } - .frame(width: 16, height: 16) - } - - if tab.isPinned { - Image(systemName: "pin.fill") - .font(.system(size: 9, weight: .semibold)) - .foregroundColor(activeSecondaryColor(0.8)) - .safeHelp(protectedWorkspaceTooltip) - } - - Text(tab.title) - .font(.system(size: 12.5, weight: titleFontWeight)) - .foregroundColor(activePrimaryTextColor) - .lineLimit(1) - .truncationMode(.tail) - .layoutPriority(1) - - Spacer(minLength: 0) - - ZStack(alignment: .trailing) { - Button(action: { - #if DEBUG - dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button") - #endif - tabManager.closeWorkspaceWithConfirmation(tab) - }) { - Image(systemName: "xmark") - .font(.system(size: 9, weight: .medium)) - .foregroundColor(activeSecondaryColor(0.7)) - } - .buttonStyle(.plain) - .safeHelp(closeButtonTooltip) - .frame(width: SidebarTrailingAccessoryWidthPolicy.closeButtonWidth, height: 16, alignment: .center) - .opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0) - .allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint) - - if showsWorkspaceShortcutHint, let workspaceShortcutLabel { - Text(workspaceShortcutLabel) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - .font(.system(size: 10, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundColor(activePrimaryTextColor) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(ShortcutHintPillBackground(emphasis: shortcutHintEmphasis)) - .offset( - x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset), - y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset) - ) - .transition(.opacity) - } - } - .animation(.easeInOut(duration: 0.14), value: showsModifierShortcutHints || alwaysShowShortcutHints) - .frame(width: trailingAccessoryWidth, height: 16, alignment: .trailing) - } - - if let description = tab.customDescription { - SidebarWorkspaceDescriptionText( - markdown: description, - isActive: usesInvertedActiveForeground - ) - .id(description) - } - - if let subtitle = effectiveSubtitle { - Text(subtitle) - .font(.system(size: 10)) - .foregroundColor(activeSecondaryColor(0.8)) - .lineLimit(2) - .truncationMode(.tail) - .multilineTextAlignment(.leading) - } - - remoteWorkspaceSection - - if detailVisibility.showsMetadata { - let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder() - let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() - if !metadataEntries.isEmpty { - SidebarMetadataRows( - entries: metadataEntries, - isActive: usesInvertedActiveForeground, - onFocus: { updateSelection() } - ) - .transition(.opacity.combined(with: .move(edge: .top))) - } - if !metadataBlocks.isEmpty { - SidebarMetadataMarkdownBlocks( - blocks: metadataBlocks, - isActive: usesInvertedActiveForeground, - onFocus: { updateSelection() } - ) - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - - // Latest log entry - if detailVisibility.showsLog, let latestLog = tab.logEntries.last { - HStack(spacing: 4) { - Image(systemName: logLevelIcon(latestLog.level)) - .font(.system(size: 8)) - .foregroundColor(logLevelColor(latestLog.level, isActive: usesInvertedActiveForeground)) - Text(latestLog.message) - .font(.system(size: 10)) - .foregroundColor(activeSecondaryColor(0.8)) - .lineLimit(1) - .truncationMode(.tail) - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - - // Progress bar - if detailVisibility.showsProgress, let progress = tab.progress { - VStack(alignment: .leading, spacing: 2) { - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule() - .fill(activeProgressTrackColor) - Capsule() - .fill(activeProgressFillColor) - .frame(width: max(0, geo.size.width * CGFloat(progress.value))) - } - } - .frame(height: 3) - - if let label = progress.label { - Text(label) - .font(.system(size: 9)) - .foregroundColor(activeSecondaryColor(0.6)) - .lineLimit(1) - } - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - - // Branch + directory row - if detailVisibility.showsBranchDirectory { - if sidebarBranchVerticalLayout { - if !branchDirectoryLines.isEmpty { - HStack(alignment: .top, spacing: 3) { - if sidebarShowGitBranchIcon, branchLinesContainBranch { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) - .foregroundColor(activeSecondaryColor(0.6)) - } - VStack(alignment: .leading, spacing: 1) { - ForEach(Array(branchDirectoryLines.enumerated()), id: \.offset) { _, line in - HStack(spacing: 3) { - if let branch = line.branch { - Text(branch) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - .truncationMode(.tail) - } - if line.branch != nil, line.directory != nil { - Image(systemName: "circle.fill") - .font(.system(size: 3)) - .foregroundColor(activeSecondaryColor(0.6)) - .padding(.horizontal, 1) - } - if let directory = line.directory { - Text(directory) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - .truncationMode(.tail) - } - } - } - } - } - } - } else if let dirRow = compactBranchDirectoryRow { - HStack(spacing: 3) { - if sidebarShowGitBranchIcon, compactGitBranchSummaryText != nil { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) - .foregroundColor(activeSecondaryColor(0.6)) - } - Text(dirRow) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - .truncationMode(.tail) - } - } - } - - // Pull request rows - if detailVisibility.showsPullRequests, !pullRequestRows.isEmpty { - VStack(alignment: .leading, spacing: 1) { - ForEach(pullRequestRows) { pullRequest in - Button(action: { - openPullRequestLink(pullRequest.url) - }) { - HStack(spacing: 4) { - PullRequestStatusIcon( - status: pullRequest.status, - color: pullRequestForegroundColor - ) - Text("\(pullRequest.label) #\(pullRequest.number)") - .underline() - .lineLimit(1) - .truncationMode(.tail) - Text(pullRequestStatusLabel(pullRequest.status, checks: pullRequest.checks)) - .lineLimit(1) - Spacer(minLength: 0) - } - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(pullRequestForegroundColor) - } - .buttonStyle(.plain) - .safeHelp(String(localized: "sidebar.pullRequest.openTooltip", defaultValue: "Open \(pullRequest.label) #\(pullRequest.number)")) - } - } - } - - // Ports row - if detailVisibility.showsPorts, !tab.listeningPorts.isEmpty { - HStack(spacing: 4) { - ForEach(tab.listeningPorts, id: \.self) { port in - Button(action: { - openPortLink(port) - }) { - Text(String(localized: "sidebar.port.label", defaultValue: ":\(port)")) - .underline() - } - .buttonStyle(.plain) - .safeHelp(String(localized: "sidebar.port.openTooltip", defaultValue: "Open localhost:\(port)")) - } - Spacer(minLength: 0) - } - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - } - } - .animation(.easeInOut(duration: 0.2), value: tab.logEntries.count) - .animation(.easeInOut(duration: 0.2), value: tab.progress != nil) - .animation(.easeInOut(duration: 0.2), value: tab.metadataBlocks.count) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(backgroundColor) - .overlay { - RoundedRectangle(cornerRadius: 6) - .strokeBorder(activeBorderColor, lineWidth: activeBorderLineWidth) - } - .overlay(alignment: .leading) { - if showsLeadingRail { - Capsule(style: .continuous) - .fill(railColor) - .frame(width: 3) - .padding(.leading, 4) - .padding(.vertical, 5) - .offset(x: -1) - } - } - ) - .padding(.horizontal, 6) - .background { - GeometryReader { proxy in - Color.clear - .onAppear { - rowHeight = max(proxy.size.height, 1) - } - .onChange(of: proxy.size.height) { newHeight in - rowHeight = max(newHeight, 1) - } - } - } - .contentShape(Rectangle()) - .opacity(isBeingDragged ? 0.6 : 1) - .overlay { - MiddleClickCapture { - #if DEBUG - dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=middleClick") - #endif - tabManager.closeWorkspaceWithConfirmation(tab) - } - } - .overlay(alignment: .top) { - if showsCenteredTopDropIndicator { - Rectangle() - .fill(programaAccentColor()) - .frame(height: 2) - .padding(.horizontal, 8) - .offset(y: index == 0 ? 0 : -(rowSpacing / 2)) - } - } - .onReceive( - tab.sidebarImmediateObservationPublisher - .receive(on: RunLoop.main) - ) { _ in -#if DEBUG - let description = tab.customDescription ?? "" - dlog( - "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + - "source=immediate " + - "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + - "descLen=\((description as NSString).length) " + - "desc=\"\(debugCommandPaletteTextPreview(description))\"" - ) -#endif - workspaceObservationGeneration &+= 1 - } - .onReceive( - tab.sidebarObservationPublisher - .receive(on: RunLoop.main) - // Prompt-time sidebar telemetry can arrive as a short burst - // (pwd, branch, PR, shell state). Coalesce that burst so the - // row redraws once with the settled state instead of blinking. - .debounce(for: Self.workspaceObservationCoalesceInterval, scheduler: RunLoop.main) - ) { _ in -#if DEBUG - let description = tab.customDescription ?? "" - dlog( - "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + - "source=debounced " + - "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + - "descLen=\((description as NSString).length) " + - "desc=\"\(debugCommandPaletteTextPreview(description))\"" - ) -#endif - // Refresh expensive caches (tree walk, branch/dir/PR) before - // signalling a redraw. The immediate publisher intentionally does - // NOT call this so that title keystrokes skip the tree walk. - recomputeSidebarDetailCache() - workspaceObservationGeneration &+= 1 - } - .onDrag { - #if DEBUG - dlog("sidebar.onDrag tab=\(tab.id.uuidString.prefix(5))") - #endif - draggedTabId = tab.id - dropIndicator = nil - return SidebarTabDragPayload.provider(for: tab.id) - } - .internalOnlyTabDrag() - .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( - targetTabId: tab.id, - tabManager: tabManager, - draggedTabId: $draggedTabId, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - targetRowHeight: rowHeight, - dragAutoScrollController: dragAutoScrollController, - dropIndicator: $dropIndicator - )) - .onDrop(of: BonsplitTabDragPayload.dropContentTypes, delegate: SidebarBonsplitTabDropDelegate( - targetWorkspaceId: tab.id, - tabManager: tabManager, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex - )) - .onTapGesture { - updateSelection() - } - .onHover { hovering in - isHovering = hovering - } - .accessibilityElement(children: .combine) - .accessibilityLabel(Text(accessibilityTitle)) - .accessibilityHint(Text(accessibilityHintText)) - .accessibilityAction(named: Text(moveUpActionText)) { - moveBy(-1) - } - .accessibilityAction(named: Text(moveDownActionText)) { - moveBy(1) - } - .onAppear { - // Prime the cache so branch/dir/PR rows appear immediately, - // before the first debounced publisher fires. - recomputeSidebarDetailCache() - } - .onChange(of: visibleAuxiliaryDetails) { _ in - // Toggling branch/PR columns changes which data we need to cache. - recomputeSidebarDetailCache() - } - .contextMenu { workspaceContextMenu } - } - - private func contextMenuLabel(multi: String, single: String, isMulti: Bool) -> String { - isMulti ? multi : single - } - - private func remoteContextMenuWorkspaces() -> [Workspace] { - guard !remoteContextMenuWorkspaceIds.isEmpty else { return [] } - return remoteContextMenuWorkspaceIds.compactMap { workspaceId in - tabManager.tabs.first(where: { $0.id == workspaceId }) - } - } - - @ViewBuilder - private var workspaceContextMenu: some View { - let targetIds = contextMenuWorkspaceIds - let isMulti = targetIds.count > 1 - let tabColorPalette = WorkspaceTabColorSettings.palette() - let shouldPin = !tab.isPinned - let reconnectLabel = contextMenuLabel( - multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"), - single: String(localized: "contextMenu.reconnectWorkspace", defaultValue: "Reconnect Workspace"), - isMulti: isMulti) - let disconnectLabel = contextMenuLabel( - multi: String(localized: "contextMenu.disconnectWorkspaces", defaultValue: "Disconnect Workspaces"), - single: String(localized: "contextMenu.disconnectWorkspace", defaultValue: "Disconnect Workspace"), - isMulti: isMulti) - let pinLabel = shouldPin - ? contextMenuLabel( - multi: String(localized: "contextMenu.pinWorkspaces", defaultValue: "Pin Workspaces"), - single: String(localized: "contextMenu.pinWorkspace", defaultValue: "Pin Workspace"), - isMulti: isMulti) - : contextMenuLabel( - multi: String(localized: "contextMenu.unpinWorkspaces", defaultValue: "Unpin Workspaces"), - single: String(localized: "contextMenu.unpinWorkspace", defaultValue: "Unpin Workspace"), - isMulti: isMulti) - let closeLabel = contextMenuLabel( - multi: String(localized: "contextMenu.closeWorkspaces", defaultValue: "Close Workspaces"), - single: String(localized: "contextMenu.closeWorkspace", defaultValue: "Close Workspace"), - isMulti: isMulti) - let markReadLabel = contextMenuLabel( - multi: String(localized: "contextMenu.markWorkspacesRead", defaultValue: "Mark Workspaces as Read"), - single: String(localized: "contextMenu.markWorkspaceRead", defaultValue: "Mark Workspace as Read"), - isMulti: isMulti) - let markUnreadLabel = contextMenuLabel( - multi: String(localized: "contextMenu.markWorkspacesUnread", defaultValue: "Mark Workspaces as Unread"), - single: String(localized: "contextMenu.markWorkspaceUnread", defaultValue: "Mark Workspace as Unread"), - isMulti: isMulti) - let clearLatestNotificationLabel = contextMenuLabel( - multi: String(localized: "contextMenu.clearLatestNotifications", defaultValue: "Clear Latest Notifications"), - single: String(localized: "contextMenu.clearLatestNotification", defaultValue: "Clear Latest Notification"), - isMulti: isMulti) - let renameWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .renameWorkspace) - let editWorkspaceDescriptionShortcut = KeyboardShortcutSettings.shortcut(for: .editWorkspaceDescription) - let closeWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .closeWorkspace) - Button(pinLabel) { - for id in targetIds { - if let tab = tabManager.tabs.first(where: { $0.id == id }) { - tabManager.setPinned(tab, pinned: shouldPin) - } - } - syncSelectionAfterMutation() - } - - if let key = renameWorkspaceShortcut.keyEquivalent { - Button(String(localized: "contextMenu.renameWorkspace", defaultValue: "Rename Workspace…")) { - promptRename() - } - .keyboardShortcut(key, modifiers: renameWorkspaceShortcut.eventModifiers) - } else { - Button(String(localized: "contextMenu.renameWorkspace", defaultValue: "Rename Workspace…")) { - promptRename() - } - } - - if tab.hasCustomTitle { - Button(String(localized: "contextMenu.removeCustomWorkspaceName", defaultValue: "Remove Custom Workspace Name")) { - tabManager.clearCustomTitle(tabId: tab.id) - } - } - - if !isMulti { - if let key = editWorkspaceDescriptionShortcut.keyEquivalent { - Button(String(localized: "contextMenu.editWorkspaceDescription", defaultValue: "Edit Workspace Description…")) { - beginWorkspaceDescriptionEditFromContextMenu() - } - .keyboardShortcut(key, modifiers: editWorkspaceDescriptionShortcut.eventModifiers) - } else { - Button(String(localized: "contextMenu.editWorkspaceDescription", defaultValue: "Edit Workspace Description…")) { - beginWorkspaceDescriptionEditFromContextMenu() - } - } - - if tab.hasCustomDescription { - Button(String(localized: "contextMenu.clearWorkspaceDescription", defaultValue: "Clear Workspace Description")) { - tabManager.clearCustomDescription(tabId: tab.id) - } - } - } - - if !remoteContextMenuWorkspaceIds.isEmpty { - Divider() - - Button(reconnectLabel) { - for workspace in remoteContextMenuWorkspaces() { - workspace.reconnectRemoteConnection() - } - } - .disabled(allRemoteContextMenuTargetsConnecting) - - Button(disconnectLabel) { - for workspace in remoteContextMenuWorkspaces() { - workspace.disconnectRemoteConnection(clearConfiguration: false) - } - } - .disabled(allRemoteContextMenuTargetsDisconnected) - } - - Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) { - if tab.customColor != nil { - Button { - applyTabColor(nil, targetIds: targetIds) - } label: { - Label(String(localized: "contextMenu.clearColor", defaultValue: "Clear Color"), systemImage: "xmark.circle") - } - } - - Button { - promptCustomColor(targetIds: targetIds) - } label: { - Label(String(localized: "contextMenu.chooseCustomColor", defaultValue: "Choose Custom Color…"), systemImage: "paintpalette") - } - - if !tabColorPalette.isEmpty { - Divider() - } - - ForEach(tabColorPalette, id: \.id) { entry in - Button { - applyTabColor(entry.hex, targetIds: targetIds) - } label: { - Label { - Text(entry.name) - } icon: { - Image(nsImage: coloredCircleImage(color: tabColorSwatchColor(for: entry.hex))) - } - } - } - } - - if let copyableSidebarSSHError { - Button(String(localized: "contextMenu.copySshError", defaultValue: "Copy SSH Error")) { - copyTextToPasteboard(copyableSidebarSSHError) - } - } - - Divider() - - Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) { - moveBy(-1) - } - .disabled(index == 0) - - Button(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")) { - moveBy(1) - } - .disabled(index >= tabManager.tabs.count - 1) - - Button(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")) { - tabManager.moveTabsToTop(Set(targetIds)) - syncSelectionAfterMutation() - } - .disabled(targetIds.isEmpty) - - let referenceWindowId = AppDelegate.shared?.windowId(for: tabManager) - let windowMoveTargets = AppDelegate.shared?.windowMoveTargets(referenceWindowId: referenceWindowId) ?? [] - let moveMenuTitle = targetIds.count > 1 - ? String(localized: "contextMenu.moveWorkspacesToWindow", defaultValue: "Move Workspaces to Window") - : String(localized: "contextMenu.moveWorkspaceToWindow", defaultValue: "Move Workspace to Window") - Menu(moveMenuTitle) { - Button(String(localized: "contextMenu.newWindow", defaultValue: "New Window")) { - moveWorkspacesToNewWindow(targetIds) - } - .disabled(targetIds.isEmpty) - - if !windowMoveTargets.isEmpty { - Divider() - } - - ForEach(windowMoveTargets) { target in - Button(target.label) { - moveWorkspaces(targetIds, toWindow: target.windowId) - } - .disabled(target.isCurrentWindow || targetIds.isEmpty) - } - } - .disabled(targetIds.isEmpty) - - Divider() - - if let key = closeWorkspaceShortcut.keyEquivalent { - Button(closeLabel) { - closeTabs(targetIds, allowPinned: true) - } - .keyboardShortcut(key, modifiers: closeWorkspaceShortcut.eventModifiers) - .disabled(targetIds.isEmpty) - } else { - Button(closeLabel) { - closeTabs(targetIds, allowPinned: true) - } - .disabled(targetIds.isEmpty) - } - - Button(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")) { - closeOtherTabs(targetIds) - } - .disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count) - - Button(String(localized: "contextMenu.closeWorkspacesBelow", defaultValue: "Close Workspaces Below")) { - closeTabsBelow(tabId: tab.id) - } - .disabled(index >= tabManager.tabs.count - 1) - - Button(String(localized: "contextMenu.closeWorkspacesAbove", defaultValue: "Close Workspaces Above")) { - closeTabsAbove(tabId: tab.id) - } - .disabled(index == 0) - - Divider() - - Button(markReadLabel) { - markTabsRead(targetIds) - } - .disabled(!hasUnreadNotifications(in: targetIds)) - - Button(markUnreadLabel) { - markTabsUnread(targetIds) - } - .disabled(!hasReadNotifications(in: targetIds)) - - Button(clearLatestNotificationLabel) { - clearLatestNotifications(targetIds) - } - .disabled(!hasLatestNotifications(in: targetIds)) - } - - private var selectionBackgroundColor: NSColor { - if let hex = sidebarSelectionColorHex, let parsed = NSColor(hex: hex) { - return parsed - } - return programaAccentNSColor(for: colorScheme) - } - - private var backgroundColor: Color { - switch activeTabIndicatorStyle { - case .leftRail: - if isActive { return Color(nsColor: selectionBackgroundColor) } - if isMultiSelected { return programaAccentColor().opacity(0.25) } - return Color.clear - case .solidFill: - if isActive { return Color(nsColor: selectionBackgroundColor) } - if let custom = resolvedCustomTabColor { - if isMultiSelected { return custom.opacity(0.35) } - return custom.opacity(0.7) - } - if isMultiSelected { return programaAccentColor().opacity(0.25) } - return Color.clear - } - } - - private var railColor: Color { - explicitRailColor ?? .clear - } - - private var explicitRailColor: Color? { - guard activeTabIndicatorStyle == .leftRail, - let custom = resolvedCustomTabColor else { - return nil - } - return custom.opacity(0.95) - } - - private var resolvedCustomTabColor: Color? { - guard let hex = tab.customColor else { return nil } - return WorkspaceTabColorSettings.displayColor( - hex: hex, - colorScheme: colorScheme, - forceBright: activeTabIndicatorStyle == .leftRail - ) - } - - private func tabColorSwatchColor(for hex: String) -> NSColor { - WorkspaceTabColorSettings.displayNSColor( - hex: hex, - colorScheme: colorScheme, - forceBright: activeTabIndicatorStyle == .leftRail - ) ?? NSColor(hex: hex) ?? .gray - } - - private var showsCenteredTopDropIndicator: Bool { - guard draggedTabId != nil, let indicator = dropIndicator else { return false } - if indicator.tabId == tab.id && indicator.edge == .top { - return true - } - - guard indicator.edge == .bottom, - let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == tab.id }), - currentIndex > 0 - else { - return false - } - return tabManager.tabs[currentIndex - 1].id == indicator.tabId - } - - private var accessibilityTitle: String { - String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(accessibilityWorkspaceCount)") - } - - private func moveBy(_ delta: Int) { - let targetIndex = index + delta - guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return } - guard tabManager.reorderWorkspace(tabId: tab.id, toIndex: targetIndex) else { return } - selectedTabIds = [tab.id] - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id } - tabManager.selectTab(tab) - setSelectionToTabs() - } - - private func updateSelection() { - #if DEBUG - let mods = NSEvent.modifierFlags - var modStr = "" - if mods.contains(.command) { modStr += "cmd " } - if mods.contains(.shift) { modStr += "shift " } - if mods.contains(.option) { modStr += "opt " } - if mods.contains(.control) { modStr += "ctrl " } - dlog("sidebar.select workspace=\(tab.id.uuidString.prefix(5)) modifiers=\(modStr.isEmpty ? "none" : modStr.trimmingCharacters(in: .whitespaces))") - #endif - let modifiers = NSEvent.modifierFlags - let isCommand = modifiers.contains(.command) - let isShift = modifiers.contains(.shift) - let wasSelected = tabManager.selectedTabId == tab.id - - if isShift, let lastIndex = lastSidebarSelectionIndex { - let lower = min(lastIndex, index) - let upper = max(lastIndex, index) - let rangeIds = tabManager.tabs[lower...upper].map { $0.id } - if isCommand { - selectedTabIds.formUnion(rangeIds) - } else { - selectedTabIds = Set(rangeIds) - } - } else if isCommand { - if selectedTabIds.contains(tab.id) { - selectedTabIds.remove(tab.id) - } else { - selectedTabIds.insert(tab.id) - } - } else { - selectedTabIds = [tab.id] - } - - lastSidebarSelectionIndex = index - tabManager.selectTab(tab) - if wasSelected, !isCommand, !isShift { - tabManager.dismissNotificationOnDirectInteraction( - tabId: tab.id, - surfaceId: tabManager.focusedSurfaceId(for: tab.id) - ) - } - setSelectionToTabs() - } - - private func closeTabs(_ targetIds: [UUID], allowPinned: Bool) { - tabManager.closeWorkspacesWithConfirmation(targetIds, allowPinned: allowPinned) - syncSelectionAfterMutation() - } - - private func closeOtherTabs(_ targetIds: [UUID]) { - let keepIds = Set(targetIds) - let idsToClose = tabManager.tabs.compactMap { keepIds.contains($0.id) ? nil : $0.id } - closeTabs(idsToClose, allowPinned: true) - } - - private func closeTabsBelow(tabId: UUID) { - guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } - let idsToClose = tabManager.tabs.suffix(from: anchorIndex + 1).map { $0.id } - closeTabs(idsToClose, allowPinned: true) - } - - private func closeTabsAbove(tabId: UUID) { - guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } - let idsToClose = tabManager.tabs.prefix(upTo: anchorIndex).map { $0.id } - closeTabs(idsToClose, allowPinned: true) - } - - private func markTabsRead(_ targetIds: [UUID]) { - for id in targetIds { - notificationStore.markRead(forTabId: id) - } - } - - private func markTabsUnread(_ targetIds: [UUID]) { - for id in targetIds { - notificationStore.markUnread(forTabId: id) - } - } - - private func clearLatestNotifications(_ targetIds: [UUID]) { - for id in targetIds { - notificationStore.clearLatestNotification(forTabId: id) - } - } - - private func hasUnreadNotifications(in targetIds: [UUID]) -> Bool { - let targetSet = Set(targetIds) - return notificationStore.notifications.contains { targetSet.contains($0.tabId) && !$0.isRead } - } - - private func hasReadNotifications(in targetIds: [UUID]) -> Bool { - let targetSet = Set(targetIds) - return notificationStore.notifications.contains { targetSet.contains($0.tabId) && $0.isRead } - } - - private func hasLatestNotifications(in targetIds: [UUID]) -> Bool { - targetIds.contains { notificationStore.latestNotification(forTabId: $0) != nil } - } - - private func syncSelectionAfterMutation() { - let existingIds = Set(tabManager.tabs.map { $0.id }) - selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } - if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { - selectedTabIds = [selectedId] - } - if let selectedId = tabManager.selectedTabId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } - } - - private var remoteStateHelpText: String { - let target = tab.remoteDisplayTarget ?? String( - localized: "sidebar.remote.help.targetFallback", - defaultValue: "remote host" - ) - let detail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) - switch tab.remoteConnectionState { - case .connected: - return String( - format: String( - localized: "sidebar.remote.help.connected", - defaultValue: "SSH connected to %@" - ), - locale: .current, - target - ) - case .connecting: - return String( - format: String( - localized: "sidebar.remote.help.connecting", - defaultValue: "SSH connecting to %@" - ), - locale: .current, - target - ) - case .error: - if let detail, !detail.isEmpty { - return String( - format: String( - localized: "sidebar.remote.help.errorWithDetail", - defaultValue: "SSH error for %@: %@" - ), - locale: .current, - target, - detail - ) - } - return String( - format: String( - localized: "sidebar.remote.help.error", - defaultValue: "SSH error for %@" - ), - locale: .current, - target - ) - case .disconnected: - return String( - format: String( - localized: "sidebar.remote.help.disconnected", - defaultValue: "SSH disconnected from %@" - ), - locale: .current, - target - ) - } - } - private func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) { - guard let app = AppDelegate.shared else { return } - let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } - guard !orderedWorkspaceIds.isEmpty else { return } - - for (index, workspaceId) in orderedWorkspaceIds.enumerated() { - let shouldFocus = index == orderedWorkspaceIds.count - 1 - _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: shouldFocus) - } - - selectedTabIds.subtract(orderedWorkspaceIds) - syncSelectionAfterMutation() - } - - private func moveWorkspacesToNewWindow(_ workspaceIds: [UUID]) { - guard let app = AppDelegate.shared else { return } - let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } - guard let firstWorkspaceId = orderedWorkspaceIds.first else { return } - - let shouldFocusImmediately = orderedWorkspaceIds.count == 1 - guard let newWindowId = app.moveWorkspaceToNewWindow(workspaceId: firstWorkspaceId, focus: shouldFocusImmediately) else { - return - } - - if orderedWorkspaceIds.count > 1 { - for workspaceId in orderedWorkspaceIds.dropFirst() { - _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: newWindowId, focus: false) - } - if let finalWorkspaceId = orderedWorkspaceIds.last { - _ = app.moveWorkspaceToWindow(workspaceId: finalWorkspaceId, windowId: newWindowId, focus: true) - } - } - - selectedTabIds.subtract(orderedWorkspaceIds) - syncSelectionAfterMutation() - } - - // 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?, - directorySummary: String? - ) -> String? { - var parts: [String] = [] - - if let gitSummary { - parts.append(gitSummary) - } - - if let directorySummary { - parts.append(directorySummary) - } - - let result = parts.joined(separator: " · ") - return result.isEmpty ? nil : result - } - - private func gitBranchSummaryText(orderedPanelIds: [UUID]) -> String? { - let lines = gitBranchSummaryLines(orderedPanelIds: orderedPanelIds) - guard !lines.isEmpty else { return nil } - return lines.joined(separator: " | ") - } - - private func gitBranchSummaryLines(orderedPanelIds: [UUID]) -> [String] { - tab.sidebarGitBranchesInDisplayOrder(orderedPanelIds: orderedPanelIds).map { branch in - "\(branch.branch)\(branch.isDirty ? "*" : "")" - } - } - - private struct VerticalBranchDirectoryLine { - let branch: String? - let directory: String? - } - - /// Recomputes the expensive sidebar detail caches (bonsplit tree walk, - /// branch/directory lines, PR snapshot) and writes them into @State. - /// Must be called only from the debounced publisher, onAppear, and - /// settings-change handlers — never from the immediate (title) publisher. - private func recomputeSidebarDetailCache() { - let detail = visibleAuxiliaryDetails - let needsDetail = detail.showsBranchDirectory || detail.showsPullRequests - let ids: [UUID]? = needsDetail ? tab.sidebarOrderedPanelIds() : nil - cachedOrderedPanelIds = ids - cachedBranchDirectoryLines = { - guard detail.showsBranchDirectory, sidebarBranchVerticalLayout, let ids else { return [] } - return verticalBranchDirectoryLines(orderedPanelIds: ids) - }() - cachedPullRequestRows = { - guard detail.showsPullRequests, let ids else { return [] } - return pullRequestDisplays(orderedPanelIds: ids) - }() - } - - private func verticalBranchDirectoryLines(orderedPanelIds: [UUID]) -> [VerticalBranchDirectoryLine] { - let entries = tab.sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: orderedPanelIds) - let home = SidebarPathFormatter.homeDirectoryPath - return entries.compactMap { entry in - let branchText: String? = { - guard sidebarShowGitBranch, let branch = entry.branch else { return nil } - return "\(branch)\(entry.isDirty ? "*" : "")" - }() - - let directoryText: String? = { - guard let directory = entry.directory else { return nil } - let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) - return shortened.isEmpty ? nil : shortened - }() - - switch (branchText, directoryText) { - case let (branch?, directory?): - return VerticalBranchDirectoryLine(branch: branch, directory: directory) - case let (branch?, nil): - return VerticalBranchDirectoryLine(branch: branch, directory: nil) - case let (nil, directory?): - return VerticalBranchDirectoryLine(branch: nil, directory: directory) - default: - return nil - } - } - } - - private func directorySummaryText(orderedPanelIds: [UUID]) -> String? { - let home = SidebarPathFormatter.homeDirectoryPath - let entries = tab.sidebarDirectoriesInDisplayOrder(orderedPanelIds: orderedPanelIds).compactMap { directory in - let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) - return shortened.isEmpty ? nil : shortened - } - return entries.isEmpty ? nil : entries.joined(separator: " | ") - } - - private struct PullRequestDisplay: Identifiable { - let id: String - let number: Int - let label: String - let url: URL - let status: SidebarPullRequestStatus - let checks: SidebarPullRequestChecksStatus? - } - - private func pullRequestDisplays(orderedPanelIds: [UUID]) -> [PullRequestDisplay] { - tab.sidebarPullRequestsInDisplayOrder(orderedPanelIds: orderedPanelIds).map { pullRequest in - PullRequestDisplay( - id: "\(pullRequest.label.lowercased())#\(pullRequest.number)|\(pullRequest.url.absoluteString)", - number: pullRequest.number, - label: pullRequest.label, - url: pullRequest.url, - status: pullRequest.status, - checks: pullRequest.checks - ) - } - } - - private var pullRequestForegroundColor: Color { - isActive ? .white.opacity(0.75) : .secondary - } - - private func openPullRequestLink(_ url: URL) { - updateSelection() - if openSidebarPullRequestLinksInProgramaBrowser { - if tabManager.openBrowser( - inWorkspace: tab.id, - url: url, - preferSplitRight: true, - insertAtEnd: true - ) == nil { - NSWorkspace.shared.open(url) - } - return - } - NSWorkspace.shared.open(url) - } - - private func openPortLink(_ port: Int) { - guard let url = URL(string: "http://localhost:\(port)") else { return } - updateSelection() - if openSidebarPortLinksInProgramaBrowser { - if tabManager.openBrowser( - inWorkspace: tab.id, - url: url, - preferSplitRight: true, - insertAtEnd: true - ) == nil { - NSWorkspace.shared.open(url) - } - return - } - NSWorkspace.shared.open(url) - } - - private func pullRequestStatusLabel( - _ status: SidebarPullRequestStatus, - checks _: SidebarPullRequestChecksStatus? - ) -> String { - switch status { - case .open: return String(localized: "sidebar.pullRequest.statusOpen", defaultValue: "open") - case .merged: return String(localized: "sidebar.pullRequest.statusMerged", defaultValue: "merged") - case .closed: return String(localized: "sidebar.pullRequest.statusClosed", defaultValue: "closed") - } - } - - private func logLevelIcon(_ level: SidebarLogLevel) -> String { - switch level { - case .info: return "circle.fill" - case .progress: return "arrowtriangle.right.fill" - case .success: return "checkmark.circle.fill" - case .warning: return "exclamationmark.triangle.fill" - case .error: return "xmark.circle.fill" - } - } - - private func logLevelColor(_ level: SidebarLogLevel, isActive: Bool) -> Color { - if isActive { - switch level { - case .info: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.5)) - case .progress: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.8)) - case .success: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) - case .warning: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) - case .error: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) - } - } - switch level { - case .info: return .secondary - case .progress: return .blue - case .success: return .green - case .warning: return .orange - case .error: return .red - } - } - - private func shortenPath(_ path: String, home: String) -> String { - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return path } - if trimmed == home { - return "~" - } - if trimmed.hasPrefix(home + "/") { - return "~" + trimmed.dropFirst(home.count) - } - return trimmed - } - - private struct PullRequestStatusIcon: View { - let status: SidebarPullRequestStatus - let color: Color - private static let frameSize: CGFloat = 12 - - var body: some View { - switch status { - case .open: - PullRequestOpenIcon(color: color) - case .merged: - PullRequestMergedIcon(color: color) - case .closed: - Image(systemName: "xmark.circle") - .font(.system(size: 7, weight: .regular)) - .foregroundColor(color) - .frame(width: Self.frameSize, height: Self.frameSize) - } - } - } - - private struct PullRequestOpenIcon: View { - let color: Color - private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) - private static let nodeDiameter: CGFloat = 3.0 - private static let frameSize: CGFloat = 13 - - var body: some View { - ZStack { - Path { path in - path.move(to: CGPoint(x: 3.0, y: 4.8)) - path.addLine(to: CGPoint(x: 3.0, y: 9.2)) - - path.move(to: CGPoint(x: 4.8, y: 3.0)) - path.addLine(to: CGPoint(x: 9.4, y: 3.0)) - path.addLine(to: CGPoint(x: 11.0, y: 4.6)) - path.addLine(to: CGPoint(x: 11.0, y: 9.2)) - } - .stroke(color, style: Self.stroke) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 3.0, y: 3.0) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 3.0, y: 11.0) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 11.0, y: 11.0) - } - .frame(width: Self.frameSize, height: Self.frameSize) - } - } - - private struct PullRequestMergedIcon: View { - let color: Color - private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) - private static let nodeDiameter: CGFloat = 3.0 - private static let frameSize: CGFloat = 13 - - var body: some View { - ZStack { - Path { path in - path.move(to: CGPoint(x: 4.6, y: 4.6)) - path.addLine(to: CGPoint(x: 7.1, y: 7.0)) - path.addLine(to: CGPoint(x: 9.2, y: 7.0)) - - path.move(to: CGPoint(x: 4.6, y: 9.4)) - path.addLine(to: CGPoint(x: 7.1, y: 7.0)) - } - .stroke(color, style: Self.stroke) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 3.0, y: 3.0) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 3.0, y: 11.0) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 11.0, y: 7.0) - } - .frame(width: Self.frameSize, height: Self.frameSize) - } - } - - private func applyTabColor(_ hex: String?, targetIds: [UUID]) { - for targetId in targetIds { - tabManager.setTabColor(tabId: targetId, color: hex) - } - } - - private func promptCustomColor(targetIds: [UUID]) { - let alert = NSAlert() - alert.messageText = String(localized: "alert.customColor.title", defaultValue: "Custom Workspace Color") - alert.informativeText = String(localized: "alert.customColor.message", defaultValue: "Enter a hex color in the format #RRGGBB.") - - let seed = tab.customColor ?? WorkspaceTabColorSettings.customPaletteEntries().first?.hex ?? "" - let input = NSTextField(string: seed) - input.placeholderString = "#1565C0" - input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) - alert.accessoryView = input - alert.addButton(withTitle: String(localized: "alert.customColor.apply", defaultValue: "Apply")) - alert.addButton(withTitle: String(localized: "alert.customColor.cancel", defaultValue: "Cancel")) - - let alertWindow = alert.window - alertWindow.initialFirstResponder = input - DispatchQueue.main.async { - alertWindow.makeFirstResponder(input) - input.selectText(nil) - } - - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { return } - guard let normalized = WorkspaceTabColorSettings.addCustomColor(input.stringValue) else { - showInvalidColorAlert(input.stringValue) - return - } - applyTabColor(normalized, targetIds: targetIds) - } - - private func showInvalidColorAlert(_ value: String) { - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String(localized: "alert.invalidColor.title", defaultValue: "Invalid Color") - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - alert.informativeText = String(localized: "alert.invalidColor.emptyMessage", defaultValue: "Enter a hex color in the format #RRGGBB.") - } else { - alert.informativeText = String(localized: "alert.invalidColor.invalidMessage", defaultValue: "\"\(trimmed)\" is not a valid hex color. Use #RRGGBB.") - } - alert.addButton(withTitle: String(localized: "alert.invalidColor.ok", defaultValue: "OK")) - _ = alert.runModal() - } - - private func promptRename() { - let alert = NSAlert() - alert.messageText = String(localized: "alert.renameWorkspace.title", defaultValue: "Rename Workspace") - alert.informativeText = String(localized: "alert.renameWorkspace.message", defaultValue: "Enter a custom name for this workspace.") - let input = NSTextField(string: tab.customTitle ?? tab.title) - input.placeholderString = String(localized: "alert.renameWorkspace.placeholder", defaultValue: "Workspace name") - input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) - alert.accessoryView = input - alert.addButton(withTitle: String(localized: "alert.renameWorkspace.rename", defaultValue: "Rename")) - alert.addButton(withTitle: String(localized: "alert.renameWorkspace.cancel", defaultValue: "Cancel")) - let alertWindow = alert.window - alertWindow.initialFirstResponder = input - DispatchQueue.main.async { - alertWindow.makeFirstResponder(input) - input.selectText(nil) - } - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { return } - tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) - } - - private func beginWorkspaceDescriptionEditFromContextMenu() { - selectedTabIds = [tab.id] - lastSidebarSelectionIndex = index - tabManager.selectTab(tab) - setSelectionToTabs() - _ = AppDelegate.shared?.requestEditWorkspaceDescriptionViaCommandPalette() - } -} - -private struct SidebarWorkspaceDescriptionText: View { - let markdown: String - let isActive: Bool - - var body: some View { - let renderedMarkdown = SidebarMarkdownRenderer.renderWorkspaceDescription(markdown) - Group { - if let renderedMarkdown { - Text(renderedMarkdown) - } else { - Text(markdown) - } - } - .font(.system(size: 10.5)) - .foregroundColor(foregroundColor) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityIdentifier("SidebarWorkspaceDescriptionText") - .accessibilityLabel(accessibilityText(renderedMarkdown: renderedMarkdown)) - .onAppear { -#if DEBUG - let newlineCount = markdown.reduce(into: 0) { count, character in - if character == "\n" { count += 1 } - } - dlog( - "sidebar.description.render workspaceState=appear " + - "len=\((markdown as NSString).length) " + - "newlines=\(newlineCount) " + - "text=\"\(debugCommandPaletteTextPreview(markdown))\"" - ) -#endif - } - .onChange(of: markdown) { newValue in -#if DEBUG - let newlineCount = newValue.reduce(into: 0) { count, character in - if character == "\n" { count += 1 } - } - dlog( - "sidebar.description.render workspaceState=change " + - "len=\((newValue as NSString).length) " + - "newlines=\(newlineCount) " + - "text=\"\(debugCommandPaletteTextPreview(newValue))\"" - ) -#endif - } - } - - private var foregroundColor: Color { - isActive ? .white.opacity(0.84) : .secondary.opacity(0.95) - } - - private func accessibilityText(renderedMarkdown: AttributedString?) -> String { - if let renderedMarkdown { - return String(renderedMarkdown.characters) - } - return markdown - } -} - -enum SidebarMarkdownRenderer { - static func renderWorkspaceDescription(_ markdown: String) -> AttributedString? { - try? AttributedString( - markdown: markdown, - options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - ) - } -} - -private struct SidebarMetadataRows: View { - let entries: [SidebarStatusEntry] - let isActive: Bool - let onFocus: () -> Void - - @State private var isExpanded: Bool = false - private let collapsedEntryLimit = 3 - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - ForEach(visibleEntries, id: \.key) { entry in - SidebarMetadataEntryRow(entry: entry, isActive: isActive, onFocus: onFocus) - } - - if shouldShowToggle { - Button(isExpanded ? String(localized: "sidebar.metadata.showLess", defaultValue: "Show less") : String(localized: "sidebar.metadata.showMore", defaultValue: "Show more")) { - onFocus() - withAnimation(.easeInOut(duration: 0.15)) { - isExpanded.toggle() - } - } - .buttonStyle(.plain) - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(isActive ? activeSecondaryTextColor : .secondary.opacity(0.9)) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .safeHelp(helpText) - } - - private var activeSecondaryTextColor: Color { - Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65)) - } - - private var visibleEntries: [SidebarStatusEntry] { - guard !isExpanded, entries.count > collapsedEntryLimit else { return entries } - return Array(entries.prefix(collapsedEntryLimit)) - } - - private var helpText: String { - entries.map { entry in - let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? entry.key : trimmed - } - .joined(separator: "\n") - } - - private var shouldShowToggle: Bool { - entries.count > collapsedEntryLimit - } -} - -private struct SidebarMetadataEntryRow: View { - let entry: SidebarStatusEntry - let isActive: Bool - let onFocus: () -> Void - - var body: some View { - Group { - if let url = entry.url { - Button { - onFocus() - NSWorkspace.shared.open(url) - } label: { - rowContent(underlined: true) - } - .buttonStyle(.plain) - .safeHelp(url.absoluteString) - } else { - rowContent(underlined: false) - .contentShape(Rectangle()) - .onTapGesture { onFocus() } - } - } - } - - @ViewBuilder - private func rowContent(underlined: Bool) -> some View { - HStack(spacing: 4) { - if let icon = iconView { - icon - .foregroundColor(foregroundColor.opacity(0.95)) - } - metadataText(underlined: underlined) - .lineLimit(1) - .truncationMode(.tail) - Spacer(minLength: 0) - } - .font(.system(size: 10)) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var foregroundColor: Color { - if isActive, - let raw = entry.color, - Color(hex: raw) != nil { - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.95)) - } - if let raw = entry.color, let explicit = Color(hex: raw) { - return explicit - } - return isActive ? .white.opacity(0.8) : .secondary - } - - private var iconView: AnyView? { - guard let iconRaw = entry.icon?.trimmingCharacters(in: .whitespacesAndNewlines), - !iconRaw.isEmpty else { - return nil - } - if iconRaw.hasPrefix("emoji:") { - let value = String(iconRaw.dropFirst("emoji:".count)) - guard !value.isEmpty else { return nil } - return AnyView(Text(value).font(.system(size: 9))) - } - if iconRaw.hasPrefix("text:") { - let value = String(iconRaw.dropFirst("text:".count)) - guard !value.isEmpty else { return nil } - return AnyView(Text(value).font(.system(size: 8, weight: .semibold))) - } - let symbolName: String - if iconRaw.hasPrefix("sf:") { - symbolName = String(iconRaw.dropFirst("sf:".count)) - } else { - symbolName = iconRaw - } - guard !symbolName.isEmpty else { return nil } - return AnyView(Image(systemName: symbolName).font(.system(size: 8, weight: .medium))) - } - - @ViewBuilder - private func metadataText(underlined: Bool) -> some View { - let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) - let display = trimmed.isEmpty ? entry.key : trimmed - if entry.format == .markdown, - let attributed = try? AttributedString( - markdown: display, - options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - ) { - Text(attributed) - .underline(underlined) - .foregroundColor(foregroundColor) - } else { - Text(display) - .underline(underlined) - .foregroundColor(foregroundColor) - } - } -} - -private struct SidebarMetadataMarkdownBlocks: View { - let blocks: [SidebarMetadataBlock] - let isActive: Bool - let onFocus: () -> Void - - @State private var isExpanded: Bool = false - private let collapsedBlockLimit = 1 - - var body: some View { - VStack(alignment: .leading, spacing: 3) { - ForEach(visibleBlocks, id: \.key) { block in - SidebarMetadataMarkdownBlockRow( - block: block, - isActive: isActive, - onFocus: onFocus - ) - } - - if shouldShowToggle { - Button(isExpanded ? String(localized: "sidebar.metadata.showLessDetails", defaultValue: "Show less details") : String(localized: "sidebar.metadata.showMoreDetails", defaultValue: "Show more details")) { - onFocus() - withAnimation(.easeInOut(duration: 0.15)) { - isExpanded.toggle() - } - } - .buttonStyle(.plain) - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9)) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } - - private var visibleBlocks: [SidebarMetadataBlock] { - guard !isExpanded, blocks.count > collapsedBlockLimit else { return blocks } - return Array(blocks.prefix(collapsedBlockLimit)) - } - - private var shouldShowToggle: Bool { - blocks.count > collapsedBlockLimit - } -} - -private struct SidebarMetadataMarkdownBlockRow: View { - let block: SidebarMetadataBlock - let isActive: Bool - let onFocus: () -> Void - - @State private var renderedMarkdown: AttributedString? - - var body: some View { - Group { - if let renderedMarkdown { - Text(renderedMarkdown) - .foregroundColor(foregroundColor) - } else { - Text(block.markdown) - .foregroundColor(foregroundColor) - } - } - .font(.system(size: 10)) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .contentShape(Rectangle()) - .onTapGesture { onFocus() } - .onAppear(perform: renderMarkdown) - .onChange(of: block.markdown) { _ in - renderMarkdown() - } - } - - private var foregroundColor: Color { - isActive ? .white.opacity(0.8) : .secondary - } - - private func renderMarkdown() { - renderedMarkdown = try? AttributedString( - markdown: block.markdown, - options: .init(interpretedSyntax: .full) - ) - } -} - enum SidebarDropEdge: Equatable { case top case bottom diff --git a/Sources/TabItemView.swift b/Sources/TabItemView.swift new file mode 100644 index 00000000000..3194f78a2fc --- /dev/null +++ b/Sources/TabItemView.swift @@ -0,0 +1,1965 @@ +// TabItemView + its exclusive helper-view cluster, extracted from ContentView.swift (nuclear-review #94.2). +// Pure move — behavior-identical relocation. Do NOT touch TabItemView's Equatable +// conformance, its precomputed let parameters, or the .equatable() call site in +// VerticalTabsSidebar (ContentView.swift) — see CLAUDE.md typing-latency-sensitive paths. +// +// Access-level widening: TabItemView was `private struct` (file-private to the old +// ContentView.swift); widened to internal (default) so VerticalTabsSidebar, which stays +// in ContentView.swift, can still construct it. No other behavior change. + +import AppKit +import Bonsplit +import Combine +import SwiftUI + + +// 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. +// Reactive workspace state inside the row must not rely on parent diffs alone: +// `.equatable()` can otherwise leave sidebar badges/details stale until an +// unrelated parent change sneaks through. Keep the workspace reference plain +// and bridge only sidebar-visible workspace changes into local state. +// Do NOT add @EnvironmentObject or new @Binding without updating ==. +// Do NOT remove .equatable() from the ForEach call site in VerticalTabsSidebar. +struct TabItemView: View, Equatable { + private static let workspaceObservationCoalesceInterval: RunLoop.SchedulerTimeType.Stride = .milliseconds(40) + + // 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.workspaceShortcutDigit == rhs.workspaceShortcutDigit && + lhs.workspaceShortcutModifierSymbol == rhs.workspaceShortcutModifierSymbol && + lhs.canCloseWorkspace == rhs.canCloseWorkspace && + lhs.accessibilityWorkspaceCount == rhs.accessibilityWorkspaceCount && + lhs.unreadCount == rhs.unreadCount && + lhs.latestNotificationText == rhs.latestNotificationText && + lhs.rowSpacing == rhs.rowSpacing && + lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints && + lhs.contextMenuWorkspaceIds == rhs.contextMenuWorkspaceIds && + lhs.remoteContextMenuWorkspaceIds == rhs.remoteContextMenuWorkspaceIds && + lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting && + lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected && + lhs.settings == rhs.settings + } + + // 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 + let tab: Tab + let index: Int + let isActive: Bool + let workspaceShortcutDigit: Int? + let workspaceShortcutModifierSymbol: String + let canCloseWorkspace: Bool + let accessibilityWorkspaceCount: Int + let unreadCount: Int + let latestNotificationText: String? + let rowSpacing: CGFloat + let setSelectionToTabs: () -> Void + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let showsModifierShortcutHints: Bool + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var draggedTabId: UUID? + @Binding var dropIndicator: SidebarDropIndicator? + let contextMenuWorkspaceIds: [UUID] + let remoteContextMenuWorkspaceIds: [UUID] + let allRemoteContextMenuTargetsConnecting: Bool + let allRemoteContextMenuTargetsDisconnected: Bool + let settings: SidebarTabItemSettingsSnapshot + @State private var workspaceObservationGeneration: UInt64 = 0 + @State private var isHovering = false + @State private var rowHeight: CGFloat = 1 + // Cached results of the expensive bonsplit tree walk + branch/dir/PR snapshot. + // Updated only by the debounced publisher, onAppear, and settings changes — + // NOT by the immediate publisher (title keystrokes). This prevents the tree + // walk from running on every keystroke in single-panel workspaces. + @State private var cachedOrderedPanelIds: [UUID]? = nil + @State private var cachedBranchDirectoryLines: [VerticalBranchDirectoryLine] = [] + @State private var cachedPullRequestRows: [PullRequestDisplay] = [] + + var isMultiSelected: Bool { + selectedTabIds.contains(tab.id) + } + + private var isBeingDragged: Bool { + draggedTabId == tab.id + } + + private var sidebarShortcutHintXOffset: Double { + settings.sidebarShortcutHintXOffset + } + + private var sidebarShortcutHintYOffset: Double { + settings.sidebarShortcutHintYOffset + } + + private var alwaysShowShortcutHints: Bool { + settings.alwaysShowShortcutHints + } + + private var sidebarShowGitBranch: Bool { + settings.showsGitBranch + } + + private var sidebarBranchVerticalLayout: Bool { + settings.usesVerticalBranchLayout + } + + private var sidebarShowGitBranchIcon: Bool { + settings.showsGitBranchIcon + } + + private var sidebarShowSSH: Bool { + settings.showsSSH + } + + private var activeTabIndicatorStyle: SidebarActiveTabIndicatorStyle { + settings.activeTabIndicatorStyle + } + + private var sidebarSelectionColorHex: String? { + settings.selectionColorHex + } + + private var sidebarNotificationBadgeColorHex: String? { + settings.notificationBadgeColorHex + } + + private var openSidebarPullRequestLinksInProgramaBrowser: Bool { + settings.openPullRequestLinksInProgramaBrowser + } + + private var openSidebarPortLinksInProgramaBrowser: Bool { + settings.openPortLinksInProgramaBrowser + } + + private var titleFontWeight: Font.Weight { + .semibold + } + + private var showsLeadingRail: Bool { + explicitRailColor != nil + } + + private var activeBorderLineWidth: CGFloat { + switch activeTabIndicatorStyle { + case .leftRail: + return 0 + case .solidFill: + return isActive ? 1.5 : 0 + } + } + + private var activeBorderColor: Color { + guard isActive else { return .clear } + switch activeTabIndicatorStyle { + case .leftRail: + return .clear + case .solidFill: + return Color.primary.opacity(0.5) + } + } + + private var usesInvertedActiveForeground: Bool { + isActive + } + + private var activePrimaryTextColor: Color { + usesInvertedActiveForeground + ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 1.0)) + : .primary + } + + private func activeSecondaryColor(_ opacity: Double = 0.75) -> Color { + usesInvertedActiveForeground + ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat(opacity))) + : .secondary + } + + private var activeUnreadBadgeFillColor: Color { + if let hex = sidebarNotificationBadgeColorHex, let nsColor = NSColor(hex: hex) { + return Color(nsColor: nsColor) + } + return usesInvertedActiveForeground ? Color.white.opacity(0.25) : programaAccentColor() + } + + private var activeProgressTrackColor: Color { + usesInvertedActiveForeground ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2) + } + + private var activeProgressFillColor: Color { + usesInvertedActiveForeground ? Color.white.opacity(0.8) : programaAccentColor() + } + + private var shortcutHintEmphasis: Double { + usesInvertedActiveForeground ? 1.0 : 0.9 + } + + private var showCloseButton: Bool { + isHovering && canCloseWorkspace && !(showsModifierShortcutHints || alwaysShowShortcutHints) + } + + private var workspaceShortcutLabel: String? { + guard let workspaceShortcutDigit else { return nil } + return "\(workspaceShortcutModifierSymbol)\(workspaceShortcutDigit)" + } + + private var showsWorkspaceShortcutHint: Bool { + (showsModifierShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil + } + + private var trailingAccessoryWidth: CGFloat { + SidebarTrailingAccessoryWidthPolicy.width( + canCloseWorkspace: canCloseWorkspace, + showsWorkspaceShortcutHint: showsWorkspaceShortcutHint, + workspaceShortcutLabel: workspaceShortcutLabel, + debugXOffset: sidebarShortcutHintXOffset + ) + } + + private var remoteWorkspaceSidebarText: String? { + guard tab.hasActiveRemoteTerminalSessions else { return nil } + let trimmedTarget = tab.remoteDisplayTarget?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedTarget, !trimmedTarget.isEmpty { + return trimmedTarget + } + return String(localized: "sidebar.remote.subtitleFallback", defaultValue: "SSH workspace") + } + + private var copyableSidebarSSHError: String? { + let fallbackTarget = tab.remoteDisplayTarget ?? String( + localized: "sidebar.remote.help.targetFallback", + defaultValue: "remote host" + ) + let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) + if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty { + let entry = SidebarRemoteErrorCopyEntry( + workspaceTitle: tab.title, + target: fallbackTarget, + detail: trimmedDetail + ) + return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) + } + if let statusValue = tab.statusEntries["remote.error"]?.value + .trimmingCharacters(in: .whitespacesAndNewlines), + !statusValue.isEmpty { + let entry = SidebarRemoteErrorCopyEntry( + workspaceTitle: tab.title, + target: fallbackTarget, + detail: statusValue + ) + return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) + } + return nil + } + + private var remoteConnectionStatusText: String { + switch tab.remoteConnectionState { + case .connected: + return String(localized: "remote.status.connected", defaultValue: "Connected") + case .connecting: + return String(localized: "remote.status.connecting", defaultValue: "Connecting") + case .error: + return String(localized: "remote.status.error", defaultValue: "Error") + case .disconnected: + return String(localized: "remote.status.disconnected", defaultValue: "Disconnected") + } + } + + @ViewBuilder + private var remoteWorkspaceSection: some View { + if sidebarShowSSH, let remoteWorkspaceSidebarText { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(remoteWorkspaceSidebarText) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.8)) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: 0) + + Text(remoteConnectionStatusText) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(activeSecondaryColor(0.58)) + .lineLimit(1) + } + } + .padding(.top, latestNotificationText == nil ? 1 : 2) + .safeHelp(remoteStateHelpText) + } + } + + private func copyTextToPasteboard(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } + + private var visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility { + settings.visibleAuxiliaryDetails + } + + var body: some View { + let _ = workspaceObservationGeneration + let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace") + let protectedWorkspaceTooltip = String( + localized: "sidebar.pinnedWorkspaceProtected.tooltip", + defaultValue: "Pinned workspace. Closing requires confirmation." + ) + let closeButtonTooltip = tab.isPinned + ? protectedWorkspaceTooltip + : KeyboardShortcutSettings.Action.closeWorkspace.tooltip(closeWorkspaceTooltip) + let accessibilityHintText = String(localized: "sidebar.workspace.accessibilityHint", defaultValue: "Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions.") + let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") + let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") + let latestNotificationSubtitle = latestNotificationText + let effectiveSubtitle = latestNotificationSubtitle + let detailVisibility = visibleAuxiliaryDetails + // Read from cache — updated only by the debounced publisher, onAppear, + // and settings changes. Title-keystroke re-renders skip this tree walk. + let orderedPanelIds: [UUID]? = cachedOrderedPanelIds + let branchDirectoryLines: [VerticalBranchDirectoryLine] = cachedBranchDirectoryLines + let pullRequestRows: [PullRequestDisplay] = cachedPullRequestRows + let compactGitBranchSummaryText: String? = { + guard detailVisibility.showsBranchDirectory, + !sidebarBranchVerticalLayout, + sidebarShowGitBranch, + let orderedPanelIds else { + return nil + } + return gitBranchSummaryText(orderedPanelIds: orderedPanelIds) + }() + let compactDirectorySummaryText: String? = { + guard detailVisibility.showsBranchDirectory, + !sidebarBranchVerticalLayout, + let orderedPanelIds else { + return nil + } + return directorySummaryText(orderedPanelIds: orderedPanelIds) + }() + let compactBranchDirectoryRow = branchDirectoryRow( + gitSummary: compactGitBranchSummaryText, + directorySummary: compactDirectorySummaryText + ) + let branchLinesContainBranch = sidebarShowGitBranch && branchDirectoryLines.contains { $0.branch != nil } + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + if unreadCount > 0 { + ZStack { + Circle() + .fill(activeUnreadBadgeFillColor) + Text("\(unreadCount)") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.white) + } + .frame(width: 16, height: 16) + } + + if tab.isPinned { + Image(systemName: "pin.fill") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(activeSecondaryColor(0.8)) + .safeHelp(protectedWorkspaceTooltip) + } + + Text(tab.title) + .font(.system(size: 12.5, weight: titleFontWeight)) + .foregroundColor(activePrimaryTextColor) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + + Spacer(minLength: 0) + + ZStack(alignment: .trailing) { + Button(action: { + #if DEBUG + dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button") + #endif + tabManager.closeWorkspaceWithConfirmation(tab) + }) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(activeSecondaryColor(0.7)) + } + .buttonStyle(.plain) + .safeHelp(closeButtonTooltip) + .frame(width: SidebarTrailingAccessoryWidthPolicy.closeButtonWidth, height: 16, alignment: .center) + .opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0) + .allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint) + + if showsWorkspaceShortcutHint, let workspaceShortcutLabel { + Text(workspaceShortcutLabel) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundColor(activePrimaryTextColor) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(ShortcutHintPillBackground(emphasis: shortcutHintEmphasis)) + .offset( + x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset), + y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset) + ) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.14), value: showsModifierShortcutHints || alwaysShowShortcutHints) + .frame(width: trailingAccessoryWidth, height: 16, alignment: .trailing) + } + + if let description = tab.customDescription { + SidebarWorkspaceDescriptionText( + markdown: description, + isActive: usesInvertedActiveForeground + ) + .id(description) + } + + if let subtitle = effectiveSubtitle { + Text(subtitle) + .font(.system(size: 10)) + .foregroundColor(activeSecondaryColor(0.8)) + .lineLimit(2) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + } + + remoteWorkspaceSection + + if detailVisibility.showsMetadata { + let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder() + let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() + if !metadataEntries.isEmpty { + SidebarMetadataRows( + entries: metadataEntries, + isActive: usesInvertedActiveForeground, + onFocus: { updateSelection() } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } + if !metadataBlocks.isEmpty { + SidebarMetadataMarkdownBlocks( + blocks: metadataBlocks, + isActive: usesInvertedActiveForeground, + onFocus: { updateSelection() } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + + // Latest log entry + if detailVisibility.showsLog, let latestLog = tab.logEntries.last { + HStack(spacing: 4) { + Image(systemName: logLevelIcon(latestLog.level)) + .font(.system(size: 8)) + .foregroundColor(logLevelColor(latestLog.level, isActive: usesInvertedActiveForeground)) + Text(latestLog.message) + .font(.system(size: 10)) + .foregroundColor(activeSecondaryColor(0.8)) + .lineLimit(1) + .truncationMode(.tail) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + + // Progress bar + if detailVisibility.showsProgress, let progress = tab.progress { + VStack(alignment: .leading, spacing: 2) { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(activeProgressTrackColor) + Capsule() + .fill(activeProgressFillColor) + .frame(width: max(0, geo.size.width * CGFloat(progress.value))) + } + } + .frame(height: 3) + + if let label = progress.label { + Text(label) + .font(.system(size: 9)) + .foregroundColor(activeSecondaryColor(0.6)) + .lineLimit(1) + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + + // Branch + directory row + if detailVisibility.showsBranchDirectory { + if sidebarBranchVerticalLayout { + if !branchDirectoryLines.isEmpty { + HStack(alignment: .top, spacing: 3) { + if sidebarShowGitBranchIcon, branchLinesContainBranch { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9)) + .foregroundColor(activeSecondaryColor(0.6)) + } + VStack(alignment: .leading, spacing: 1) { + ForEach(Array(branchDirectoryLines.enumerated()), id: \.offset) { _, line in + HStack(spacing: 3) { + if let branch = line.branch { + Text(branch) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) + } + if line.branch != nil, line.directory != nil { + Image(systemName: "circle.fill") + .font(.system(size: 3)) + .foregroundColor(activeSecondaryColor(0.6)) + .padding(.horizontal, 1) + } + if let directory = line.directory { + Text(directory) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + } + } + } + } else if let dirRow = compactBranchDirectoryRow { + HStack(spacing: 3) { + if sidebarShowGitBranchIcon, compactGitBranchSummaryText != nil { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9)) + .foregroundColor(activeSecondaryColor(0.6)) + } + Text(dirRow) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + + // Pull request rows + if detailVisibility.showsPullRequests, !pullRequestRows.isEmpty { + VStack(alignment: .leading, spacing: 1) { + ForEach(pullRequestRows) { pullRequest in + Button(action: { + openPullRequestLink(pullRequest.url) + }) { + HStack(spacing: 4) { + PullRequestStatusIcon( + status: pullRequest.status, + color: pullRequestForegroundColor + ) + Text("\(pullRequest.label) #\(pullRequest.number)") + .underline() + .lineLimit(1) + .truncationMode(.tail) + Text(pullRequestStatusLabel(pullRequest.status, checks: pullRequest.checks)) + .lineLimit(1) + Spacer(minLength: 0) + } + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(pullRequestForegroundColor) + } + .buttonStyle(.plain) + .safeHelp(String(localized: "sidebar.pullRequest.openTooltip", defaultValue: "Open \(pullRequest.label) #\(pullRequest.number)")) + } + } + } + + // Ports row + if detailVisibility.showsPorts, !tab.listeningPorts.isEmpty { + HStack(spacing: 4) { + ForEach(tab.listeningPorts, id: \.self) { port in + Button(action: { + openPortLink(port) + }) { + Text(String(localized: "sidebar.port.label", defaultValue: ":\(port)")) + .underline() + } + .buttonStyle(.plain) + .safeHelp(String(localized: "sidebar.port.openTooltip", defaultValue: "Open localhost:\(port)")) + } + Spacer(minLength: 0) + } + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + } + } + .animation(.easeInOut(duration: 0.2), value: tab.logEntries.count) + .animation(.easeInOut(duration: 0.2), value: tab.progress != nil) + .animation(.easeInOut(duration: 0.2), value: tab.metadataBlocks.count) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(backgroundColor) + .overlay { + RoundedRectangle(cornerRadius: 6) + .strokeBorder(activeBorderColor, lineWidth: activeBorderLineWidth) + } + .overlay(alignment: .leading) { + if showsLeadingRail { + Capsule(style: .continuous) + .fill(railColor) + .frame(width: 3) + .padding(.leading, 4) + .padding(.vertical, 5) + .offset(x: -1) + } + } + ) + .padding(.horizontal, 6) + .background { + GeometryReader { proxy in + Color.clear + .onAppear { + rowHeight = max(proxy.size.height, 1) + } + .onChange(of: proxy.size.height) { newHeight in + rowHeight = max(newHeight, 1) + } + } + } + .contentShape(Rectangle()) + .opacity(isBeingDragged ? 0.6 : 1) + .overlay { + MiddleClickCapture { + #if DEBUG + dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=middleClick") + #endif + tabManager.closeWorkspaceWithConfirmation(tab) + } + } + .overlay(alignment: .top) { + if showsCenteredTopDropIndicator { + Rectangle() + .fill(programaAccentColor()) + .frame(height: 2) + .padding(.horizontal, 8) + .offset(y: index == 0 ? 0 : -(rowSpacing / 2)) + } + } + .onReceive( + tab.sidebarImmediateObservationPublisher + .receive(on: RunLoop.main) + ) { _ in +#if DEBUG + let description = tab.customDescription ?? "" + dlog( + "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + + "source=immediate " + + "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + + "descLen=\((description as NSString).length) " + + "desc=\"\(debugCommandPaletteTextPreview(description))\"" + ) +#endif + workspaceObservationGeneration &+= 1 + } + .onReceive( + tab.sidebarObservationPublisher + .receive(on: RunLoop.main) + // Prompt-time sidebar telemetry can arrive as a short burst + // (pwd, branch, PR, shell state). Coalesce that burst so the + // row redraws once with the settled state instead of blinking. + .debounce(for: Self.workspaceObservationCoalesceInterval, scheduler: RunLoop.main) + ) { _ in +#if DEBUG + let description = tab.customDescription ?? "" + dlog( + "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + + "source=debounced " + + "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + + "descLen=\((description as NSString).length) " + + "desc=\"\(debugCommandPaletteTextPreview(description))\"" + ) +#endif + // Refresh expensive caches (tree walk, branch/dir/PR) before + // signalling a redraw. The immediate publisher intentionally does + // NOT call this so that title keystrokes skip the tree walk. + recomputeSidebarDetailCache() + workspaceObservationGeneration &+= 1 + } + .onDrag { + #if DEBUG + dlog("sidebar.onDrag tab=\(tab.id.uuidString.prefix(5))") + #endif + draggedTabId = tab.id + dropIndicator = nil + return SidebarTabDragPayload.provider(for: tab.id) + } + .internalOnlyTabDrag() + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( + targetTabId: tab.id, + tabManager: tabManager, + draggedTabId: $draggedTabId, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + targetRowHeight: rowHeight, + dragAutoScrollController: dragAutoScrollController, + dropIndicator: $dropIndicator + )) + .onDrop(of: BonsplitTabDragPayload.dropContentTypes, delegate: SidebarBonsplitTabDropDelegate( + targetWorkspaceId: tab.id, + tabManager: tabManager, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex + )) + .onTapGesture { + updateSelection() + } + .onHover { hovering in + isHovering = hovering + } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(accessibilityTitle)) + .accessibilityHint(Text(accessibilityHintText)) + .accessibilityAction(named: Text(moveUpActionText)) { + moveBy(-1) + } + .accessibilityAction(named: Text(moveDownActionText)) { + moveBy(1) + } + .onAppear { + // Prime the cache so branch/dir/PR rows appear immediately, + // before the first debounced publisher fires. + recomputeSidebarDetailCache() + } + .onChange(of: visibleAuxiliaryDetails) { _ in + // Toggling branch/PR columns changes which data we need to cache. + recomputeSidebarDetailCache() + } + .contextMenu { workspaceContextMenu } + } + + private func contextMenuLabel(multi: String, single: String, isMulti: Bool) -> String { + isMulti ? multi : single + } + + private func remoteContextMenuWorkspaces() -> [Workspace] { + guard !remoteContextMenuWorkspaceIds.isEmpty else { return [] } + return remoteContextMenuWorkspaceIds.compactMap { workspaceId in + tabManager.tabs.first(where: { $0.id == workspaceId }) + } + } + + @ViewBuilder + private var workspaceContextMenu: some View { + let targetIds = contextMenuWorkspaceIds + let isMulti = targetIds.count > 1 + let tabColorPalette = WorkspaceTabColorSettings.palette() + let shouldPin = !tab.isPinned + let reconnectLabel = contextMenuLabel( + multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"), + single: String(localized: "contextMenu.reconnectWorkspace", defaultValue: "Reconnect Workspace"), + isMulti: isMulti) + let disconnectLabel = contextMenuLabel( + multi: String(localized: "contextMenu.disconnectWorkspaces", defaultValue: "Disconnect Workspaces"), + single: String(localized: "contextMenu.disconnectWorkspace", defaultValue: "Disconnect Workspace"), + isMulti: isMulti) + let pinLabel = shouldPin + ? contextMenuLabel( + multi: String(localized: "contextMenu.pinWorkspaces", defaultValue: "Pin Workspaces"), + single: String(localized: "contextMenu.pinWorkspace", defaultValue: "Pin Workspace"), + isMulti: isMulti) + : contextMenuLabel( + multi: String(localized: "contextMenu.unpinWorkspaces", defaultValue: "Unpin Workspaces"), + single: String(localized: "contextMenu.unpinWorkspace", defaultValue: "Unpin Workspace"), + isMulti: isMulti) + let closeLabel = contextMenuLabel( + multi: String(localized: "contextMenu.closeWorkspaces", defaultValue: "Close Workspaces"), + single: String(localized: "contextMenu.closeWorkspace", defaultValue: "Close Workspace"), + isMulti: isMulti) + let markReadLabel = contextMenuLabel( + multi: String(localized: "contextMenu.markWorkspacesRead", defaultValue: "Mark Workspaces as Read"), + single: String(localized: "contextMenu.markWorkspaceRead", defaultValue: "Mark Workspace as Read"), + isMulti: isMulti) + let markUnreadLabel = contextMenuLabel( + multi: String(localized: "contextMenu.markWorkspacesUnread", defaultValue: "Mark Workspaces as Unread"), + single: String(localized: "contextMenu.markWorkspaceUnread", defaultValue: "Mark Workspace as Unread"), + isMulti: isMulti) + let clearLatestNotificationLabel = contextMenuLabel( + multi: String(localized: "contextMenu.clearLatestNotifications", defaultValue: "Clear Latest Notifications"), + single: String(localized: "contextMenu.clearLatestNotification", defaultValue: "Clear Latest Notification"), + isMulti: isMulti) + let renameWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .renameWorkspace) + let editWorkspaceDescriptionShortcut = KeyboardShortcutSettings.shortcut(for: .editWorkspaceDescription) + let closeWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .closeWorkspace) + Button(pinLabel) { + for id in targetIds { + if let tab = tabManager.tabs.first(where: { $0.id == id }) { + tabManager.setPinned(tab, pinned: shouldPin) + } + } + syncSelectionAfterMutation() + } + + if let key = renameWorkspaceShortcut.keyEquivalent { + Button(String(localized: "contextMenu.renameWorkspace", defaultValue: "Rename Workspace…")) { + promptRename() + } + .keyboardShortcut(key, modifiers: renameWorkspaceShortcut.eventModifiers) + } else { + Button(String(localized: "contextMenu.renameWorkspace", defaultValue: "Rename Workspace…")) { + promptRename() + } + } + + if tab.hasCustomTitle { + Button(String(localized: "contextMenu.removeCustomWorkspaceName", defaultValue: "Remove Custom Workspace Name")) { + tabManager.clearCustomTitle(tabId: tab.id) + } + } + + if !isMulti { + if let key = editWorkspaceDescriptionShortcut.keyEquivalent { + Button(String(localized: "contextMenu.editWorkspaceDescription", defaultValue: "Edit Workspace Description…")) { + beginWorkspaceDescriptionEditFromContextMenu() + } + .keyboardShortcut(key, modifiers: editWorkspaceDescriptionShortcut.eventModifiers) + } else { + Button(String(localized: "contextMenu.editWorkspaceDescription", defaultValue: "Edit Workspace Description…")) { + beginWorkspaceDescriptionEditFromContextMenu() + } + } + + if tab.hasCustomDescription { + Button(String(localized: "contextMenu.clearWorkspaceDescription", defaultValue: "Clear Workspace Description")) { + tabManager.clearCustomDescription(tabId: tab.id) + } + } + } + + if !remoteContextMenuWorkspaceIds.isEmpty { + Divider() + + Button(reconnectLabel) { + for workspace in remoteContextMenuWorkspaces() { + workspace.reconnectRemoteConnection() + } + } + .disabled(allRemoteContextMenuTargetsConnecting) + + Button(disconnectLabel) { + for workspace in remoteContextMenuWorkspaces() { + workspace.disconnectRemoteConnection(clearConfiguration: false) + } + } + .disabled(allRemoteContextMenuTargetsDisconnected) + } + + Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) { + if tab.customColor != nil { + Button { + applyTabColor(nil, targetIds: targetIds) + } label: { + Label(String(localized: "contextMenu.clearColor", defaultValue: "Clear Color"), systemImage: "xmark.circle") + } + } + + Button { + promptCustomColor(targetIds: targetIds) + } label: { + Label(String(localized: "contextMenu.chooseCustomColor", defaultValue: "Choose Custom Color…"), systemImage: "paintpalette") + } + + if !tabColorPalette.isEmpty { + Divider() + } + + ForEach(tabColorPalette, id: \.id) { entry in + Button { + applyTabColor(entry.hex, targetIds: targetIds) + } label: { + Label { + Text(entry.name) + } icon: { + Image(nsImage: coloredCircleImage(color: tabColorSwatchColor(for: entry.hex))) + } + } + } + } + + if let copyableSidebarSSHError { + Button(String(localized: "contextMenu.copySshError", defaultValue: "Copy SSH Error")) { + copyTextToPasteboard(copyableSidebarSSHError) + } + } + + Divider() + + Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) { + moveBy(-1) + } + .disabled(index == 0) + + Button(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")) { + moveBy(1) + } + .disabled(index >= tabManager.tabs.count - 1) + + Button(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")) { + tabManager.moveTabsToTop(Set(targetIds)) + syncSelectionAfterMutation() + } + .disabled(targetIds.isEmpty) + + let referenceWindowId = AppDelegate.shared?.windowId(for: tabManager) + let windowMoveTargets = AppDelegate.shared?.windowMoveTargets(referenceWindowId: referenceWindowId) ?? [] + let moveMenuTitle = targetIds.count > 1 + ? String(localized: "contextMenu.moveWorkspacesToWindow", defaultValue: "Move Workspaces to Window") + : String(localized: "contextMenu.moveWorkspaceToWindow", defaultValue: "Move Workspace to Window") + Menu(moveMenuTitle) { + Button(String(localized: "contextMenu.newWindow", defaultValue: "New Window")) { + moveWorkspacesToNewWindow(targetIds) + } + .disabled(targetIds.isEmpty) + + if !windowMoveTargets.isEmpty { + Divider() + } + + ForEach(windowMoveTargets) { target in + Button(target.label) { + moveWorkspaces(targetIds, toWindow: target.windowId) + } + .disabled(target.isCurrentWindow || targetIds.isEmpty) + } + } + .disabled(targetIds.isEmpty) + + Divider() + + if let key = closeWorkspaceShortcut.keyEquivalent { + Button(closeLabel) { + closeTabs(targetIds, allowPinned: true) + } + .keyboardShortcut(key, modifiers: closeWorkspaceShortcut.eventModifiers) + .disabled(targetIds.isEmpty) + } else { + Button(closeLabel) { + closeTabs(targetIds, allowPinned: true) + } + .disabled(targetIds.isEmpty) + } + + Button(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")) { + closeOtherTabs(targetIds) + } + .disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count) + + Button(String(localized: "contextMenu.closeWorkspacesBelow", defaultValue: "Close Workspaces Below")) { + closeTabsBelow(tabId: tab.id) + } + .disabled(index >= tabManager.tabs.count - 1) + + Button(String(localized: "contextMenu.closeWorkspacesAbove", defaultValue: "Close Workspaces Above")) { + closeTabsAbove(tabId: tab.id) + } + .disabled(index == 0) + + Divider() + + Button(markReadLabel) { + markTabsRead(targetIds) + } + .disabled(!hasUnreadNotifications(in: targetIds)) + + Button(markUnreadLabel) { + markTabsUnread(targetIds) + } + .disabled(!hasReadNotifications(in: targetIds)) + + Button(clearLatestNotificationLabel) { + clearLatestNotifications(targetIds) + } + .disabled(!hasLatestNotifications(in: targetIds)) + } + + private var selectionBackgroundColor: NSColor { + if let hex = sidebarSelectionColorHex, let parsed = NSColor(hex: hex) { + return parsed + } + return programaAccentNSColor(for: colorScheme) + } + + private var backgroundColor: Color { + switch activeTabIndicatorStyle { + case .leftRail: + if isActive { return Color(nsColor: selectionBackgroundColor) } + if isMultiSelected { return programaAccentColor().opacity(0.25) } + return Color.clear + case .solidFill: + if isActive { return Color(nsColor: selectionBackgroundColor) } + if let custom = resolvedCustomTabColor { + if isMultiSelected { return custom.opacity(0.35) } + return custom.opacity(0.7) + } + if isMultiSelected { return programaAccentColor().opacity(0.25) } + return Color.clear + } + } + + private var railColor: Color { + explicitRailColor ?? .clear + } + + private var explicitRailColor: Color? { + guard activeTabIndicatorStyle == .leftRail, + let custom = resolvedCustomTabColor else { + return nil + } + return custom.opacity(0.95) + } + + private var resolvedCustomTabColor: Color? { + guard let hex = tab.customColor else { return nil } + return WorkspaceTabColorSettings.displayColor( + hex: hex, + colorScheme: colorScheme, + forceBright: activeTabIndicatorStyle == .leftRail + ) + } + + private func tabColorSwatchColor(for hex: String) -> NSColor { + WorkspaceTabColorSettings.displayNSColor( + hex: hex, + colorScheme: colorScheme, + forceBright: activeTabIndicatorStyle == .leftRail + ) ?? NSColor(hex: hex) ?? .gray + } + + private var showsCenteredTopDropIndicator: Bool { + guard draggedTabId != nil, let indicator = dropIndicator else { return false } + if indicator.tabId == tab.id && indicator.edge == .top { + return true + } + + guard indicator.edge == .bottom, + let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == tab.id }), + currentIndex > 0 + else { + return false + } + return tabManager.tabs[currentIndex - 1].id == indicator.tabId + } + + private var accessibilityTitle: String { + String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(accessibilityWorkspaceCount)") + } + + private func moveBy(_ delta: Int) { + let targetIndex = index + delta + guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return } + guard tabManager.reorderWorkspace(tabId: tab.id, toIndex: targetIndex) else { return } + selectedTabIds = [tab.id] + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id } + tabManager.selectTab(tab) + setSelectionToTabs() + } + + private func updateSelection() { + #if DEBUG + let mods = NSEvent.modifierFlags + var modStr = "" + if mods.contains(.command) { modStr += "cmd " } + if mods.contains(.shift) { modStr += "shift " } + if mods.contains(.option) { modStr += "opt " } + if mods.contains(.control) { modStr += "ctrl " } + dlog("sidebar.select workspace=\(tab.id.uuidString.prefix(5)) modifiers=\(modStr.isEmpty ? "none" : modStr.trimmingCharacters(in: .whitespaces))") + #endif + let modifiers = NSEvent.modifierFlags + let isCommand = modifiers.contains(.command) + let isShift = modifiers.contains(.shift) + let wasSelected = tabManager.selectedTabId == tab.id + + if isShift, let lastIndex = lastSidebarSelectionIndex { + let lower = min(lastIndex, index) + let upper = max(lastIndex, index) + let rangeIds = tabManager.tabs[lower...upper].map { $0.id } + if isCommand { + selectedTabIds.formUnion(rangeIds) + } else { + selectedTabIds = Set(rangeIds) + } + } else if isCommand { + if selectedTabIds.contains(tab.id) { + selectedTabIds.remove(tab.id) + } else { + selectedTabIds.insert(tab.id) + } + } else { + selectedTabIds = [tab.id] + } + + lastSidebarSelectionIndex = index + tabManager.selectTab(tab) + if wasSelected, !isCommand, !isShift { + tabManager.dismissNotificationOnDirectInteraction( + tabId: tab.id, + surfaceId: tabManager.focusedSurfaceId(for: tab.id) + ) + } + setSelectionToTabs() + } + + private func closeTabs(_ targetIds: [UUID], allowPinned: Bool) { + tabManager.closeWorkspacesWithConfirmation(targetIds, allowPinned: allowPinned) + syncSelectionAfterMutation() + } + + private func closeOtherTabs(_ targetIds: [UUID]) { + let keepIds = Set(targetIds) + let idsToClose = tabManager.tabs.compactMap { keepIds.contains($0.id) ? nil : $0.id } + closeTabs(idsToClose, allowPinned: true) + } + + private func closeTabsBelow(tabId: UUID) { + guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + let idsToClose = tabManager.tabs.suffix(from: anchorIndex + 1).map { $0.id } + closeTabs(idsToClose, allowPinned: true) + } + + private func closeTabsAbove(tabId: UUID) { + guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + let idsToClose = tabManager.tabs.prefix(upTo: anchorIndex).map { $0.id } + closeTabs(idsToClose, allowPinned: true) + } + + private func markTabsRead(_ targetIds: [UUID]) { + for id in targetIds { + notificationStore.markRead(forTabId: id) + } + } + + private func markTabsUnread(_ targetIds: [UUID]) { + for id in targetIds { + notificationStore.markUnread(forTabId: id) + } + } + + private func clearLatestNotifications(_ targetIds: [UUID]) { + for id in targetIds { + notificationStore.clearLatestNotification(forTabId: id) + } + } + + private func hasUnreadNotifications(in targetIds: [UUID]) -> Bool { + let targetSet = Set(targetIds) + return notificationStore.notifications.contains { targetSet.contains($0.tabId) && !$0.isRead } + } + + private func hasReadNotifications(in targetIds: [UUID]) -> Bool { + let targetSet = Set(targetIds) + return notificationStore.notifications.contains { targetSet.contains($0.tabId) && $0.isRead } + } + + private func hasLatestNotifications(in targetIds: [UUID]) -> Bool { + targetIds.contains { notificationStore.latestNotification(forTabId: $0) != nil } + } + + private func syncSelectionAfterMutation() { + let existingIds = Set(tabManager.tabs.map { $0.id }) + selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } + if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + } + if let selectedId = tabManager.selectedTabId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } + } + + private var remoteStateHelpText: String { + let target = tab.remoteDisplayTarget ?? String( + localized: "sidebar.remote.help.targetFallback", + defaultValue: "remote host" + ) + let detail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) + switch tab.remoteConnectionState { + case .connected: + return String( + format: String( + localized: "sidebar.remote.help.connected", + defaultValue: "SSH connected to %@" + ), + locale: .current, + target + ) + case .connecting: + return String( + format: String( + localized: "sidebar.remote.help.connecting", + defaultValue: "SSH connecting to %@" + ), + locale: .current, + target + ) + case .error: + if let detail, !detail.isEmpty { + return String( + format: String( + localized: "sidebar.remote.help.errorWithDetail", + defaultValue: "SSH error for %@: %@" + ), + locale: .current, + target, + detail + ) + } + return String( + format: String( + localized: "sidebar.remote.help.error", + defaultValue: "SSH error for %@" + ), + locale: .current, + target + ) + case .disconnected: + return String( + format: String( + localized: "sidebar.remote.help.disconnected", + defaultValue: "SSH disconnected from %@" + ), + locale: .current, + target + ) + } + } + private func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) { + guard let app = AppDelegate.shared else { return } + let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } + guard !orderedWorkspaceIds.isEmpty else { return } + + for (index, workspaceId) in orderedWorkspaceIds.enumerated() { + let shouldFocus = index == orderedWorkspaceIds.count - 1 + _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: shouldFocus) + } + + selectedTabIds.subtract(orderedWorkspaceIds) + syncSelectionAfterMutation() + } + + private func moveWorkspacesToNewWindow(_ workspaceIds: [UUID]) { + guard let app = AppDelegate.shared else { return } + let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } + guard let firstWorkspaceId = orderedWorkspaceIds.first else { return } + + let shouldFocusImmediately = orderedWorkspaceIds.count == 1 + guard let newWindowId = app.moveWorkspaceToNewWindow(workspaceId: firstWorkspaceId, focus: shouldFocusImmediately) else { + return + } + + if orderedWorkspaceIds.count > 1 { + for workspaceId in orderedWorkspaceIds.dropFirst() { + _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: newWindowId, focus: false) + } + if let finalWorkspaceId = orderedWorkspaceIds.last { + _ = app.moveWorkspaceToWindow(workspaceId: finalWorkspaceId, windowId: newWindowId, focus: true) + } + } + + selectedTabIds.subtract(orderedWorkspaceIds) + syncSelectionAfterMutation() + } + + // 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?, + directorySummary: String? + ) -> String? { + var parts: [String] = [] + + if let gitSummary { + parts.append(gitSummary) + } + + if let directorySummary { + parts.append(directorySummary) + } + + let result = parts.joined(separator: " · ") + return result.isEmpty ? nil : result + } + + private func gitBranchSummaryText(orderedPanelIds: [UUID]) -> String? { + let lines = gitBranchSummaryLines(orderedPanelIds: orderedPanelIds) + guard !lines.isEmpty else { return nil } + return lines.joined(separator: " | ") + } + + private func gitBranchSummaryLines(orderedPanelIds: [UUID]) -> [String] { + tab.sidebarGitBranchesInDisplayOrder(orderedPanelIds: orderedPanelIds).map { branch in + "\(branch.branch)\(branch.isDirty ? "*" : "")" + } + } + + private struct VerticalBranchDirectoryLine { + let branch: String? + let directory: String? + } + + /// Recomputes the expensive sidebar detail caches (bonsplit tree walk, + /// branch/directory lines, PR snapshot) and writes them into @State. + /// Must be called only from the debounced publisher, onAppear, and + /// settings-change handlers — never from the immediate (title) publisher. + private func recomputeSidebarDetailCache() { + let detail = visibleAuxiliaryDetails + let needsDetail = detail.showsBranchDirectory || detail.showsPullRequests + let ids: [UUID]? = needsDetail ? tab.sidebarOrderedPanelIds() : nil + cachedOrderedPanelIds = ids + cachedBranchDirectoryLines = { + guard detail.showsBranchDirectory, sidebarBranchVerticalLayout, let ids else { return [] } + return verticalBranchDirectoryLines(orderedPanelIds: ids) + }() + cachedPullRequestRows = { + guard detail.showsPullRequests, let ids else { return [] } + return pullRequestDisplays(orderedPanelIds: ids) + }() + } + + private func verticalBranchDirectoryLines(orderedPanelIds: [UUID]) -> [VerticalBranchDirectoryLine] { + let entries = tab.sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: orderedPanelIds) + let home = SidebarPathFormatter.homeDirectoryPath + return entries.compactMap { entry in + let branchText: String? = { + guard sidebarShowGitBranch, let branch = entry.branch else { return nil } + return "\(branch)\(entry.isDirty ? "*" : "")" + }() + + let directoryText: String? = { + guard let directory = entry.directory else { return nil } + let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) + return shortened.isEmpty ? nil : shortened + }() + + switch (branchText, directoryText) { + case let (branch?, directory?): + return VerticalBranchDirectoryLine(branch: branch, directory: directory) + case let (branch?, nil): + return VerticalBranchDirectoryLine(branch: branch, directory: nil) + case let (nil, directory?): + return VerticalBranchDirectoryLine(branch: nil, directory: directory) + default: + return nil + } + } + } + + private func directorySummaryText(orderedPanelIds: [UUID]) -> String? { + let home = SidebarPathFormatter.homeDirectoryPath + let entries = tab.sidebarDirectoriesInDisplayOrder(orderedPanelIds: orderedPanelIds).compactMap { directory in + let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) + return shortened.isEmpty ? nil : shortened + } + return entries.isEmpty ? nil : entries.joined(separator: " | ") + } + + private struct PullRequestDisplay: Identifiable { + let id: String + let number: Int + let label: String + let url: URL + let status: SidebarPullRequestStatus + let checks: SidebarPullRequestChecksStatus? + } + + private func pullRequestDisplays(orderedPanelIds: [UUID]) -> [PullRequestDisplay] { + tab.sidebarPullRequestsInDisplayOrder(orderedPanelIds: orderedPanelIds).map { pullRequest in + PullRequestDisplay( + id: "\(pullRequest.label.lowercased())#\(pullRequest.number)|\(pullRequest.url.absoluteString)", + number: pullRequest.number, + label: pullRequest.label, + url: pullRequest.url, + status: pullRequest.status, + checks: pullRequest.checks + ) + } + } + + private var pullRequestForegroundColor: Color { + isActive ? .white.opacity(0.75) : .secondary + } + + private func openPullRequestLink(_ url: URL) { + updateSelection() + if openSidebarPullRequestLinksInProgramaBrowser { + if tabManager.openBrowser( + inWorkspace: tab.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) == nil { + NSWorkspace.shared.open(url) + } + return + } + NSWorkspace.shared.open(url) + } + + private func openPortLink(_ port: Int) { + guard let url = URL(string: "http://localhost:\(port)") else { return } + updateSelection() + if openSidebarPortLinksInProgramaBrowser { + if tabManager.openBrowser( + inWorkspace: tab.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) == nil { + NSWorkspace.shared.open(url) + } + return + } + NSWorkspace.shared.open(url) + } + + private func pullRequestStatusLabel( + _ status: SidebarPullRequestStatus, + checks _: SidebarPullRequestChecksStatus? + ) -> String { + switch status { + case .open: return String(localized: "sidebar.pullRequest.statusOpen", defaultValue: "open") + case .merged: return String(localized: "sidebar.pullRequest.statusMerged", defaultValue: "merged") + case .closed: return String(localized: "sidebar.pullRequest.statusClosed", defaultValue: "closed") + } + } + + private func logLevelIcon(_ level: SidebarLogLevel) -> String { + switch level { + case .info: return "circle.fill" + case .progress: return "arrowtriangle.right.fill" + case .success: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .error: return "xmark.circle.fill" + } + } + + private func logLevelColor(_ level: SidebarLogLevel, isActive: Bool) -> Color { + if isActive { + switch level { + case .info: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.5)) + case .progress: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.8)) + case .success: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) + case .warning: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) + case .error: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) + } + } + switch level { + case .info: return .secondary + case .progress: return .blue + case .success: return .green + case .warning: return .orange + case .error: return .red + } + } + + private func shortenPath(_ path: String, home: String) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return path } + if trimmed == home { + return "~" + } + if trimmed.hasPrefix(home + "/") { + return "~" + trimmed.dropFirst(home.count) + } + return trimmed + } + + private struct PullRequestStatusIcon: View { + let status: SidebarPullRequestStatus + let color: Color + private static let frameSize: CGFloat = 12 + + var body: some View { + switch status { + case .open: + PullRequestOpenIcon(color: color) + case .merged: + PullRequestMergedIcon(color: color) + case .closed: + Image(systemName: "xmark.circle") + .font(.system(size: 7, weight: .regular)) + .foregroundColor(color) + .frame(width: Self.frameSize, height: Self.frameSize) + } + } + } + + private struct PullRequestOpenIcon: View { + let color: Color + private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) + private static let nodeDiameter: CGFloat = 3.0 + private static let frameSize: CGFloat = 13 + + var body: some View { + ZStack { + Path { path in + path.move(to: CGPoint(x: 3.0, y: 4.8)) + path.addLine(to: CGPoint(x: 3.0, y: 9.2)) + + path.move(to: CGPoint(x: 4.8, y: 3.0)) + path.addLine(to: CGPoint(x: 9.4, y: 3.0)) + path.addLine(to: CGPoint(x: 11.0, y: 4.6)) + path.addLine(to: CGPoint(x: 11.0, y: 9.2)) + } + .stroke(color, style: Self.stroke) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 3.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 11.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 11.0, y: 11.0) + } + .frame(width: Self.frameSize, height: Self.frameSize) + } + } + + private struct PullRequestMergedIcon: View { + let color: Color + private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) + private static let nodeDiameter: CGFloat = 3.0 + private static let frameSize: CGFloat = 13 + + var body: some View { + ZStack { + Path { path in + path.move(to: CGPoint(x: 4.6, y: 4.6)) + path.addLine(to: CGPoint(x: 7.1, y: 7.0)) + path.addLine(to: CGPoint(x: 9.2, y: 7.0)) + + path.move(to: CGPoint(x: 4.6, y: 9.4)) + path.addLine(to: CGPoint(x: 7.1, y: 7.0)) + } + .stroke(color, style: Self.stroke) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 3.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 11.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 11.0, y: 7.0) + } + .frame(width: Self.frameSize, height: Self.frameSize) + } + } + + private func applyTabColor(_ hex: String?, targetIds: [UUID]) { + for targetId in targetIds { + tabManager.setTabColor(tabId: targetId, color: hex) + } + } + + private func promptCustomColor(targetIds: [UUID]) { + let alert = NSAlert() + alert.messageText = String(localized: "alert.customColor.title", defaultValue: "Custom Workspace Color") + alert.informativeText = String(localized: "alert.customColor.message", defaultValue: "Enter a hex color in the format #RRGGBB.") + + let seed = tab.customColor ?? WorkspaceTabColorSettings.customPaletteEntries().first?.hex ?? "" + let input = NSTextField(string: seed) + input.placeholderString = "#1565C0" + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "alert.customColor.apply", defaultValue: "Apply")) + alert.addButton(withTitle: String(localized: "alert.customColor.cancel", defaultValue: "Cancel")) + + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return } + guard let normalized = WorkspaceTabColorSettings.addCustomColor(input.stringValue) else { + showInvalidColorAlert(input.stringValue) + return + } + applyTabColor(normalized, targetIds: targetIds) + } + + private func showInvalidColorAlert(_ value: String) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "alert.invalidColor.title", defaultValue: "Invalid Color") + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + alert.informativeText = String(localized: "alert.invalidColor.emptyMessage", defaultValue: "Enter a hex color in the format #RRGGBB.") + } else { + alert.informativeText = String(localized: "alert.invalidColor.invalidMessage", defaultValue: "\"\(trimmed)\" is not a valid hex color. Use #RRGGBB.") + } + alert.addButton(withTitle: String(localized: "alert.invalidColor.ok", defaultValue: "OK")) + _ = alert.runModal() + } + + private func promptRename() { + let alert = NSAlert() + alert.messageText = String(localized: "alert.renameWorkspace.title", defaultValue: "Rename Workspace") + alert.informativeText = String(localized: "alert.renameWorkspace.message", defaultValue: "Enter a custom name for this workspace.") + let input = NSTextField(string: tab.customTitle ?? tab.title) + input.placeholderString = String(localized: "alert.renameWorkspace.placeholder", defaultValue: "Workspace name") + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "alert.renameWorkspace.rename", defaultValue: "Rename")) + alert.addButton(withTitle: String(localized: "alert.renameWorkspace.cancel", defaultValue: "Cancel")) + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return } + tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) + } + + private func beginWorkspaceDescriptionEditFromContextMenu() { + selectedTabIds = [tab.id] + lastSidebarSelectionIndex = index + tabManager.selectTab(tab) + setSelectionToTabs() + _ = AppDelegate.shared?.requestEditWorkspaceDescriptionViaCommandPalette() + } +} + +private struct SidebarWorkspaceDescriptionText: View { + let markdown: String + let isActive: Bool + + var body: some View { + let renderedMarkdown = SidebarMarkdownRenderer.renderWorkspaceDescription(markdown) + Group { + if let renderedMarkdown { + Text(renderedMarkdown) + } else { + Text(markdown) + } + } + .font(.system(size: 10.5)) + .foregroundColor(foregroundColor) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("SidebarWorkspaceDescriptionText") + .accessibilityLabel(accessibilityText(renderedMarkdown: renderedMarkdown)) + .onAppear { +#if DEBUG + let newlineCount = markdown.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } + dlog( + "sidebar.description.render workspaceState=appear " + + "len=\((markdown as NSString).length) " + + "newlines=\(newlineCount) " + + "text=\"\(debugCommandPaletteTextPreview(markdown))\"" + ) +#endif + } + .onChange(of: markdown) { newValue in +#if DEBUG + let newlineCount = newValue.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } + dlog( + "sidebar.description.render workspaceState=change " + + "len=\((newValue as NSString).length) " + + "newlines=\(newlineCount) " + + "text=\"\(debugCommandPaletteTextPreview(newValue))\"" + ) +#endif + } + } + + private var foregroundColor: Color { + isActive ? .white.opacity(0.84) : .secondary.opacity(0.95) + } + + private func accessibilityText(renderedMarkdown: AttributedString?) -> String { + if let renderedMarkdown { + return String(renderedMarkdown.characters) + } + return markdown + } +} + +enum SidebarMarkdownRenderer { + static func renderWorkspaceDescription(_ markdown: String) -> AttributedString? { + try? AttributedString( + markdown: markdown, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) + } +} + +private struct SidebarMetadataRows: View { + let entries: [SidebarStatusEntry] + let isActive: Bool + let onFocus: () -> Void + + @State private var isExpanded: Bool = false + private let collapsedEntryLimit = 3 + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(visibleEntries, id: \.key) { entry in + SidebarMetadataEntryRow(entry: entry, isActive: isActive, onFocus: onFocus) + } + + if shouldShowToggle { + Button(isExpanded ? String(localized: "sidebar.metadata.showLess", defaultValue: "Show less") : String(localized: "sidebar.metadata.showMore", defaultValue: "Show more")) { + onFocus() + withAnimation(.easeInOut(duration: 0.15)) { + isExpanded.toggle() + } + } + .buttonStyle(.plain) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(isActive ? activeSecondaryTextColor : .secondary.opacity(0.9)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .safeHelp(helpText) + } + + private var activeSecondaryTextColor: Color { + Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65)) + } + + private var visibleEntries: [SidebarStatusEntry] { + guard !isExpanded, entries.count > collapsedEntryLimit else { return entries } + return Array(entries.prefix(collapsedEntryLimit)) + } + + private var helpText: String { + entries.map { entry in + let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? entry.key : trimmed + } + .joined(separator: "\n") + } + + private var shouldShowToggle: Bool { + entries.count > collapsedEntryLimit + } +} + +private struct SidebarMetadataEntryRow: View { + let entry: SidebarStatusEntry + let isActive: Bool + let onFocus: () -> Void + + var body: some View { + Group { + if let url = entry.url { + Button { + onFocus() + NSWorkspace.shared.open(url) + } label: { + rowContent(underlined: true) + } + .buttonStyle(.plain) + .safeHelp(url.absoluteString) + } else { + rowContent(underlined: false) + .contentShape(Rectangle()) + .onTapGesture { onFocus() } + } + } + } + + @ViewBuilder + private func rowContent(underlined: Bool) -> some View { + HStack(spacing: 4) { + if let icon = iconView { + icon + .foregroundColor(foregroundColor.opacity(0.95)) + } + metadataText(underlined: underlined) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + } + .font(.system(size: 10)) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var foregroundColor: Color { + if isActive, + let raw = entry.color, + Color(hex: raw) != nil { + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.95)) + } + if let raw = entry.color, let explicit = Color(hex: raw) { + return explicit + } + return isActive ? .white.opacity(0.8) : .secondary + } + + private var iconView: AnyView? { + guard let iconRaw = entry.icon?.trimmingCharacters(in: .whitespacesAndNewlines), + !iconRaw.isEmpty else { + return nil + } + if iconRaw.hasPrefix("emoji:") { + let value = String(iconRaw.dropFirst("emoji:".count)) + guard !value.isEmpty else { return nil } + return AnyView(Text(value).font(.system(size: 9))) + } + if iconRaw.hasPrefix("text:") { + let value = String(iconRaw.dropFirst("text:".count)) + guard !value.isEmpty else { return nil } + return AnyView(Text(value).font(.system(size: 8, weight: .semibold))) + } + let symbolName: String + if iconRaw.hasPrefix("sf:") { + symbolName = String(iconRaw.dropFirst("sf:".count)) + } else { + symbolName = iconRaw + } + guard !symbolName.isEmpty else { return nil } + return AnyView(Image(systemName: symbolName).font(.system(size: 8, weight: .medium))) + } + + @ViewBuilder + private func metadataText(underlined: Bool) -> some View { + let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) + let display = trimmed.isEmpty ? entry.key : trimmed + if entry.format == .markdown, + let attributed = try? AttributedString( + markdown: display, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) { + Text(attributed) + .underline(underlined) + .foregroundColor(foregroundColor) + } else { + Text(display) + .underline(underlined) + .foregroundColor(foregroundColor) + } + } +} + +private struct SidebarMetadataMarkdownBlocks: View { + let blocks: [SidebarMetadataBlock] + let isActive: Bool + let onFocus: () -> Void + + @State private var isExpanded: Bool = false + private let collapsedBlockLimit = 1 + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + ForEach(visibleBlocks, id: \.key) { block in + SidebarMetadataMarkdownBlockRow( + block: block, + isActive: isActive, + onFocus: onFocus + ) + } + + if shouldShowToggle { + Button(isExpanded ? String(localized: "sidebar.metadata.showLessDetails", defaultValue: "Show less details") : String(localized: "sidebar.metadata.showMoreDetails", defaultValue: "Show more details")) { + onFocus() + withAnimation(.easeInOut(duration: 0.15)) { + isExpanded.toggle() + } + } + .buttonStyle(.plain) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private var visibleBlocks: [SidebarMetadataBlock] { + guard !isExpanded, blocks.count > collapsedBlockLimit else { return blocks } + return Array(blocks.prefix(collapsedBlockLimit)) + } + + private var shouldShowToggle: Bool { + blocks.count > collapsedBlockLimit + } +} + +private struct SidebarMetadataMarkdownBlockRow: View { + let block: SidebarMetadataBlock + let isActive: Bool + let onFocus: () -> Void + + @State private var renderedMarkdown: AttributedString? + + var body: some View { + Group { + if let renderedMarkdown { + Text(renderedMarkdown) + .foregroundColor(foregroundColor) + } else { + Text(block.markdown) + .foregroundColor(foregroundColor) + } + } + .font(.system(size: 10)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .contentShape(Rectangle()) + .onTapGesture { onFocus() } + .onAppear(perform: renderMarkdown) + .onChange(of: block.markdown) { _ in + renderMarkdown() + } + } + + private var foregroundColor: Color { + isActive ? .white.opacity(0.8) : .secondary + } + + private func renderMarkdown() { + renderedMarkdown = try? AttributedString( + markdown: block.markdown, + options: .init(interpretedSyntax: .full) + ) + } +} + From 3a6620dc8a74f1e072ce5f101f21c192a1788b4e Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:52:24 -0300 Subject: [PATCH 2/9] refactor(contentview): extract FileDropOverlayView.swift (mechanical move) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the FileDropOverlayView NSView class out of ContentView.swift into its own file, verbatim (no access-level changes needed — it was already internal). Refs #94. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/ContentView.swift | 482 ------------------------- Sources/FileDropOverlayView.swift | 488 ++++++++++++++++++++++++++ 3 files changed, 492 insertions(+), 482 deletions(-) create mode 100644 Sources/FileDropOverlayView.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index c973b5ea48c..1df369db106 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; A5FF0005 /* ContentView+CommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0015 /* ContentView+CommandPalette.swift */; }; NRCV00000000000000000005 /* TabItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000006 /* TabItemView.swift */; }; + NRCV00000000000000000003 /* FileDropOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000004 /* FileDropOverlayView.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; }; A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; @@ -237,6 +238,7 @@ A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A5FF0015 /* ContentView+CommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+CommandPalette.swift"; sourceTree = ""; }; NRCV00000000000000000006 /* TabItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItemView.swift; sourceTree = ""; }; + NRCV00000000000000000004 /* FileDropOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayView.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; @@ -507,6 +509,7 @@ A5001012 /* ContentView.swift */, A5FF0015 /* ContentView+CommandPalette.swift */, NRCV00000000000000000006 /* TabItemView.swift */, + NRCV00000000000000000004 /* FileDropOverlayView.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, @@ -838,6 +841,7 @@ A5001002 /* ContentView.swift in Sources */, A5FF0005 /* ContentView+CommandPalette.swift in Sources */, NRCV00000000000000000005 /* TabItemView.swift in Sources */, + NRCV00000000000000000003 /* FileDropOverlayView.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index a49d2eec076..5500ee6ba19 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -471,488 +471,6 @@ enum DragOverlayRoutingPolicy { } } -/// Transparent NSView installed on the window's theme frame (above the NSHostingView) to -/// handle file/URL drags from Finder. Nested NSHostingController layers (created by bonsplit's -/// SinglePaneWrapper) prevent AppKit's NSDraggingDestination routing from reaching deeply -/// embedded terminal views. This overlay sits above the entire content view hierarchy and -/// intercepts file drags, forwarding drops to the GhosttyNSView under the cursor. -/// -/// Mouse events are forwarded to the views below via a hide-send-unhide pattern so clicks, -/// scrolls, and other interactions pass through normally. -final class FileDropOverlayView: NSView { - /// Fallback handler when no terminal is found under the drop point. - var onDrop: (([URL]) -> Bool)? - private var isForwardingMouseEvent = false - private weak var forwardedMouseDragTarget: NSView? - private var forwardedMouseDragButton: ForwardedMouseDragButton? - /// The WKWebView currently receiving forwarded drag events, so we can - /// synthesize draggingExited/draggingEntered as the cursor moves. - private weak var activeDragWebView: WKWebView? - /// The WKWebView that accepted prepareForDragOperation so conclude can be - /// delivered to the same browser target after the drop completes. - private weak var preparedDragWebView: WKWebView? - private var lastHitTestLogSignature: String? - private var lastDragRouteLogSignatureByPhase: [String: String] = [:] - - override var acceptsFirstResponder: Bool { false } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - registerForDraggedTypes([.fileURL]) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } - - private enum ForwardedMouseDragButton: Equatable { - case left - case right - case other(Int) - } - - private func dragButton(for event: NSEvent) -> ForwardedMouseDragButton? { - switch event.type { - case .leftMouseDown, .leftMouseUp, .leftMouseDragged: - return .left - case .rightMouseDown, .rightMouseUp, .rightMouseDragged: - return .right - case .otherMouseDown, .otherMouseUp, .otherMouseDragged: - return .other(Int(event.buttonNumber)) - default: - return nil - } - } - - private func shouldTrackForwardedMouseDragStart(for eventType: NSEvent.EventType) -> Bool { - switch eventType { - case .leftMouseDown, .rightMouseDown, .otherMouseDown: - return true - default: - return false - } - } - - private func shouldTrackForwardedMouseDragEnd(for eventType: NSEvent.EventType) -> Bool { - switch eventType { - case .leftMouseUp, .rightMouseUp, .otherMouseUp: - return true - default: - return false - } - } - - // MARK: Hit-testing — participation is routed by DragOverlayRoutingPolicy so - // file-drop, bonsplit tab drags, and sidebar tab reorder drags cannot conflict. - - override func hitTest(_ point: NSPoint) -> NSView? { - let pb = NSPasteboard(name: .drag) - let eventType = NSApp.currentEvent?.type - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropOverlay( - pasteboardTypes: pb.types, - eventType: eventType - ) -#if DEBUG - logHitTestDecision( - pasteboardTypes: pb.types, - eventType: eventType, - shouldCapture: shouldCapture - ) -#endif - guard shouldCapture else { return nil } - - return super.hitTest(point) - } - - // MARK: Mouse forwarding — safety net for the rare case where stale drag pasteboard - // data causes hitTest to return self when no drag is actually active. - // We hit-test contentView directly and dispatch to the target rather than using - // window.sendEvent(), which caches the mouse target and causes infinite recursion. - - private func forwardEvent(_ event: NSEvent) { - guard !isForwardingMouseEvent else { return } - guard let window, let contentView = window.contentView else { return } - let eventButton = dragButton(for: event) - - isForwardingMouseEvent = true - isHidden = true - defer { - isHidden = false - isForwardingMouseEvent = false - } - - let target: NSView? - if let eventButton, - forwardedMouseDragButton == eventButton, - let activeTarget = forwardedMouseDragTarget, - activeTarget.window != nil { - // Preserve normal AppKit mouse-delivery semantics: once a drag starts, - // keep routing dragged/up events to the original mouseDown target. - target = activeTarget - } else { - let point = contentView.convert(event.locationInWindow, from: nil) - target = contentView.hitTest(point) - } - - guard let target, target !== self else { - if shouldTrackForwardedMouseDragEnd(for: event.type), - let eventButton, - forwardedMouseDragButton == eventButton { - forwardedMouseDragTarget = nil - forwardedMouseDragButton = nil - } - return - } - - if shouldTrackForwardedMouseDragStart(for: event.type), let eventButton { - forwardedMouseDragTarget = target - forwardedMouseDragButton = eventButton - } - - switch event.type { - case .leftMouseDown: target.mouseDown(with: event) - case .leftMouseUp: target.mouseUp(with: event) - case .leftMouseDragged: target.mouseDragged(with: event) - case .rightMouseDown: target.rightMouseDown(with: event) - case .rightMouseUp: target.rightMouseUp(with: event) - case .rightMouseDragged: target.rightMouseDragged(with: event) - case .otherMouseDown: target.otherMouseDown(with: event) - case .otherMouseUp: target.otherMouseUp(with: event) - case .otherMouseDragged: target.otherMouseDragged(with: event) - case .scrollWheel: target.scrollWheel(with: event) - default: break - } - - if shouldTrackForwardedMouseDragEnd(for: event.type), - let eventButton, - forwardedMouseDragButton == eventButton { - forwardedMouseDragTarget = nil - forwardedMouseDragButton = nil - } - } - - override func mouseDown(with event: NSEvent) { forwardEvent(event) } - override func mouseUp(with event: NSEvent) { forwardEvent(event) } - override func mouseDragged(with event: NSEvent) { forwardEvent(event) } - override func rightMouseDown(with event: NSEvent) { forwardEvent(event) } - override func rightMouseUp(with event: NSEvent) { forwardEvent(event) } - override func rightMouseDragged(with event: NSEvent) { forwardEvent(event) } - override func otherMouseDown(with event: NSEvent) { forwardEvent(event) } - override func otherMouseUp(with event: NSEvent) { forwardEvent(event) } - override func otherMouseDragged(with event: NSEvent) { forwardEvent(event) } - override func scrollWheel(with event: NSEvent) { forwardEvent(event) } - - // MARK: NSDraggingDestination – accept file drops over terminal and browser views. - // - // AppKit sends draggingEntered once when the drag enters this overlay, then - // draggingUpdated as the cursor moves within it. We track which WKWebView (if - // any) is under the cursor and synthesize enter/exit calls so the browser's - // HTML5 drag events (dragenter, dragleave, drop) fire correctly. - - override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { - return updateDragTarget(sender, phase: "entered") - } - - override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { - return updateDragTarget(sender, phase: "updated") - } - - override func draggingExited(_ sender: (any NSDraggingInfo)?) { - preparedDragWebView = nil - if let prev = activeDragWebView { - prev.draggingExited(sender) - activeDragWebView = nil - } - } - - override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { - let hasLocalDraggingSource = sender.draggingSource != nil - let types = sender.draggingPasteboard.types - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( - pasteboardTypes: types, - hasLocalDraggingSource: hasLocalDraggingSource - ) - let webView = shouldCapture ? (activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)) : nil - let terminal = terminalUnderPoint(sender.draggingLocation) - let hasTerminalTarget = terminal != nil -#if DEBUG - logDragRouteDecision( - phase: "prepare", - pasteboardTypes: types, - shouldCapture: shouldCapture, - hasLocalDraggingSource: hasLocalDraggingSource, - hasTerminalTarget: hasTerminalTarget - ) -#endif - guard shouldCapture else { - preparedDragWebView = nil - return false - } - if let webView { - preparedDragWebView = webView - return webView.prepareForDragOperation(sender) - } - preparedDragWebView = nil - return hasTerminalTarget - } - - override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { - let hasLocalDraggingSource = sender.draggingSource != nil - let types = sender.draggingPasteboard.types - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( - pasteboardTypes: types, - hasLocalDraggingSource: hasLocalDraggingSource - ) - let webView = shouldCapture - ? (preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)) - : nil - let terminal = terminalUnderPoint(sender.draggingLocation) - let hasTerminalTarget = terminal != nil -#if DEBUG - logDragRouteDecision( - phase: "perform", - pasteboardTypes: types, - shouldCapture: shouldCapture, - hasLocalDraggingSource: hasLocalDraggingSource, - hasTerminalTarget: hasTerminalTarget - ) -#endif - guard shouldCapture else { - preparedDragWebView = nil - activeDragWebView = nil - return false - } - if let webView { - preparedDragWebView = webView - return webView.performDragOperation(sender) - } - preparedDragWebView = nil - activeDragWebView = nil - guard let terminal else { return false } - return terminal.performDragOperation(sender) - } - - override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { - defer { - preparedDragWebView = nil - activeDragWebView = nil - } - guard let sender else { return } - let webView = preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation) - webView?.concludeDragOperation(sender) - } - - private func updateDragTarget(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation { - let loc = sender.draggingLocation - let hasLocalDraggingSource = sender.draggingSource != nil - let types = sender.draggingPasteboard.types - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( - pasteboardTypes: types, - hasLocalDraggingSource: hasLocalDraggingSource - ) - let webView = shouldCapture ? webViewUnderPoint(loc) : nil - - if let prev = activeDragWebView, prev !== webView { - prev.draggingExited(sender) - activeDragWebView = nil - } - - if let webView { - if activeDragWebView !== webView { - activeDragWebView = webView - return webView.draggingEntered(sender) - } - return webView.draggingUpdated(sender) - } - - let hasTerminalTarget = terminalUnderPoint(loc) != nil -#if DEBUG - logDragRouteDecision( - phase: phase, - pasteboardTypes: types, - shouldCapture: shouldCapture, - hasLocalDraggingSource: hasLocalDraggingSource, - hasTerminalTarget: hasTerminalTarget - ) -#endif - guard shouldCapture, hasTerminalTarget else { return [] } - return .copy - } - - private func debugPasteboardTypes(_ types: [NSPasteboard.PasteboardType]?) -> String { - guard let types, !types.isEmpty else { return "-" } - return types.map(\.rawValue).joined(separator: ",") - } - - /// Hit-tests the window to find a WKWebView (browser panel) under the cursor. - func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? { - if let window, - let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint(windowPoint, in: window) { - return portalWebView - } - - guard let window, let contentView = window.contentView else { return nil } - isHidden = true - defer { isHidden = false } - let point = contentView.convert(windowPoint, from: nil) - let hitView = contentView.hitTest(point) - - var current: NSView? = hitView - while let view = current { - if let webView = view as? WKWebView { return webView } - current = view.superview - } - return nil - } - - private func debugTopHitViewForCurrentEvent() -> String { - guard let window, - let currentEvent = NSApp.currentEvent, - let contentView = window.contentView, - let themeFrame = contentView.superview else { return "-" } - - let pointInTheme = themeFrame.convert(currentEvent.locationInWindow, from: nil) - // Don't toggle isHidden here — it triggers setNeedsDisplay which can - // exceed AppKit's display-pass limit during cursor-update display cycles. - guard let hit = themeFrame.hitTest(pointInTheme) else { return "nil" } - var chain: [String] = [] - var current: NSView? = hit - var depth = 0 - while let view = current, depth < 6 { - chain.append(debugHitViewDescriptor(view)) - current = view.superview - depth += 1 - } - return chain.joined(separator: "->") - } - - private func debugHitViewDescriptor(_ view: NSView) -> String { - let className = String(describing: type(of: view)) - let ptr = String(describing: Unmanaged.passUnretained(view).toOpaque()) - let dragTypes = debugRegisteredDragTypes(view) - return "\(className)@\(ptr){dragTypes=\(dragTypes)}" - } - - private func debugRegisteredDragTypes(_ view: NSView) -> String { - let types = view.registeredDraggedTypes - guard !types.isEmpty else { return "-" } - - let interestingTypes = types.filter { type in - let raw = type.rawValue - return raw == NSPasteboard.PasteboardType.fileURL.rawValue - || raw == DragOverlayRoutingPolicy.bonsplitTabTransferType.rawValue - || raw == DragOverlayRoutingPolicy.sidebarTabReorderType.rawValue - || raw.contains("public.text") - || raw.contains("public.url") - || raw.contains("public.data") - } - let selected = interestingTypes.isEmpty ? Array(types.prefix(3)) : interestingTypes - let rendered = selected.map(\.rawValue).joined(separator: ",") - if selected.count < types.count { - return "\(rendered),+\(types.count - selected.count)" - } - return rendered - } - - private func hasRelevantDragTypes(_ types: [NSPasteboard.PasteboardType]?) -> Bool { - guard let types else { return false } - return types.contains(.fileURL) - || types.contains(DragOverlayRoutingPolicy.bonsplitTabTransferType) - || types.contains(DragOverlayRoutingPolicy.sidebarTabReorderType) - } - - private func debugEventName(_ eventType: NSEvent.EventType?) -> String { - guard let eventType else { return "none" } - switch eventType { - case .cursorUpdate: return "cursorUpdate" - case .appKitDefined: return "appKitDefined" - case .systemDefined: return "systemDefined" - case .applicationDefined: return "applicationDefined" - case .periodic: return "periodic" - case .mouseMoved: return "mouseMoved" - case .mouseEntered: return "mouseEntered" - case .mouseExited: return "mouseExited" - case .flagsChanged: return "flagsChanged" - case .leftMouseDown: return "leftMouseDown" - case .leftMouseUp: return "leftMouseUp" - case .leftMouseDragged: return "leftMouseDragged" - case .rightMouseDown: return "rightMouseDown" - case .rightMouseUp: return "rightMouseUp" - case .rightMouseDragged: return "rightMouseDragged" - case .otherMouseDown: return "otherMouseDown" - case .otherMouseUp: return "otherMouseUp" - case .otherMouseDragged: return "otherMouseDragged" - case .scrollWheel: return "scrollWheel" - default: return "other(\(eventType.rawValue))" - } - } - -#if DEBUG - private func logHitTestDecision( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - eventType: NSEvent.EventType?, - shouldCapture: Bool - ) { - let isDragEvent = eventType == .leftMouseDragged - || eventType == .rightMouseDragged - || eventType == .otherMouseDragged - guard shouldCapture || isDragEvent || hasRelevantDragTypes(pasteboardTypes) else { return } - - let signature = "\(shouldCapture ? 1 : 0)|\(debugEventName(eventType))|\(debugPasteboardTypes(pasteboardTypes))" - guard lastHitTestLogSignature != signature else { return } - lastHitTestLogSignature = signature - dlog( - "overlay.fileDrop.hitTest capture=\(shouldCapture ? 1 : 0) " + - "event=\(debugEventName(eventType)) " + - "topHit=\(debugTopHitViewForCurrentEvent()) " + - "types=\(debugPasteboardTypes(pasteboardTypes))" - ) - } - - private func logDragRouteDecision( - phase: String, - pasteboardTypes: [NSPasteboard.PasteboardType]?, - shouldCapture: Bool, - hasLocalDraggingSource: Bool, - hasTerminalTarget: Bool - ) { - guard shouldCapture || hasRelevantDragTypes(pasteboardTypes) else { return } - let signature = [ - shouldCapture ? "1" : "0", - hasLocalDraggingSource ? "1" : "0", - hasTerminalTarget ? "1" : "0", - debugPasteboardTypes(pasteboardTypes) - ].joined(separator: "|") - guard lastDragRouteLogSignatureByPhase[phase] != signature else { return } - lastDragRouteLogSignatureByPhase[phase] = signature - dlog( - "overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " + - "localSource=\(hasLocalDraggingSource ? 1 : 0) " + - "hasTerminal=\(hasTerminalTarget ? 1 : 0) " + - "types=\(debugPasteboardTypes(pasteboardTypes))" - ) - } -#endif - /// Hit-tests the window to find the GhosttyNSView under the cursor. - func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { - if let window, - let portalTerminal = TerminalWindowPortalRegistry.terminalViewAtWindowPoint(windowPoint, in: window) { - return portalTerminal - } - - guard let window, let contentView = window.contentView else { return nil } - isHidden = true - defer { isHidden = false } - let point = contentView.convert(windowPoint, from: nil) - let hitView = contentView.hitTest(point) - - var current: NSView? = hitView - while let view = current { - if let terminal = view as? GhosttyNSView { return terminal } - current = view.superview - } - return nil - } -} - var fileDropOverlayKey: UInt8 = 0 private var commandPaletteWindowOverlayKey: UInt8 = 0 private var tmuxWorkspacePaneWindowOverlayKey: UInt8 = 0 diff --git a/Sources/FileDropOverlayView.swift b/Sources/FileDropOverlayView.swift new file mode 100644 index 00000000000..6d403707d14 --- /dev/null +++ b/Sources/FileDropOverlayView.swift @@ -0,0 +1,488 @@ +// File-drop overlay NSView, extracted from ContentView.swift (nuclear-review #94.3). +// Pure move — behavior-identical relocation. + +import AppKit +import WebKit + + +/// Transparent NSView installed on the window's theme frame (above the NSHostingView) to +/// handle file/URL drags from Finder. Nested NSHostingController layers (created by bonsplit's +/// SinglePaneWrapper) prevent AppKit's NSDraggingDestination routing from reaching deeply +/// embedded terminal views. This overlay sits above the entire content view hierarchy and +/// intercepts file drags, forwarding drops to the GhosttyNSView under the cursor. +/// +/// Mouse events are forwarded to the views below via a hide-send-unhide pattern so clicks, +/// scrolls, and other interactions pass through normally. +final class FileDropOverlayView: NSView { + /// Fallback handler when no terminal is found under the drop point. + var onDrop: (([URL]) -> Bool)? + private var isForwardingMouseEvent = false + private weak var forwardedMouseDragTarget: NSView? + private var forwardedMouseDragButton: ForwardedMouseDragButton? + /// The WKWebView currently receiving forwarded drag events, so we can + /// synthesize draggingExited/draggingEntered as the cursor moves. + private weak var activeDragWebView: WKWebView? + /// The WKWebView that accepted prepareForDragOperation so conclude can be + /// delivered to the same browser target after the drop completes. + private weak var preparedDragWebView: WKWebView? + private var lastHitTestLogSignature: String? + private var lastDragRouteLogSignatureByPhase: [String: String] = [:] + + override var acceptsFirstResponder: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + registerForDraggedTypes([.fileURL]) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } + + private enum ForwardedMouseDragButton: Equatable { + case left + case right + case other(Int) + } + + private func dragButton(for event: NSEvent) -> ForwardedMouseDragButton? { + switch event.type { + case .leftMouseDown, .leftMouseUp, .leftMouseDragged: + return .left + case .rightMouseDown, .rightMouseUp, .rightMouseDragged: + return .right + case .otherMouseDown, .otherMouseUp, .otherMouseDragged: + return .other(Int(event.buttonNumber)) + default: + return nil + } + } + + private func shouldTrackForwardedMouseDragStart(for eventType: NSEvent.EventType) -> Bool { + switch eventType { + case .leftMouseDown, .rightMouseDown, .otherMouseDown: + return true + default: + return false + } + } + + private func shouldTrackForwardedMouseDragEnd(for eventType: NSEvent.EventType) -> Bool { + switch eventType { + case .leftMouseUp, .rightMouseUp, .otherMouseUp: + return true + default: + return false + } + } + + // MARK: Hit-testing — participation is routed by DragOverlayRoutingPolicy so + // file-drop, bonsplit tab drags, and sidebar tab reorder drags cannot conflict. + + override func hitTest(_ point: NSPoint) -> NSView? { + let pb = NSPasteboard(name: .drag) + let eventType = NSApp.currentEvent?.type + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropOverlay( + pasteboardTypes: pb.types, + eventType: eventType + ) +#if DEBUG + logHitTestDecision( + pasteboardTypes: pb.types, + eventType: eventType, + shouldCapture: shouldCapture + ) +#endif + guard shouldCapture else { return nil } + + return super.hitTest(point) + } + + // MARK: Mouse forwarding — safety net for the rare case where stale drag pasteboard + // data causes hitTest to return self when no drag is actually active. + // We hit-test contentView directly and dispatch to the target rather than using + // window.sendEvent(), which caches the mouse target and causes infinite recursion. + + private func forwardEvent(_ event: NSEvent) { + guard !isForwardingMouseEvent else { return } + guard let window, let contentView = window.contentView else { return } + let eventButton = dragButton(for: event) + + isForwardingMouseEvent = true + isHidden = true + defer { + isHidden = false + isForwardingMouseEvent = false + } + + let target: NSView? + if let eventButton, + forwardedMouseDragButton == eventButton, + let activeTarget = forwardedMouseDragTarget, + activeTarget.window != nil { + // Preserve normal AppKit mouse-delivery semantics: once a drag starts, + // keep routing dragged/up events to the original mouseDown target. + target = activeTarget + } else { + let point = contentView.convert(event.locationInWindow, from: nil) + target = contentView.hitTest(point) + } + + guard let target, target !== self else { + if shouldTrackForwardedMouseDragEnd(for: event.type), + let eventButton, + forwardedMouseDragButton == eventButton { + forwardedMouseDragTarget = nil + forwardedMouseDragButton = nil + } + return + } + + if shouldTrackForwardedMouseDragStart(for: event.type), let eventButton { + forwardedMouseDragTarget = target + forwardedMouseDragButton = eventButton + } + + switch event.type { + case .leftMouseDown: target.mouseDown(with: event) + case .leftMouseUp: target.mouseUp(with: event) + case .leftMouseDragged: target.mouseDragged(with: event) + case .rightMouseDown: target.rightMouseDown(with: event) + case .rightMouseUp: target.rightMouseUp(with: event) + case .rightMouseDragged: target.rightMouseDragged(with: event) + case .otherMouseDown: target.otherMouseDown(with: event) + case .otherMouseUp: target.otherMouseUp(with: event) + case .otherMouseDragged: target.otherMouseDragged(with: event) + case .scrollWheel: target.scrollWheel(with: event) + default: break + } + + if shouldTrackForwardedMouseDragEnd(for: event.type), + let eventButton, + forwardedMouseDragButton == eventButton { + forwardedMouseDragTarget = nil + forwardedMouseDragButton = nil + } + } + + override func mouseDown(with event: NSEvent) { forwardEvent(event) } + override func mouseUp(with event: NSEvent) { forwardEvent(event) } + override func mouseDragged(with event: NSEvent) { forwardEvent(event) } + override func rightMouseDown(with event: NSEvent) { forwardEvent(event) } + override func rightMouseUp(with event: NSEvent) { forwardEvent(event) } + override func rightMouseDragged(with event: NSEvent) { forwardEvent(event) } + override func otherMouseDown(with event: NSEvent) { forwardEvent(event) } + override func otherMouseUp(with event: NSEvent) { forwardEvent(event) } + override func otherMouseDragged(with event: NSEvent) { forwardEvent(event) } + override func scrollWheel(with event: NSEvent) { forwardEvent(event) } + + // MARK: NSDraggingDestination – accept file drops over terminal and browser views. + // + // AppKit sends draggingEntered once when the drag enters this overlay, then + // draggingUpdated as the cursor moves within it. We track which WKWebView (if + // any) is under the cursor and synthesize enter/exit calls so the browser's + // HTML5 drag events (dragenter, dragleave, drop) fire correctly. + + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + return updateDragTarget(sender, phase: "entered") + } + + override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + return updateDragTarget(sender, phase: "updated") + } + + override func draggingExited(_ sender: (any NSDraggingInfo)?) { + preparedDragWebView = nil + if let prev = activeDragWebView { + prev.draggingExited(sender) + activeDragWebView = nil + } + } + + override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { + let hasLocalDraggingSource = sender.draggingSource != nil + let types = sender.draggingPasteboard.types + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( + pasteboardTypes: types, + hasLocalDraggingSource: hasLocalDraggingSource + ) + let webView = shouldCapture ? (activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)) : nil + let terminal = terminalUnderPoint(sender.draggingLocation) + let hasTerminalTarget = terminal != nil +#if DEBUG + logDragRouteDecision( + phase: "prepare", + pasteboardTypes: types, + shouldCapture: shouldCapture, + hasLocalDraggingSource: hasLocalDraggingSource, + hasTerminalTarget: hasTerminalTarget + ) +#endif + guard shouldCapture else { + preparedDragWebView = nil + return false + } + if let webView { + preparedDragWebView = webView + return webView.prepareForDragOperation(sender) + } + preparedDragWebView = nil + return hasTerminalTarget + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + let hasLocalDraggingSource = sender.draggingSource != nil + let types = sender.draggingPasteboard.types + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( + pasteboardTypes: types, + hasLocalDraggingSource: hasLocalDraggingSource + ) + let webView = shouldCapture + ? (preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)) + : nil + let terminal = terminalUnderPoint(sender.draggingLocation) + let hasTerminalTarget = terminal != nil +#if DEBUG + logDragRouteDecision( + phase: "perform", + pasteboardTypes: types, + shouldCapture: shouldCapture, + hasLocalDraggingSource: hasLocalDraggingSource, + hasTerminalTarget: hasTerminalTarget + ) +#endif + guard shouldCapture else { + preparedDragWebView = nil + activeDragWebView = nil + return false + } + if let webView { + preparedDragWebView = webView + return webView.performDragOperation(sender) + } + preparedDragWebView = nil + activeDragWebView = nil + guard let terminal else { return false } + return terminal.performDragOperation(sender) + } + + override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { + defer { + preparedDragWebView = nil + activeDragWebView = nil + } + guard let sender else { return } + let webView = preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation) + webView?.concludeDragOperation(sender) + } + + private func updateDragTarget(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation { + let loc = sender.draggingLocation + let hasLocalDraggingSource = sender.draggingSource != nil + let types = sender.draggingPasteboard.types + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( + pasteboardTypes: types, + hasLocalDraggingSource: hasLocalDraggingSource + ) + let webView = shouldCapture ? webViewUnderPoint(loc) : nil + + if let prev = activeDragWebView, prev !== webView { + prev.draggingExited(sender) + activeDragWebView = nil + } + + if let webView { + if activeDragWebView !== webView { + activeDragWebView = webView + return webView.draggingEntered(sender) + } + return webView.draggingUpdated(sender) + } + + let hasTerminalTarget = terminalUnderPoint(loc) != nil +#if DEBUG + logDragRouteDecision( + phase: phase, + pasteboardTypes: types, + shouldCapture: shouldCapture, + hasLocalDraggingSource: hasLocalDraggingSource, + hasTerminalTarget: hasTerminalTarget + ) +#endif + guard shouldCapture, hasTerminalTarget else { return [] } + return .copy + } + + private func debugPasteboardTypes(_ types: [NSPasteboard.PasteboardType]?) -> String { + guard let types, !types.isEmpty else { return "-" } + return types.map(\.rawValue).joined(separator: ",") + } + + /// Hit-tests the window to find a WKWebView (browser panel) under the cursor. + func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? { + if let window, + let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint(windowPoint, in: window) { + return portalWebView + } + + guard let window, let contentView = window.contentView else { return nil } + isHidden = true + defer { isHidden = false } + let point = contentView.convert(windowPoint, from: nil) + let hitView = contentView.hitTest(point) + + var current: NSView? = hitView + while let view = current { + if let webView = view as? WKWebView { return webView } + current = view.superview + } + return nil + } + + private func debugTopHitViewForCurrentEvent() -> String { + guard let window, + let currentEvent = NSApp.currentEvent, + let contentView = window.contentView, + let themeFrame = contentView.superview else { return "-" } + + let pointInTheme = themeFrame.convert(currentEvent.locationInWindow, from: nil) + // Don't toggle isHidden here — it triggers setNeedsDisplay which can + // exceed AppKit's display-pass limit during cursor-update display cycles. + guard let hit = themeFrame.hitTest(pointInTheme) else { return "nil" } + var chain: [String] = [] + var current: NSView? = hit + var depth = 0 + while let view = current, depth < 6 { + chain.append(debugHitViewDescriptor(view)) + current = view.superview + depth += 1 + } + return chain.joined(separator: "->") + } + + private func debugHitViewDescriptor(_ view: NSView) -> String { + let className = String(describing: type(of: view)) + let ptr = String(describing: Unmanaged.passUnretained(view).toOpaque()) + let dragTypes = debugRegisteredDragTypes(view) + return "\(className)@\(ptr){dragTypes=\(dragTypes)}" + } + + private func debugRegisteredDragTypes(_ view: NSView) -> String { + let types = view.registeredDraggedTypes + guard !types.isEmpty else { return "-" } + + let interestingTypes = types.filter { type in + let raw = type.rawValue + return raw == NSPasteboard.PasteboardType.fileURL.rawValue + || raw == DragOverlayRoutingPolicy.bonsplitTabTransferType.rawValue + || raw == DragOverlayRoutingPolicy.sidebarTabReorderType.rawValue + || raw.contains("public.text") + || raw.contains("public.url") + || raw.contains("public.data") + } + let selected = interestingTypes.isEmpty ? Array(types.prefix(3)) : interestingTypes + let rendered = selected.map(\.rawValue).joined(separator: ",") + if selected.count < types.count { + return "\(rendered),+\(types.count - selected.count)" + } + return rendered + } + + private func hasRelevantDragTypes(_ types: [NSPasteboard.PasteboardType]?) -> Bool { + guard let types else { return false } + return types.contains(.fileURL) + || types.contains(DragOverlayRoutingPolicy.bonsplitTabTransferType) + || types.contains(DragOverlayRoutingPolicy.sidebarTabReorderType) + } + + private func debugEventName(_ eventType: NSEvent.EventType?) -> String { + guard let eventType else { return "none" } + switch eventType { + case .cursorUpdate: return "cursorUpdate" + case .appKitDefined: return "appKitDefined" + case .systemDefined: return "systemDefined" + case .applicationDefined: return "applicationDefined" + case .periodic: return "periodic" + case .mouseMoved: return "mouseMoved" + case .mouseEntered: return "mouseEntered" + case .mouseExited: return "mouseExited" + case .flagsChanged: return "flagsChanged" + case .leftMouseDown: return "leftMouseDown" + case .leftMouseUp: return "leftMouseUp" + case .leftMouseDragged: return "leftMouseDragged" + case .rightMouseDown: return "rightMouseDown" + case .rightMouseUp: return "rightMouseUp" + case .rightMouseDragged: return "rightMouseDragged" + case .otherMouseDown: return "otherMouseDown" + case .otherMouseUp: return "otherMouseUp" + case .otherMouseDragged: return "otherMouseDragged" + case .scrollWheel: return "scrollWheel" + default: return "other(\(eventType.rawValue))" + } + } + +#if DEBUG + private func logHitTestDecision( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType?, + shouldCapture: Bool + ) { + let isDragEvent = eventType == .leftMouseDragged + || eventType == .rightMouseDragged + || eventType == .otherMouseDragged + guard shouldCapture || isDragEvent || hasRelevantDragTypes(pasteboardTypes) else { return } + + let signature = "\(shouldCapture ? 1 : 0)|\(debugEventName(eventType))|\(debugPasteboardTypes(pasteboardTypes))" + guard lastHitTestLogSignature != signature else { return } + lastHitTestLogSignature = signature + dlog( + "overlay.fileDrop.hitTest capture=\(shouldCapture ? 1 : 0) " + + "event=\(debugEventName(eventType)) " + + "topHit=\(debugTopHitViewForCurrentEvent()) " + + "types=\(debugPasteboardTypes(pasteboardTypes))" + ) + } + + private func logDragRouteDecision( + phase: String, + pasteboardTypes: [NSPasteboard.PasteboardType]?, + shouldCapture: Bool, + hasLocalDraggingSource: Bool, + hasTerminalTarget: Bool + ) { + guard shouldCapture || hasRelevantDragTypes(pasteboardTypes) else { return } + let signature = [ + shouldCapture ? "1" : "0", + hasLocalDraggingSource ? "1" : "0", + hasTerminalTarget ? "1" : "0", + debugPasteboardTypes(pasteboardTypes) + ].joined(separator: "|") + guard lastDragRouteLogSignatureByPhase[phase] != signature else { return } + lastDragRouteLogSignatureByPhase[phase] = signature + dlog( + "overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " + + "localSource=\(hasLocalDraggingSource ? 1 : 0) " + + "hasTerminal=\(hasTerminalTarget ? 1 : 0) " + + "types=\(debugPasteboardTypes(pasteboardTypes))" + ) + } +#endif + /// Hit-tests the window to find the GhosttyNSView under the cursor. + func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { + if let window, + let portalTerminal = TerminalWindowPortalRegistry.terminalViewAtWindowPoint(windowPoint, in: window) { + return portalTerminal + } + + guard let window, let contentView = window.contentView else { return nil } + isHidden = true + defer { isHidden = false } + let point = contentView.convert(windowPoint, from: nil) + let hitView = contentView.hitTest(point) + + var current: NSView? = hitView + while let view = current { + if let terminal = view as? GhosttyNSView { return terminal } + current = view.superview + } + return nil + } +} From 3938784096f0aeca42fdac74b233bf3879939597 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:53:12 -0300 Subject: [PATCH 3/9] refactor(contentview): extract CommandPaletteSearchEngine.swift (mechanical move) Move the command-palette fuzzy-search engine (switcher search metadata/indexer, fuzzy matcher, search corpus entry/result types, search engine) out of ContentView.swift into its own file, verbatim. Zero coupling to ContentView state. Refs #94. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/CommandPaletteSearchEngine.swift | 993 +++++++++++++++++++++++ Sources/ContentView.swift | 987 ---------------------- 3 files changed, 997 insertions(+), 987 deletions(-) create mode 100644 Sources/CommandPaletteSearchEngine.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 1df369db106..009e2b37251 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ A5FF0005 /* ContentView+CommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0015 /* ContentView+CommandPalette.swift */; }; NRCV00000000000000000005 /* TabItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000006 /* TabItemView.swift */; }; NRCV00000000000000000003 /* FileDropOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000004 /* FileDropOverlayView.swift */; }; + NRCV00000000000000000001 /* CommandPaletteSearchEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; }; A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; @@ -239,6 +240,7 @@ A5FF0015 /* ContentView+CommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+CommandPalette.swift"; sourceTree = ""; }; NRCV00000000000000000006 /* TabItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItemView.swift; sourceTree = ""; }; NRCV00000000000000000004 /* FileDropOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayView.swift; sourceTree = ""; }; + NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngine.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; @@ -510,6 +512,7 @@ A5FF0015 /* ContentView+CommandPalette.swift */, NRCV00000000000000000006 /* TabItemView.swift */, NRCV00000000000000000004 /* FileDropOverlayView.swift */, + NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, @@ -842,6 +845,7 @@ A5FF0005 /* ContentView+CommandPalette.swift in Sources */, NRCV00000000000000000005 /* TabItemView.swift in Sources */, NRCV00000000000000000003 /* FileDropOverlayView.swift in Sources */, + NRCV00000000000000000001 /* CommandPaletteSearchEngine.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, diff --git a/Sources/CommandPaletteSearchEngine.swift b/Sources/CommandPaletteSearchEngine.swift new file mode 100644 index 00000000000..de3444a9fab --- /dev/null +++ b/Sources/CommandPaletteSearchEngine.swift @@ -0,0 +1,993 @@ +// Command palette fuzzy-search engine, extracted from ContentView.swift (nuclear-review #94.1). +// Pure move: search metadata, indexer, fuzzy matcher, corpus types, and the search engine. +// Zero coupling to ContentView state — behavior-identical relocation. + +import Foundation + +struct CommandPaletteSwitcherSearchMetadata: Equatable, Sendable { + let directories: [String] + let branches: [String] + let ports: [Int] + let description: String? + + init( + directories: [String] = [], + branches: [String] = [], + ports: [Int] = [], + description: String? = nil + ) { + self.directories = directories + self.branches = branches + self.ports = ports + self.description = description + } +} + +enum CommandPaletteSwitcherSearchIndexer { + enum MetadataDetail { + case workspace + case surface + } + + private static let metadataDelimiters = CharacterSet(charactersIn: "/\\.:_- ") + + static func keywords( + baseKeywords: [String], + metadata: CommandPaletteSwitcherSearchMetadata, + detail: MetadataDetail = .surface + ) -> [String] { + let metadataKeywords = metadataKeywordsForSearch(metadata, detail: detail) + return uniqueNormalizedPreservingOrder(baseKeywords + metadataKeywords) + } + + private static func metadataKeywordsForSearch( + _ metadata: CommandPaletteSwitcherSearchMetadata, + detail: MetadataDetail + ) -> [String] { + let directoryTokens = metadata.directories.flatMap { directoryTokensForSearch($0, detail: detail) } + let branchTokens = metadata.branches.flatMap { branchTokensForSearch($0, detail: detail) } + let portTokens = metadata.ports.flatMap(portTokensForSearch) + let descriptionTokens = descriptionTokensForSearch(metadata.description) + + var contextKeywords: [String] = [] + if !directoryTokens.isEmpty { + contextKeywords.append(contentsOf: ["directory", "dir", "cwd", "path"]) + } + if !branchTokens.isEmpty { + contextKeywords.append(contentsOf: ["branch", "git"]) + } + if !portTokens.isEmpty { + contextKeywords.append(contentsOf: ["port", "ports"]) + } + if !descriptionTokens.isEmpty { + contextKeywords.append(contentsOf: ["description", "descriptions", "notes", "note"]) + } + + return contextKeywords + directoryTokens + branchTokens + portTokens + descriptionTokens + } + + private static func directoryTokensForSearch( + _ rawDirectory: String, + detail: MetadataDetail + ) -> [String] { + let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + let standardized = (trimmed as NSString).standardizingPath + let canonical = standardized.isEmpty ? trimmed : standardized + let abbreviated = (canonical as NSString).abbreviatingWithTildeInPath + switch detail { + case .workspace: + return uniqueNormalizedPreservingOrder([trimmed, canonical, abbreviated]) + case .surface: + let basename = URL(fileURLWithPath: canonical, isDirectory: true).lastPathComponent + let components = canonical.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder( + [trimmed, canonical, abbreviated, basename] + components + ) + } + } + + private static func branchTokensForSearch( + _ rawBranch: String, + detail: MetadataDetail + ) -> [String] { + let trimmed = rawBranch.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + switch detail { + case .workspace: + return [trimmed] + case .surface: + let components = trimmed.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder([trimmed] + components) + } + } + + private static func portTokensForSearch(_ port: Int) -> [String] { + guard (1...65535).contains(port) else { return [] } + let portText = String(port) + return [portText, ":\(portText)"] + } + + private static func descriptionTokensForSearch(_ rawDescription: String?) -> [String] { + let trimmed = rawDescription?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return [] } + let normalizedWhitespace = trimmed.replacingOccurrences( + of: "\\s+", + with: " ", + options: .regularExpression + ) + let components = normalizedWhitespace.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder([trimmed, normalizedWhitespace] + components) + } + + private static func uniqueNormalizedPreservingOrder(_ values: [String]) -> [String] { + var result: [String] = [] + var seen: Set = [] + result.reserveCapacity(values.count) + + for value in values { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let normalizedKey = trimmed + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + guard seen.insert(normalizedKey).inserted else { continue } + result.append(trimmed) + } + return result + } +} + +enum CommandPaletteFuzzyMatcher { + private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] + + private enum SingleEditWordPrefixEditKind { + case candidateExtraCharacter + case tokenExtraCharacter + case substitutedCharacter + case transposedCharacters + + var basePenalty: Int { + switch self { + case .candidateExtraCharacter: + return 0 + case .tokenExtraCharacter: + return 10 + case .transposedCharacters: + return 24 + case .substitutedCharacter: + return 40 + } + } + } + + private struct SingleEditWordPrefixMatch { + let matchedIndices: Set + let segmentStart: Int + let segmentLength: Int + let prefixLength: Int + let editPosition: Int + let editKind: SingleEditWordPrefixEditKind + } + + struct PreparedQuery { + let normalizedText: String + let tokens: [String] + + var isEmpty: Bool { + tokens.isEmpty + } + } + + static func preparedQuery(_ query: String) -> PreparedQuery { + let normalizedQuery = normalizeForSearch(query) + return PreparedQuery( + normalizedText: normalizedQuery, + tokens: normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } + ) + } + + static func normalizeForSearch(_ text: String) -> String { + text + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + } + + static func score(query: String, candidate: String) -> Int? { + score(query: query, candidates: [candidate]) + } + + static func score(query: String, candidates: [String]) -> Int? { + score( + preparedQuery: preparedQuery(query), + normalizedCandidates: candidates + .map(normalizeForSearch) + .filter { !$0.isEmpty } + ) + } + + static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? { + guard !preparedQuery.isEmpty else { return 0 } + guard !normalizedCandidates.isEmpty else { return nil } + + var totalScore = 0 + for token in preparedQuery.tokens { + var bestTokenScore: Int? + for candidate in normalizedCandidates { + guard let candidateScore = scoreToken(token, in: candidate) else { continue } + bestTokenScore = max(bestTokenScore ?? candidateScore, candidateScore) + } + guard let bestTokenScore else { return nil } + totalScore += bestTokenScore + } + return totalScore + } + + static func matchCharacterIndices(query: String, candidate: String) -> Set { + matchCharacterIndices(preparedQuery: preparedQuery(query), candidate: candidate) + } + + static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set { + guard !preparedQuery.isEmpty else { return [] } + + let loweredCandidate = normalizeForSearch(candidate) + guard !loweredCandidate.isEmpty else { return [] } + + let candidateChars = Array(loweredCandidate) + var matched: Set = [] + + for token in preparedQuery.tokens { + if token == loweredCandidate { + matched.formUnion(0.. Int? { + guard !token.isEmpty else { return 0 } + + let candidateChars = Array(candidate) + let tokenChars = Array(token) + guard tokenChars.count <= candidateChars.count else { return nil } + + if token == candidate { + return 8000 + } + if candidate.hasPrefix(token) { + return 6800 - max(0, candidate.count - token.count) + } + + var bestScore: Int? + if let wordExactScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: true) { + bestScore = max(bestScore ?? wordExactScore, wordExactScore) + } + if let wordPrefixScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: false) { + bestScore = max(bestScore ?? wordPrefixScore, wordPrefixScore) + } + if let singleEditPrefixScore = singleEditWordPrefixScore( + tokenChars: tokenChars, + candidateChars: candidateChars + ) { + bestScore = max(bestScore ?? singleEditPrefixScore, singleEditPrefixScore) + } + + if let range = candidate.range(of: token) { + let distance = candidate.distance(from: candidate.startIndex, to: range.lowerBound) + let lengthPenalty = max(0, candidate.count - token.count) + let boundaryBoost: Int = { + guard distance > 0 else { return 220 } + let prior = candidateChars[distance - 1] + return tokenBoundaryChars.contains(prior) ? 180 : 0 + }() + let containsScore = 4200 + boundaryBoost - (distance * 9) - lengthPenalty + bestScore = max(bestScore ?? containsScore, containsScore) + } + + if let initialismScore = initialismScore(tokenChars: tokenChars, candidateChars: candidateChars) { + bestScore = max(bestScore ?? initialismScore, initialismScore) + } + + if let stitchedScore = stitchedWordPrefixScore(tokenChars: tokenChars, candidateChars: candidateChars) { + bestScore = max(bestScore ?? stitchedScore, stitchedScore) + } + + if tokenChars.count <= 3, let subsequence = subsequenceScore(token: token, candidate: candidate) { + bestScore = max(bestScore ?? subsequence, subsequence) + } + + guard let bestScore else { return nil } + return max(1, bestScore) + } + + private static func bestWordScore( + tokenChars: [Character], + candidateChars: [Character], + requireExactWord: Bool + ) -> Int? { + guard !tokenChars.isEmpty else { return nil } + + var best: Int? + for segment in wordSegments(candidateChars) { + let wordLength = segment.end - segment.start + guard tokenChars.count <= wordLength else { continue } + + var matchesPrefix = true + for offset in 0.. Int? { + guard let match = singleEditWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidateChars + ) else { + return nil + } + return singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) + } + + private static func singleEditWordPrefixScore( + match: SingleEditWordPrefixMatch, + candidateLength: Int + ) -> Int { + let lengthPenalty = max(0, match.segmentLength - match.prefixLength) * 6 + let distancePenalty = match.segmentStart * 8 + let trailingPenalty = max(0, candidateLength - match.segmentLength) + let editPositionPenalty = max(0, match.editPosition - match.segmentStart) * 10 + return 5000 + - match.editKind.basePenalty + - distancePenalty + - lengthPenalty + - trailingPenalty + - editPositionPenalty + } + + private static func initialismScore(tokenChars: [Character], candidateChars: [Character]) -> Int? { + guard !tokenChars.isEmpty else { return nil } + let segments = wordSegments(candidateChars) + guard tokenChars.count <= segments.count else { return nil } + + var matchedStarts: [Int] = [] + var searchWordIndex = 0 + + for tokenChar in tokenChars { + var found = false + while searchWordIndex < segments.count { + let segment = segments[searchWordIndex] + searchWordIndex += 1 + if candidateChars[segment.start] == tokenChar { + matchedStarts.append(segment.start) + found = true + break + } + } + if !found { return nil } + } + + let firstStart = matchedStarts.first ?? 0 + let skippedWords = max(0, segments.count - tokenChars.count) + return 3000 + (tokenChars.count * 160) - (firstStart * 5) - (skippedWords * 30) + } + + private static func tokenPrefixMatches( + tokenChars: [Character], + tokenStart: Int, + length: Int, + candidateChars: [Character], + candidateStart: Int + ) -> Bool { + guard length >= 0 else { return false } + guard tokenStart + length <= tokenChars.count else { return false } + guard candidateStart + length <= candidateChars.count else { return false } + guard length > 0 else { return true } + + for offset in 0.. Int? { + guard tokenChars.count >= 4 else { return nil } + let segments = wordSegments(candidateChars) + guard segments.count >= 2 else { return nil } + + struct StitchState: Hashable { + let tokenIndex: Int + let wordIndex: Int + let usedWords: Int + } + + var memo: [StitchState: Int?] = [:] + + func dfs(tokenIndex: Int, wordIndex: Int, usedWords: Int) -> Int? { + if tokenIndex == tokenChars.count { + return usedWords >= 2 ? 0 : nil + } + guard wordIndex < segments.count else { return nil } + + let state = StitchState(tokenIndex: tokenIndex, wordIndex: wordIndex, usedWords: usedWords) + if let cached = memo[state] { + return cached + } + + var best: Int? + let remainingChars = tokenChars.count - tokenIndex + for segmentIndex in wordIndex.. 0 else { continue } + + let skippedWords = max(0, segmentIndex - wordIndex) + let skipPenalty = skippedWords * 120 + for chunkLength in stride(from: maxChunk, through: 1, by: -1) { + guard tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: tokenIndex, + length: chunkLength, + candidateChars: candidateChars, + candidateStart: segment.start + ) else { + continue + } + guard let suffixScore = dfs( + tokenIndex: tokenIndex + chunkLength, + wordIndex: segmentIndex + 1, + usedWords: min(2, usedWords + 1) + ) else { + continue + } + + let chunkCoverage = chunkLength * 220 + let contiguityBonus = segmentIndex == wordIndex ? 80 : 0 + let segmentRemainderPenalty = max(0, segmentLength - chunkLength) * 9 + let distancePenalty = segment.start * 4 + let chunkScore = chunkCoverage + contiguityBonus - segmentRemainderPenalty - distancePenalty - skipPenalty + let totalScore = suffixScore + chunkScore + best = max(best ?? totalScore, totalScore) + } + } + + memo[state] = best + return best + } + + guard let stitchedScore = dfs(tokenIndex: 0, wordIndex: 0, usedWords: 0) else { return nil } + let lengthPenalty = max(0, candidateChars.count - tokenChars.count) + return 3500 + stitchedScore - lengthPenalty + } + + private static func stitchedWordPrefixMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count >= 4 else { return nil } + + let segments = wordSegments(candidateChars) + guard segments.count >= 2 else { return nil } + + var tokenIndex = 0 + var nextWordIndex = 0 + var usedWords = 0 + var matchedIndices: Set = [] + + while tokenIndex < tokenChars.count { + let remainingChars = tokenChars.count - tokenIndex + var foundMatch = false + + for segmentIndex in nextWordIndex.. 0 else { continue } + + for chunkLength in stride(from: maxChunk, through: 1, by: -1) { + guard tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: tokenIndex, + length: chunkLength, + candidateChars: candidateChars, + candidateStart: segment.start + ) else { + continue + } + + matchedIndices.formUnion(segment.start..<(segment.start + chunkLength)) + tokenIndex += chunkLength + nextWordIndex = segmentIndex + 1 + usedWords += 1 + foundMatch = true + break + } + + if foundMatch { break } + } + + if !foundMatch { return nil } + } + + guard usedWords >= 2 else { return nil } + return matchedIndices + } + + private static func singleEditWordPrefixMatch( + token: String, + candidate: String + ) -> SingleEditWordPrefixMatch? { + singleEditWordPrefixMatch( + tokenChars: Array(token), + candidateChars: Array(candidate) + ) + } + + private static func singleEditWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character] + ) -> SingleEditWordPrefixMatch? { + guard tokenChars.count >= 4 else { return nil } + + var bestMatch: SingleEditWordPrefixMatch? + var bestScore: Int? + + for segment in wordSegments(candidateChars) { + guard let match = singleEditWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidateChars, + segment: segment + ) else { + continue + } + + let score = singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) + if let bestScore, score <= bestScore { + continue + } + bestScore = score + bestMatch = match + } + + return bestMatch + } + + private static func singleEditWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character], + segment: (start: Int, end: Int) + ) -> SingleEditWordPrefixMatch? { + guard tokenChars.count >= 4 else { return nil } + + let segmentLength = segment.end - segment.start + guard segmentLength + 1 >= tokenChars.count else { return nil } + + let exactPrefixLength = min(tokenChars.count, segmentLength) + var mismatchOffset = 0 + while mismatchOffset < exactPrefixLength, + candidateChars[segment.start + mismatchOffset] == tokenChars[mismatchOffset] + { + mismatchOffset += 1 + } + + if mismatchOffset == tokenChars.count { + let prefixLength = tokenChars.count + 1 + guard segmentLength >= prefixLength else { return nil } + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: prefixLength, + editPosition: segment.start + tokenChars.count, + editKind: .candidateExtraCharacter + ) + } + + if mismatchOffset == segmentLength { + let prefixLength = tokenChars.count - 1 + guard prefixLength > 0 else { return nil } + guard tokenChars.count == segmentLength + 1 else { return nil } + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + prefixLength)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: prefixLength, + editPosition: segment.start + prefixLength, + editKind: .tokenExtraCharacter + ) + } + + let mismatchCandidateIndex = segment.start + mismatchOffset + + if segmentLength >= tokenChars.count + 1, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset, + length: tokenChars.count - mismatchOffset, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 1 + ) + { + var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count + 1)) + matchedIndices.remove(mismatchCandidateIndex) + return SingleEditWordPrefixMatch( + matchedIndices: matchedIndices, + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count + 1, + editPosition: mismatchCandidateIndex, + editKind: .candidateExtraCharacter + ) + } + + if tokenChars.count >= 2, + segmentLength >= tokenChars.count - 1, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 1, + length: tokenChars.count - mismatchOffset - 1, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + ) + { + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count - 1)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count - 1, + editPosition: mismatchCandidateIndex, + editKind: .tokenExtraCharacter + ) + } + + if segmentLength >= tokenChars.count, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 1, + length: tokenChars.count - mismatchOffset - 1, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 1 + ) + { + var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count)) + matchedIndices.remove(mismatchCandidateIndex) + return SingleEditWordPrefixMatch( + matchedIndices: matchedIndices, + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count, + editPosition: mismatchCandidateIndex, + editKind: .substitutedCharacter + ) + } + + if segmentLength >= tokenChars.count, + mismatchOffset + 1 < tokenChars.count, + mismatchCandidateIndex + 1 < segment.end, + tokenChars[mismatchOffset] == candidateChars[mismatchCandidateIndex + 1], + tokenChars[mismatchOffset + 1] == candidateChars[mismatchCandidateIndex], + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 2, + length: tokenChars.count - mismatchOffset - 2, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 2 + ) + { + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count, + editPosition: mismatchCandidateIndex, + editKind: .transposedCharacters + ) + } + + return nil + } + + private static func wordSegments(_ candidateChars: [Character]) -> [(start: Int, end: Int)] { + var segments: [(start: Int, end: Int)] = [] + var index = 0 + + while index < candidateChars.count { + while index < candidateChars.count, tokenBoundaryChars.contains(candidateChars[index]) { + index += 1 + } + guard index < candidateChars.count else { break } + let start = index + while index < candidateChars.count, !tokenBoundaryChars.contains(candidateChars[index]) { + index += 1 + } + segments.append((start: start, end: index)) + } + + return segments + } + + private static func subsequenceScore(token: String, candidate: String) -> Int? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count <= candidateChars.count else { return nil } + + var searchIndex = 0 + var previousMatch = -1 + var consecutiveRun = 0 + var score = 0 + + for tokenChar in tokenChars { + var foundIndex: Int? + while searchIndex < candidateChars.count { + if candidateChars[searchIndex] == tokenChar { + foundIndex = searchIndex + break + } + searchIndex += 1 + } + guard let matchedIndex = foundIndex else { return nil } + + score += 90 + if matchedIndex == 0 || tokenBoundaryChars.contains(candidateChars[matchedIndex - 1]) { + score += 140 + } + if matchedIndex == previousMatch + 1 { + consecutiveRun += 1 + score += min(200, consecutiveRun * 45) + } else { + consecutiveRun = 0 + score -= min(120, max(0, matchedIndex - previousMatch - 1) * 4) + } + + previousMatch = matchedIndex + searchIndex = matchedIndex + 1 + } + + score -= max(0, candidateChars.count - tokenChars.count) + return max(1, score) + } + + private static func subsequenceMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count <= candidateChars.count else { return nil } + + var indices: Set = [] + var searchIndex = 0 + + for tokenChar in tokenChars { + var foundIndex: Int? + while searchIndex < candidateChars.count { + if candidateChars[searchIndex] == tokenChar { + foundIndex = searchIndex + break + } + searchIndex += 1 + } + guard let matchIndex = foundIndex else { return nil } + indices.insert(matchIndex) + searchIndex = matchIndex + 1 + } + + return indices + } + + private static func initialismMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard !tokenChars.isEmpty else { return nil } + + let segments = wordSegments(candidateChars) + guard tokenChars.count <= segments.count else { return nil } + + var matched: Set = [] + var searchWordIndex = 0 + + for tokenChar in tokenChars { + var found = false + while searchWordIndex < segments.count { + let segment = segments[searchWordIndex] + searchWordIndex += 1 + if candidateChars[segment.start] == tokenChar { + matched.insert(segment.start) + found = true + break + } + } + if !found { return nil } + } + + return matched + } +} + +struct CommandPaletteSearchCorpusEntry: Sendable where Payload: Sendable { + let payload: Payload + let rank: Int + let title: String + let normalizedTitle: String + let normalizedSearchableTexts: [String] + + init(payload: Payload, rank: Int, title: String, searchableTexts: [String]) { + self.payload = payload + self.rank = rank + self.title = title + self.normalizedTitle = CommandPaletteFuzzyMatcher.normalizeForSearch(title) + self.normalizedSearchableTexts = searchableTexts + .map(CommandPaletteFuzzyMatcher.normalizeForSearch) + .filter { !$0.isEmpty } + } +} + +struct CommandPaletteSearchCorpusResult: Sendable where Payload: Sendable { + let payload: Payload + let rank: Int + let title: String + let score: Int + let titleMatchIndices: Set +} + +enum CommandPaletteSearchEngine { + private static let titleMatchBonus = 2000 + + static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + historyBoost: (Payload, Bool) -> Int + ) -> [CommandPaletteSearchCorpusResult] { + search( + entries: entries, + query: query, + historyBoost: historyBoost, + shouldCancel: nil + ) + } + + static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + historyBoost: (Payload, Bool) -> Int, + shouldCancel: @escaping () -> Bool + ) -> [CommandPaletteSearchCorpusResult] { + search( + entries: entries, + query: query, + historyBoost: historyBoost, + shouldCancel: Optional(shouldCancel) + ) + } + + private static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + historyBoost: (Payload, Bool) -> Int, + shouldCancel: (() -> Bool)? + ) -> [CommandPaletteSearchCorpusResult] { + let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) + let queryIsEmpty = preparedQuery.isEmpty + var results: [CommandPaletteSearchCorpusResult] = [] + results.reserveCapacity(entries.count) + + func shouldCancelSearch(at index: Int) -> Bool { + guard let shouldCancel else { return false } + return index % 16 == 0 && shouldCancel() + } + + if queryIsEmpty { + for (index, entry) in entries.enumerated() { + if shouldCancelSearch(at: index) { return [] } + results.append( + CommandPaletteSearchCorpusResult( + payload: entry.payload, + rank: entry.rank, + title: entry.title, + score: historyBoost(entry.payload, true), + titleMatchIndices: [] + ) + ) + } + } else { + for (index, entry) in entries.enumerated() { + if shouldCancelSearch(at: index) { return [] } + guard let fuzzyScore = weightedScore( + preparedQuery: preparedQuery, + entry: entry + ) else { + continue + } + results.append( + CommandPaletteSearchCorpusResult( + payload: entry.payload, + rank: entry.rank, + title: entry.title, + score: fuzzyScore + historyBoost(entry.payload, false), + titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( + preparedQuery: preparedQuery, + candidate: entry.title + ) + ) + ) + } + } + + if shouldCancel?() == true { return [] } + + return results.sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + if lhs.rank != rhs.rank { return lhs.rank < rhs.rank } + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + } + + private static func weightedScore( + preparedQuery: CommandPaletteFuzzyMatcher.PreparedQuery, + entry: CommandPaletteSearchCorpusEntry + ) -> Int? { + guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( + preparedQuery: preparedQuery, + normalizedCandidates: entry.normalizedSearchableTexts + ) else { + return nil + } + guard !entry.normalizedTitle.isEmpty, + let titleScore = CommandPaletteFuzzyMatcher.score( + preparedQuery: preparedQuery, + normalizedCandidates: [entry.normalizedTitle] + ) else { + return fuzzyScore + } + return max(fuzzyScore, titleScore + titleMatchBonus) + } +} + diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 5500ee6ba19..656147c7d8c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7249,993 +7249,6 @@ struct ContentView: View { #endif } -struct CommandPaletteSwitcherSearchMetadata: Equatable, Sendable { - let directories: [String] - let branches: [String] - let ports: [Int] - let description: String? - - init( - directories: [String] = [], - branches: [String] = [], - ports: [Int] = [], - description: String? = nil - ) { - self.directories = directories - self.branches = branches - self.ports = ports - self.description = description - } -} - -enum CommandPaletteSwitcherSearchIndexer { - enum MetadataDetail { - case workspace - case surface - } - - private static let metadataDelimiters = CharacterSet(charactersIn: "/\\.:_- ") - - static func keywords( - baseKeywords: [String], - metadata: CommandPaletteSwitcherSearchMetadata, - detail: MetadataDetail = .surface - ) -> [String] { - let metadataKeywords = metadataKeywordsForSearch(metadata, detail: detail) - return uniqueNormalizedPreservingOrder(baseKeywords + metadataKeywords) - } - - private static func metadataKeywordsForSearch( - _ metadata: CommandPaletteSwitcherSearchMetadata, - detail: MetadataDetail - ) -> [String] { - let directoryTokens = metadata.directories.flatMap { directoryTokensForSearch($0, detail: detail) } - let branchTokens = metadata.branches.flatMap { branchTokensForSearch($0, detail: detail) } - let portTokens = metadata.ports.flatMap(portTokensForSearch) - let descriptionTokens = descriptionTokensForSearch(metadata.description) - - var contextKeywords: [String] = [] - if !directoryTokens.isEmpty { - contextKeywords.append(contentsOf: ["directory", "dir", "cwd", "path"]) - } - if !branchTokens.isEmpty { - contextKeywords.append(contentsOf: ["branch", "git"]) - } - if !portTokens.isEmpty { - contextKeywords.append(contentsOf: ["port", "ports"]) - } - if !descriptionTokens.isEmpty { - contextKeywords.append(contentsOf: ["description", "descriptions", "notes", "note"]) - } - - return contextKeywords + directoryTokens + branchTokens + portTokens + descriptionTokens - } - - private static func directoryTokensForSearch( - _ rawDirectory: String, - detail: MetadataDetail - ) -> [String] { - let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return [] } - - let standardized = (trimmed as NSString).standardizingPath - let canonical = standardized.isEmpty ? trimmed : standardized - let abbreviated = (canonical as NSString).abbreviatingWithTildeInPath - switch detail { - case .workspace: - return uniqueNormalizedPreservingOrder([trimmed, canonical, abbreviated]) - case .surface: - let basename = URL(fileURLWithPath: canonical, isDirectory: true).lastPathComponent - let components = canonical.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } - return uniqueNormalizedPreservingOrder( - [trimmed, canonical, abbreviated, basename] + components - ) - } - } - - private static func branchTokensForSearch( - _ rawBranch: String, - detail: MetadataDetail - ) -> [String] { - let trimmed = rawBranch.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return [] } - switch detail { - case .workspace: - return [trimmed] - case .surface: - let components = trimmed.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } - return uniqueNormalizedPreservingOrder([trimmed] + components) - } - } - - private static func portTokensForSearch(_ port: Int) -> [String] { - guard (1...65535).contains(port) else { return [] } - let portText = String(port) - return [portText, ":\(portText)"] - } - - private static func descriptionTokensForSearch(_ rawDescription: String?) -> [String] { - let trimmed = rawDescription?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !trimmed.isEmpty else { return [] } - let normalizedWhitespace = trimmed.replacingOccurrences( - of: "\\s+", - with: " ", - options: .regularExpression - ) - let components = normalizedWhitespace.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } - return uniqueNormalizedPreservingOrder([trimmed, normalizedWhitespace] + components) - } - - private static func uniqueNormalizedPreservingOrder(_ values: [String]) -> [String] { - var result: [String] = [] - var seen: Set = [] - result.reserveCapacity(values.count) - - for value in values { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - let normalizedKey = trimmed - .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) - .lowercased() - guard seen.insert(normalizedKey).inserted else { continue } - result.append(trimmed) - } - return result - } -} - -enum CommandPaletteFuzzyMatcher { - private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] - - private enum SingleEditWordPrefixEditKind { - case candidateExtraCharacter - case tokenExtraCharacter - case substitutedCharacter - case transposedCharacters - - var basePenalty: Int { - switch self { - case .candidateExtraCharacter: - return 0 - case .tokenExtraCharacter: - return 10 - case .transposedCharacters: - return 24 - case .substitutedCharacter: - return 40 - } - } - } - - private struct SingleEditWordPrefixMatch { - let matchedIndices: Set - let segmentStart: Int - let segmentLength: Int - let prefixLength: Int - let editPosition: Int - let editKind: SingleEditWordPrefixEditKind - } - - struct PreparedQuery { - let normalizedText: String - let tokens: [String] - - var isEmpty: Bool { - tokens.isEmpty - } - } - - static func preparedQuery(_ query: String) -> PreparedQuery { - let normalizedQuery = normalizeForSearch(query) - return PreparedQuery( - normalizedText: normalizedQuery, - tokens: normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } - ) - } - - static func normalizeForSearch(_ text: String) -> String { - text - .trimmingCharacters(in: .whitespacesAndNewlines) - .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) - .lowercased() - } - - static func score(query: String, candidate: String) -> Int? { - score(query: query, candidates: [candidate]) - } - - static func score(query: String, candidates: [String]) -> Int? { - score( - preparedQuery: preparedQuery(query), - normalizedCandidates: candidates - .map(normalizeForSearch) - .filter { !$0.isEmpty } - ) - } - - static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? { - guard !preparedQuery.isEmpty else { return 0 } - guard !normalizedCandidates.isEmpty else { return nil } - - var totalScore = 0 - for token in preparedQuery.tokens { - var bestTokenScore: Int? - for candidate in normalizedCandidates { - guard let candidateScore = scoreToken(token, in: candidate) else { continue } - bestTokenScore = max(bestTokenScore ?? candidateScore, candidateScore) - } - guard let bestTokenScore else { return nil } - totalScore += bestTokenScore - } - return totalScore - } - - static func matchCharacterIndices(query: String, candidate: String) -> Set { - matchCharacterIndices(preparedQuery: preparedQuery(query), candidate: candidate) - } - - static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set { - guard !preparedQuery.isEmpty else { return [] } - - let loweredCandidate = normalizeForSearch(candidate) - guard !loweredCandidate.isEmpty else { return [] } - - let candidateChars = Array(loweredCandidate) - var matched: Set = [] - - for token in preparedQuery.tokens { - if token == loweredCandidate { - matched.formUnion(0.. Int? { - guard !token.isEmpty else { return 0 } - - let candidateChars = Array(candidate) - let tokenChars = Array(token) - guard tokenChars.count <= candidateChars.count else { return nil } - - if token == candidate { - return 8000 - } - if candidate.hasPrefix(token) { - return 6800 - max(0, candidate.count - token.count) - } - - var bestScore: Int? - if let wordExactScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: true) { - bestScore = max(bestScore ?? wordExactScore, wordExactScore) - } - if let wordPrefixScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: false) { - bestScore = max(bestScore ?? wordPrefixScore, wordPrefixScore) - } - if let singleEditPrefixScore = singleEditWordPrefixScore( - tokenChars: tokenChars, - candidateChars: candidateChars - ) { - bestScore = max(bestScore ?? singleEditPrefixScore, singleEditPrefixScore) - } - - if let range = candidate.range(of: token) { - let distance = candidate.distance(from: candidate.startIndex, to: range.lowerBound) - let lengthPenalty = max(0, candidate.count - token.count) - let boundaryBoost: Int = { - guard distance > 0 else { return 220 } - let prior = candidateChars[distance - 1] - return tokenBoundaryChars.contains(prior) ? 180 : 0 - }() - let containsScore = 4200 + boundaryBoost - (distance * 9) - lengthPenalty - bestScore = max(bestScore ?? containsScore, containsScore) - } - - if let initialismScore = initialismScore(tokenChars: tokenChars, candidateChars: candidateChars) { - bestScore = max(bestScore ?? initialismScore, initialismScore) - } - - if let stitchedScore = stitchedWordPrefixScore(tokenChars: tokenChars, candidateChars: candidateChars) { - bestScore = max(bestScore ?? stitchedScore, stitchedScore) - } - - if tokenChars.count <= 3, let subsequence = subsequenceScore(token: token, candidate: candidate) { - bestScore = max(bestScore ?? subsequence, subsequence) - } - - guard let bestScore else { return nil } - return max(1, bestScore) - } - - private static func bestWordScore( - tokenChars: [Character], - candidateChars: [Character], - requireExactWord: Bool - ) -> Int? { - guard !tokenChars.isEmpty else { return nil } - - var best: Int? - for segment in wordSegments(candidateChars) { - let wordLength = segment.end - segment.start - guard tokenChars.count <= wordLength else { continue } - - var matchesPrefix = true - for offset in 0.. Int? { - guard let match = singleEditWordPrefixMatch( - tokenChars: tokenChars, - candidateChars: candidateChars - ) else { - return nil - } - return singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) - } - - private static func singleEditWordPrefixScore( - match: SingleEditWordPrefixMatch, - candidateLength: Int - ) -> Int { - let lengthPenalty = max(0, match.segmentLength - match.prefixLength) * 6 - let distancePenalty = match.segmentStart * 8 - let trailingPenalty = max(0, candidateLength - match.segmentLength) - let editPositionPenalty = max(0, match.editPosition - match.segmentStart) * 10 - return 5000 - - match.editKind.basePenalty - - distancePenalty - - lengthPenalty - - trailingPenalty - - editPositionPenalty - } - - private static func initialismScore(tokenChars: [Character], candidateChars: [Character]) -> Int? { - guard !tokenChars.isEmpty else { return nil } - let segments = wordSegments(candidateChars) - guard tokenChars.count <= segments.count else { return nil } - - var matchedStarts: [Int] = [] - var searchWordIndex = 0 - - for tokenChar in tokenChars { - var found = false - while searchWordIndex < segments.count { - let segment = segments[searchWordIndex] - searchWordIndex += 1 - if candidateChars[segment.start] == tokenChar { - matchedStarts.append(segment.start) - found = true - break - } - } - if !found { return nil } - } - - let firstStart = matchedStarts.first ?? 0 - let skippedWords = max(0, segments.count - tokenChars.count) - return 3000 + (tokenChars.count * 160) - (firstStart * 5) - (skippedWords * 30) - } - - private static func tokenPrefixMatches( - tokenChars: [Character], - tokenStart: Int, - length: Int, - candidateChars: [Character], - candidateStart: Int - ) -> Bool { - guard length >= 0 else { return false } - guard tokenStart + length <= tokenChars.count else { return false } - guard candidateStart + length <= candidateChars.count else { return false } - guard length > 0 else { return true } - - for offset in 0.. Int? { - guard tokenChars.count >= 4 else { return nil } - let segments = wordSegments(candidateChars) - guard segments.count >= 2 else { return nil } - - struct StitchState: Hashable { - let tokenIndex: Int - let wordIndex: Int - let usedWords: Int - } - - var memo: [StitchState: Int?] = [:] - - func dfs(tokenIndex: Int, wordIndex: Int, usedWords: Int) -> Int? { - if tokenIndex == tokenChars.count { - return usedWords >= 2 ? 0 : nil - } - guard wordIndex < segments.count else { return nil } - - let state = StitchState(tokenIndex: tokenIndex, wordIndex: wordIndex, usedWords: usedWords) - if let cached = memo[state] { - return cached - } - - var best: Int? - let remainingChars = tokenChars.count - tokenIndex - for segmentIndex in wordIndex.. 0 else { continue } - - let skippedWords = max(0, segmentIndex - wordIndex) - let skipPenalty = skippedWords * 120 - for chunkLength in stride(from: maxChunk, through: 1, by: -1) { - guard tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: tokenIndex, - length: chunkLength, - candidateChars: candidateChars, - candidateStart: segment.start - ) else { - continue - } - guard let suffixScore = dfs( - tokenIndex: tokenIndex + chunkLength, - wordIndex: segmentIndex + 1, - usedWords: min(2, usedWords + 1) - ) else { - continue - } - - let chunkCoverage = chunkLength * 220 - let contiguityBonus = segmentIndex == wordIndex ? 80 : 0 - let segmentRemainderPenalty = max(0, segmentLength - chunkLength) * 9 - let distancePenalty = segment.start * 4 - let chunkScore = chunkCoverage + contiguityBonus - segmentRemainderPenalty - distancePenalty - skipPenalty - let totalScore = suffixScore + chunkScore - best = max(best ?? totalScore, totalScore) - } - } - - memo[state] = best - return best - } - - guard let stitchedScore = dfs(tokenIndex: 0, wordIndex: 0, usedWords: 0) else { return nil } - let lengthPenalty = max(0, candidateChars.count - tokenChars.count) - return 3500 + stitchedScore - lengthPenalty - } - - private static func stitchedWordPrefixMatchIndices(token: String, candidate: String) -> Set? { - let tokenChars = Array(token) - let candidateChars = Array(candidate) - guard tokenChars.count >= 4 else { return nil } - - let segments = wordSegments(candidateChars) - guard segments.count >= 2 else { return nil } - - var tokenIndex = 0 - var nextWordIndex = 0 - var usedWords = 0 - var matchedIndices: Set = [] - - while tokenIndex < tokenChars.count { - let remainingChars = tokenChars.count - tokenIndex - var foundMatch = false - - for segmentIndex in nextWordIndex.. 0 else { continue } - - for chunkLength in stride(from: maxChunk, through: 1, by: -1) { - guard tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: tokenIndex, - length: chunkLength, - candidateChars: candidateChars, - candidateStart: segment.start - ) else { - continue - } - - matchedIndices.formUnion(segment.start..<(segment.start + chunkLength)) - tokenIndex += chunkLength - nextWordIndex = segmentIndex + 1 - usedWords += 1 - foundMatch = true - break - } - - if foundMatch { break } - } - - if !foundMatch { return nil } - } - - guard usedWords >= 2 else { return nil } - return matchedIndices - } - - private static func singleEditWordPrefixMatch( - token: String, - candidate: String - ) -> SingleEditWordPrefixMatch? { - singleEditWordPrefixMatch( - tokenChars: Array(token), - candidateChars: Array(candidate) - ) - } - - private static func singleEditWordPrefixMatch( - tokenChars: [Character], - candidateChars: [Character] - ) -> SingleEditWordPrefixMatch? { - guard tokenChars.count >= 4 else { return nil } - - var bestMatch: SingleEditWordPrefixMatch? - var bestScore: Int? - - for segment in wordSegments(candidateChars) { - guard let match = singleEditWordPrefixMatch( - tokenChars: tokenChars, - candidateChars: candidateChars, - segment: segment - ) else { - continue - } - - let score = singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) - if let bestScore, score <= bestScore { - continue - } - bestScore = score - bestMatch = match - } - - return bestMatch - } - - private static func singleEditWordPrefixMatch( - tokenChars: [Character], - candidateChars: [Character], - segment: (start: Int, end: Int) - ) -> SingleEditWordPrefixMatch? { - guard tokenChars.count >= 4 else { return nil } - - let segmentLength = segment.end - segment.start - guard segmentLength + 1 >= tokenChars.count else { return nil } - - let exactPrefixLength = min(tokenChars.count, segmentLength) - var mismatchOffset = 0 - while mismatchOffset < exactPrefixLength, - candidateChars[segment.start + mismatchOffset] == tokenChars[mismatchOffset] - { - mismatchOffset += 1 - } - - if mismatchOffset == tokenChars.count { - let prefixLength = tokenChars.count + 1 - guard segmentLength >= prefixLength else { return nil } - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: prefixLength, - editPosition: segment.start + tokenChars.count, - editKind: .candidateExtraCharacter - ) - } - - if mismatchOffset == segmentLength { - let prefixLength = tokenChars.count - 1 - guard prefixLength > 0 else { return nil } - guard tokenChars.count == segmentLength + 1 else { return nil } - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + prefixLength)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: prefixLength, - editPosition: segment.start + prefixLength, - editKind: .tokenExtraCharacter - ) - } - - let mismatchCandidateIndex = segment.start + mismatchOffset - - if segmentLength >= tokenChars.count + 1, - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset, - length: tokenChars.count - mismatchOffset, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex + 1 - ) - { - var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count + 1)) - matchedIndices.remove(mismatchCandidateIndex) - return SingleEditWordPrefixMatch( - matchedIndices: matchedIndices, - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count + 1, - editPosition: mismatchCandidateIndex, - editKind: .candidateExtraCharacter - ) - } - - if tokenChars.count >= 2, - segmentLength >= tokenChars.count - 1, - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset + 1, - length: tokenChars.count - mismatchOffset - 1, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex - ) - { - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + tokenChars.count - 1)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count - 1, - editPosition: mismatchCandidateIndex, - editKind: .tokenExtraCharacter - ) - } - - if segmentLength >= tokenChars.count, - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset + 1, - length: tokenChars.count - mismatchOffset - 1, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex + 1 - ) - { - var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count)) - matchedIndices.remove(mismatchCandidateIndex) - return SingleEditWordPrefixMatch( - matchedIndices: matchedIndices, - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count, - editPosition: mismatchCandidateIndex, - editKind: .substitutedCharacter - ) - } - - if segmentLength >= tokenChars.count, - mismatchOffset + 1 < tokenChars.count, - mismatchCandidateIndex + 1 < segment.end, - tokenChars[mismatchOffset] == candidateChars[mismatchCandidateIndex + 1], - tokenChars[mismatchOffset + 1] == candidateChars[mismatchCandidateIndex], - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset + 2, - length: tokenChars.count - mismatchOffset - 2, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex + 2 - ) - { - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count, - editPosition: mismatchCandidateIndex, - editKind: .transposedCharacters - ) - } - - return nil - } - - private static func wordSegments(_ candidateChars: [Character]) -> [(start: Int, end: Int)] { - var segments: [(start: Int, end: Int)] = [] - var index = 0 - - while index < candidateChars.count { - while index < candidateChars.count, tokenBoundaryChars.contains(candidateChars[index]) { - index += 1 - } - guard index < candidateChars.count else { break } - let start = index - while index < candidateChars.count, !tokenBoundaryChars.contains(candidateChars[index]) { - index += 1 - } - segments.append((start: start, end: index)) - } - - return segments - } - - private static func subsequenceScore(token: String, candidate: String) -> Int? { - let tokenChars = Array(token) - let candidateChars = Array(candidate) - guard tokenChars.count <= candidateChars.count else { return nil } - - var searchIndex = 0 - var previousMatch = -1 - var consecutiveRun = 0 - var score = 0 - - for tokenChar in tokenChars { - var foundIndex: Int? - while searchIndex < candidateChars.count { - if candidateChars[searchIndex] == tokenChar { - foundIndex = searchIndex - break - } - searchIndex += 1 - } - guard let matchedIndex = foundIndex else { return nil } - - score += 90 - if matchedIndex == 0 || tokenBoundaryChars.contains(candidateChars[matchedIndex - 1]) { - score += 140 - } - if matchedIndex == previousMatch + 1 { - consecutiveRun += 1 - score += min(200, consecutiveRun * 45) - } else { - consecutiveRun = 0 - score -= min(120, max(0, matchedIndex - previousMatch - 1) * 4) - } - - previousMatch = matchedIndex - searchIndex = matchedIndex + 1 - } - - score -= max(0, candidateChars.count - tokenChars.count) - return max(1, score) - } - - private static func subsequenceMatchIndices(token: String, candidate: String) -> Set? { - let tokenChars = Array(token) - let candidateChars = Array(candidate) - guard tokenChars.count <= candidateChars.count else { return nil } - - var indices: Set = [] - var searchIndex = 0 - - for tokenChar in tokenChars { - var foundIndex: Int? - while searchIndex < candidateChars.count { - if candidateChars[searchIndex] == tokenChar { - foundIndex = searchIndex - break - } - searchIndex += 1 - } - guard let matchIndex = foundIndex else { return nil } - indices.insert(matchIndex) - searchIndex = matchIndex + 1 - } - - return indices - } - - private static func initialismMatchIndices(token: String, candidate: String) -> Set? { - let tokenChars = Array(token) - let candidateChars = Array(candidate) - guard !tokenChars.isEmpty else { return nil } - - let segments = wordSegments(candidateChars) - guard tokenChars.count <= segments.count else { return nil } - - var matched: Set = [] - var searchWordIndex = 0 - - for tokenChar in tokenChars { - var found = false - while searchWordIndex < segments.count { - let segment = segments[searchWordIndex] - searchWordIndex += 1 - if candidateChars[segment.start] == tokenChar { - matched.insert(segment.start) - found = true - break - } - } - if !found { return nil } - } - - return matched - } -} - -struct CommandPaletteSearchCorpusEntry: Sendable where Payload: Sendable { - let payload: Payload - let rank: Int - let title: String - let normalizedTitle: String - let normalizedSearchableTexts: [String] - - init(payload: Payload, rank: Int, title: String, searchableTexts: [String]) { - self.payload = payload - self.rank = rank - self.title = title - self.normalizedTitle = CommandPaletteFuzzyMatcher.normalizeForSearch(title) - self.normalizedSearchableTexts = searchableTexts - .map(CommandPaletteFuzzyMatcher.normalizeForSearch) - .filter { !$0.isEmpty } - } -} - -struct CommandPaletteSearchCorpusResult: Sendable where Payload: Sendable { - let payload: Payload - let rank: Int - let title: String - let score: Int - let titleMatchIndices: Set -} - -enum CommandPaletteSearchEngine { - private static let titleMatchBonus = 2000 - - static func search( - entries: [CommandPaletteSearchCorpusEntry], - query: String, - historyBoost: (Payload, Bool) -> Int - ) -> [CommandPaletteSearchCorpusResult] { - search( - entries: entries, - query: query, - historyBoost: historyBoost, - shouldCancel: nil - ) - } - - static func search( - entries: [CommandPaletteSearchCorpusEntry], - query: String, - historyBoost: (Payload, Bool) -> Int, - shouldCancel: @escaping () -> Bool - ) -> [CommandPaletteSearchCorpusResult] { - search( - entries: entries, - query: query, - historyBoost: historyBoost, - shouldCancel: Optional(shouldCancel) - ) - } - - private static func search( - entries: [CommandPaletteSearchCorpusEntry], - query: String, - historyBoost: (Payload, Bool) -> Int, - shouldCancel: (() -> Bool)? - ) -> [CommandPaletteSearchCorpusResult] { - let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) - let queryIsEmpty = preparedQuery.isEmpty - var results: [CommandPaletteSearchCorpusResult] = [] - results.reserveCapacity(entries.count) - - func shouldCancelSearch(at index: Int) -> Bool { - guard let shouldCancel else { return false } - return index % 16 == 0 && shouldCancel() - } - - if queryIsEmpty { - for (index, entry) in entries.enumerated() { - if shouldCancelSearch(at: index) { return [] } - results.append( - CommandPaletteSearchCorpusResult( - payload: entry.payload, - rank: entry.rank, - title: entry.title, - score: historyBoost(entry.payload, true), - titleMatchIndices: [] - ) - ) - } - } else { - for (index, entry) in entries.enumerated() { - if shouldCancelSearch(at: index) { return [] } - guard let fuzzyScore = weightedScore( - preparedQuery: preparedQuery, - entry: entry - ) else { - continue - } - results.append( - CommandPaletteSearchCorpusResult( - payload: entry.payload, - rank: entry.rank, - title: entry.title, - score: fuzzyScore + historyBoost(entry.payload, false), - titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( - preparedQuery: preparedQuery, - candidate: entry.title - ) - ) - ) - } - } - - if shouldCancel?() == true { return [] } - - return results.sorted { lhs, rhs in - if lhs.score != rhs.score { return lhs.score > rhs.score } - if lhs.rank != rhs.rank { return lhs.rank < rhs.rank } - return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending - } - } - - private static func weightedScore( - preparedQuery: CommandPaletteFuzzyMatcher.PreparedQuery, - entry: CommandPaletteSearchCorpusEntry - ) -> Int? { - guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( - preparedQuery: preparedQuery, - normalizedCandidates: entry.normalizedSearchableTexts - ) else { - return nil - } - guard !entry.normalizedTitle.isEmpty, - let titleScore = CommandPaletteFuzzyMatcher.score( - preparedQuery: preparedQuery, - normalizedCandidates: [entry.normalizedTitle] - ) else { - return fuzzyScore - } - return max(fuzzyScore, titleScore + titleMatchBonus) - } -} - private struct SidebarResizerAccessibilityModifier: ViewModifier { let accessibilityIdentifier: String? From 5b236db5e7ad3904e67d5df91bd7cfbab4177b0a Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 13:58:03 -0300 Subject: [PATCH 4/9] refactor: split free-standing types out of AppDelegate.swift Mechanical eviction of ~2,950 zero-dependency-on-AppDelegate free-standing types into their own files: - MainWindowHostingView.swift: MainWindowHostingView (SwiftUI hosting view) - TypingProfiler.swift: ProgramaTypingTiming, ProgramaMainRunLoopStallMonitor, ProgramaMainThreadTurnProfiler (all #if DEBUG) - TerminalDirectoryOpener.swift: FinderServicePathResolver, TerminalDirectoryOpenTarget - VSCodeIntegration.swift: VSCodeServeWebURLBuilder, VSCodeCLILaunchConfiguration(Builder), VSCodeServeWebController, ServeWebOutputCollector, ServeWebPortStore - WorkspaceShortcutMapper.swift: WorkspaceShortcutMapper - CLIInstaller.swift: ProgramaCLIPathInstaller - MenuBarIconRenderer.swift: MenuBarExtraController, notification menu snapshot/formatting types, MenuBarIconRenderer and its settings - WindowSwizzles.swift: the NSWindow/NSApplication method-swizzle implementations and their supporting file-scoped state Access-level widening required for cross-file visibility (no other code changes): - AppDelegate.recordTypingActivity(): fileprivate -> internal. Called from NSWindow.programa_sendEvent(_:), now in WindowSwizzles.swift. - extension NSWindow / extension NSApplication (swizzle methods): private -> internal. AppDelegate.installWindowResponderSwizzles() references programa_makeFirstResponder/programa_sendEvent/programa_performKeyEquivalent/ programa_applicationSendEvent via #selector(...) from AppDelegate.swift. - programaFirstResponderGuardCurrentEventOverride/HitViewOverride: private -> internal. AppDelegate.setWindowFirstResponderGuardTesting(...) and .clearWindowFirstResponderGuardTesting() write these directly. private extension AppDelegate (handleThemesReloadNotification) stays in AppDelegate.swift since it extends AppDelegate itself, not a zero-dependency type. Refs #95. --- GhosttyTabs.xcodeproj/project.pbxproj | 32 + Sources/AppDelegate.swift | 16661 ++++++++++-------------- Sources/CLIInstaller.swift | 324 + Sources/MainWindowHostingView.swift | 33 + Sources/MenuBarIconRenderer.swift | 669 + Sources/TerminalDirectoryOpener.swift | 297 + Sources/TypingProfiler.swift | 354 + Sources/VSCodeIntegration.swift | 593 + Sources/WindowSwizzles.swift | 734 ++ Sources/WorkspaceShortcutMapper.swift | 37 + 10 files changed, 9927 insertions(+), 9807 deletions(-) create mode 100644 Sources/CLIInstaller.swift create mode 100644 Sources/MainWindowHostingView.swift create mode 100644 Sources/MenuBarIconRenderer.swift create mode 100644 Sources/TerminalDirectoryOpener.swift create mode 100644 Sources/TypingProfiler.swift create mode 100644 Sources/VSCodeIntegration.swift create mode 100644 Sources/WindowSwizzles.swift create mode 100644 Sources/WorkspaceShortcutMapper.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 5ba0f619fc7..922e4bb722c 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -56,6 +56,14 @@ A5FF0011 /* WorkspaceRemoteDaemon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0001 /* WorkspaceRemoteDaemon.swift */; }; A5001407 /* WorkspaceContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001417 /* WorkspaceContentView.swift */; }; A5001093 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001090 /* AppDelegate.swift */; }; + NRAD1001 /* MainWindowHostingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0001 /* MainWindowHostingView.swift */; }; + NRAD1002 /* TypingProfiler.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0002 /* TypingProfiler.swift */; }; + NRAD1003 /* TerminalDirectoryOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0003 /* TerminalDirectoryOpener.swift */; }; + NRAD1004 /* VSCodeIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0004 /* VSCodeIntegration.swift */; }; + NRAD1005 /* WorkspaceShortcutMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0005 /* WorkspaceShortcutMapper.swift */; }; + NRAD1006 /* CLIInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0006 /* CLIInstaller.swift */; }; + NRAD1007 /* MenuBarIconRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0007 /* MenuBarIconRenderer.swift */; }; + NRAD1008 /* WindowSwizzles.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRAD0008 /* WindowSwizzles.swift */; }; A5FF0031 /* AppDelegate+UITestCmdClick.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0021 /* AppDelegate+UITestCmdClick.swift */; }; A5FF0032 /* AppDelegate+UITestStressWorkspaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0022 /* AppDelegate+UITestStressWorkspaces.swift */; }; A5FF0033 /* AppDelegate+UITestHarnesses.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0023 /* AppDelegate+UITestHarnesses.swift */; }; @@ -280,6 +288,14 @@ A5FF0001 /* WorkspaceRemoteDaemon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteDaemon.swift; sourceTree = ""; }; A5001417 /* WorkspaceContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentView.swift; sourceTree = ""; }; A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + NRAD0001 /* MainWindowHostingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowHostingView.swift; sourceTree = ""; }; + NRAD0002 /* TypingProfiler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingProfiler.swift; sourceTree = ""; }; + NRAD0003 /* TerminalDirectoryOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalDirectoryOpener.swift; sourceTree = ""; }; + NRAD0004 /* VSCodeIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VSCodeIntegration.swift; sourceTree = ""; }; + NRAD0005 /* WorkspaceShortcutMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceShortcutMapper.swift; sourceTree = ""; }; + NRAD0006 /* CLIInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIInstaller.swift; sourceTree = ""; }; + NRAD0007 /* MenuBarIconRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarIconRenderer.swift; sourceTree = ""; }; + NRAD0008 /* WindowSwizzles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowSwizzles.swift; sourceTree = ""; }; A5FF0021 /* AppDelegate+UITestCmdClick.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+UITestCmdClick.swift"; sourceTree = ""; }; A5FF0022 /* AppDelegate+UITestStressWorkspaces.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+UITestStressWorkspaces.swift"; sourceTree = ""; }; A5FF0023 /* AppDelegate+UITestHarnesses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+UITestHarnesses.swift"; sourceTree = ""; }; @@ -538,6 +554,14 @@ A5001620 /* AppleScriptSupport.swift */, D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, A5001090 /* AppDelegate.swift */, + NRAD0001 /* MainWindowHostingView.swift */, + NRAD0002 /* TypingProfiler.swift */, + NRAD0003 /* TerminalDirectoryOpener.swift */, + NRAD0004 /* VSCodeIntegration.swift */, + NRAD0005 /* WorkspaceShortcutMapper.swift */, + NRAD0006 /* CLIInstaller.swift */, + NRAD0007 /* MenuBarIconRenderer.swift */, + NRAD0008 /* WindowSwizzles.swift */, A5FF0021 /* AppDelegate+UITestCmdClick.swift */, A5FF0022 /* AppDelegate+UITestStressWorkspaces.swift */, A5FF0023 /* AppDelegate+UITestHarnesses.swift */, @@ -867,6 +891,14 @@ A5001226 /* SocketControlSettings.swift in Sources */, A5001621 /* AppleScriptSupport.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, + NRAD1001 /* MainWindowHostingView.swift in Sources */, + NRAD1002 /* TypingProfiler.swift in Sources */, + NRAD1003 /* TerminalDirectoryOpener.swift in Sources */, + NRAD1004 /* VSCodeIntegration.swift in Sources */, + NRAD1005 /* WorkspaceShortcutMapper.swift in Sources */, + NRAD1006 /* CLIInstaller.swift in Sources */, + NRAD1007 /* MenuBarIconRenderer.swift in Sources */, + NRAD1008 /* WindowSwizzles.swift in Sources */, A5FF0031 /* AppDelegate+UITestCmdClick.swift in Sources */, A5FF0032 /* AppDelegate+UITestStressWorkspaces.swift in Sources */, A5FF0033 /* AppDelegate+UITestHarnesses.swift in Sources */, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 72285c20bed..0f6bb677d90 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8,29 +8,7 @@ import Combine import ObjectiveC.runtime import Darwin -final class MainWindowHostingView: NSHostingView { - private let zeroSafeAreaLayoutGuide = NSLayoutGuide() - - override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } - override var safeAreaRect: NSRect { bounds } - override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide } - - required init(rootView: Content) { - super.init(rootView: rootView) - addLayoutGuide(zeroSafeAreaLayoutGuide) - NSLayoutConstraint.activate([ - zeroSafeAreaLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor), - zeroSafeAreaLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor), - zeroSafeAreaLayoutGuide.topAnchor.constraint(equalTo: topAnchor), - zeroSafeAreaLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} private enum ProgramaThemeNotifications { static let reloadConfig = Notification.Name("com.darkroom.programa.themes.reload-config") @@ -70,5553 +48,4001 @@ func isCommandPaletteFocusStealingTerminalOrBrowserView(_ view: NSView) -> Bool return false } -#if DEBUG -enum ProgramaTypingTiming { - static let isEnabled: Bool = { - let environment = ProcessInfo.processInfo.environment - if environment["PROGRAMA_TYPING_TIMING_LOGS"] == "1" || environment["PROGRAMA_KEY_LATENCY_PROBE"] == "1" { - return true - } - let defaults = UserDefaults.standard - return defaults.bool(forKey: "programaTypingTimingLogs") || defaults.bool(forKey: "programaKeyLatencyProbe") - }() - static let isVerboseProbeEnabled: Bool = { - let environment = ProcessInfo.processInfo.environment - if environment["PROGRAMA_KEY_LATENCY_PROBE"] == "1" { - return true - } - return UserDefaults.standard.bool(forKey: "programaKeyLatencyProbe") - }() - 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) { - ProgramaMainThreadTurnProfiler.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) + + +private extension NSScreen { + var programaDisplayID: UInt32? { + let key = NSDeviceDescriptionKey("NSScreenNumber") + guard let value = deviceDescription[key] as? NSNumber else { return nil } + return value.uint32Value } } -final class ProgramaMainRunLoopStallMonitor { - static let shared = ProgramaMainRunLoopStallMonitor() - - private let thresholdMs: Double = 8.0 - private var observer: CFRunLoopObserver? - private var installed = false - private var lastActivity: CFRunLoopActivity? - private var lastTimestamp: TimeInterval? +func browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: Bool, + flags: NSEvent.ModifierFlags, + chars: String +) -> Int? { + guard hasFocusedAddressBar else { return nil } + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] + guard isCommandOrControlOnly else { return nil } + if chars == "n" { return 1 } + if chars == "p" { return -1 } + return nil +} - private init() {} +func browserOmnibarSelectionDeltaForArrowNavigation( + hasFocusedAddressBar: Bool, + flags: NSEvent.ModifierFlags, + keyCode: UInt16 +) -> Int? { + guard hasFocusedAddressBar else { return nil } + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + guard normalizedFlags == [] else { return nil } + switch keyCode { + case 125: return 1 + case 126: return -1 + default: return nil + } +} - func installIfNeeded() { - guard ProgramaTypingTiming.isEnabled else { return } - guard !installed else { return } +func browserOmnibarNormalizedModifierFlags(_ flags: NSEvent.ModifierFlags) -> NSEvent.ModifierFlags { + flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) +} - var context = CFRunLoopObserverContext( - version: 0, - info: Unmanaged.passUnretained(self).toOpaque(), - retain: nil, - release: nil, - copyDescription: nil - ) +func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + return normalizedFlags == [] || normalizedFlags == [.shift] +} - 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 - ) +func browserResponderHasMarkedText(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } - guard let observer else { return } - CFRunLoopAddObserver(CFRunLoopGetMain(), observer, .commonModes) - installed = true + // During IME composition, Return/Enter belongs to the text system so the + // candidate list can commit or confirm the marked text. + if let textInputClient = responder as? NSTextInputClient { + if textInputClient.hasMarkedText() { return 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 - } + if let textField = responder as? NSTextField, + let editor = textField.currentEditor() as? NSTextView { + if editor.hasMarkedText() { return true } + } - 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)" - ) + // WKWebView clears marked text before performKeyEquivalent fires, so the + // synchronous hasMarkedText() check above can return false even though an IME + // composition just ended on the same Enter keystroke. Check the JS bridge's + // composition timestamp to detect this race condition (#2626). + if let webView = responder.programaEnclosingProgramaWebView { + if webView.webViewIsComposing { return true } + let age = ProcessInfo.processInfo.systemUptime - webView.recentCompositionEndTimestamp + if age >= 0 && age < 0.15 { return true } } - 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))" + return false +} + +private extension NSResponder { + /// Walk the responder chain to find the enclosing ProgramaWebView. + var programaEnclosingProgramaWebView: ProgramaWebView? { + var current: NSResponder? = self + while let responder = current { + if let webView = responder as? ProgramaWebView { return webView } + current = responder.nextResponder } + return nil } } -final class ProgramaMainThreadTurnProfiler { - static let shared = ProgramaMainThreadTurnProfiler() +func shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: UInt16, + firstResponderIsBrowser: Bool, + firstResponderHasMarkedText: Bool = false, + flags: NSEvent.ModifierFlags +) -> Bool { + guard firstResponderIsBrowser else { return false } + guard !firstResponderHasMarkedText else { return false } + guard keyCode == 36 || keyCode == 76 else { return false } + // Keep browser Return forwarding narrow: only plain/Shift Return should be + // treated as submit-intent. Command-modified Return is reserved for app shortcuts + // like Toggle Pane Zoom (Cmd+Shift+Enter). + return browserOmnibarShouldSubmitOnReturn(flags: flags) +} - private struct BucketStats { - var count: Int = 0 - var totalMs: Double = 0 - var maxMs: Double = 0 +func shouldToggleMainWindowFullScreenForCommandControlFShortcut( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16, + layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) +) -> Bool { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + guard normalizedFlags == [.command, .control] else { return false } + let normalizedChars = chars.lowercased() + if normalizedChars == "f" { + return true } - - 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, ProgramaTypingTiming.isEnabled, Thread.isMainThread else { return } - let elapsedMs = max(0, (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0) - shared.record(bucket: bucket, elapsedMs: elapsedMs, count: 1) + let charsAreControlSequence = !normalizedChars.isEmpty + && normalizedChars.unicodeScalars.allSatisfy { CharacterSet.controlCharacters.contains($0) } + if !normalizedChars.isEmpty && !charsAreControlSequence { + return false } - func installIfNeeded() { - guard ProgramaTypingTiming.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 + // Fallback to layout translation only when characters are unavailable (for + // synthetic/key-equivalent paths that can report an empty string). + if let translatedCharacter = layoutCharacterProvider(keyCode, flags), !translatedCharacter.isEmpty { + return translatedCharacter == "f" } - 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 - } - } + // Keep ANSI fallback as a final safety net when layout translation is unavailable. + return keyCode == 3 +} + +func commandPaletteSelectionDeltaForKeyboardNavigation( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16 +) -> Int? { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + let normalizedChars = chars.lowercased() - private func record(bucket: String, elapsedMs: Double, count: Int) { - if turnStart == nil { - turnStart = ProcessInfo.processInfo.systemUptime + if normalizedFlags == [] { + switch keyCode { + case 125: return 1 // Down arrow + case 126: return -1 // Up arrow + default: break } - 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)" - ) + if normalizedFlags == [.control] { + // Control modifiers can surface as either printable chars or ASCII control chars. + // Keep Emacs-style next/previous navigation, but leave other control bindings + // (for example Ctrl+K text editing in the palette search field) to AppKit. + if keyCode == 45 || normalizedChars == "n" || normalizedChars == "\u{0e}" { return 1 } // Ctrl+N + if keyCode == 35 || normalizedChars == "p" || normalizedChars == "\u{10}" { return -1 } // Ctrl+P } - 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))" - } - } + return nil } -#endif -enum FinderServicePathResolver { - private static func canonicalDirectoryPath(_ path: String) -> String { - guard path.count > 1 else { return path } - var canonical = path - while canonical.count > 1 && canonical.hasSuffix("/") { - canonical.removeLast() - } - return canonical - } +func shouldRouteCommandPaletteSelectionNavigation( + delta: Int?, + isInteractive: Bool, + usesInlineTextHandling: Bool +) -> Bool { + guard delta != nil, isInteractive else { return false } + return !usesInlineTextHandling +} - private static func normalizedComparisonURL(_ url: URL) -> URL { - url.standardizedFileURL.resolvingSymlinksInPath() - } +func shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: Bool, + normalizedFlags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16 +) -> Bool { + guard isCommandPaletteVisible else { return false } - private static func isSameOrDescendant(_ url: URL, of rootURL: URL) -> Bool { - let urlPathComponents = normalizedComparisonURL(url).pathComponents - let rootPathComponents = normalizedComparisonURL(rootURL).pathComponents - guard urlPathComponents.count >= rootPathComponents.count else { return false } - return Array(urlPathComponents.prefix(rootPathComponents.count)) == rootPathComponents + // Escape dismisses the palette, and must not leak through to the + // underlying terminal or browser content. + if normalizedFlags.isEmpty, keyCode == 53 { + return true } - private static func resolvedDirectoryURL(from url: URL) -> URL { - let standardized = url.standardizedFileURL - if standardized.hasDirectoryPath { - return standardized - } - if let resourceValues = try? standardized.resourceValues(forKeys: [.isDirectoryKey]), - resourceValues.isDirectory == true { - return standardized - } - return standardized.deletingLastPathComponent() - } + guard normalizedFlags.contains(.command) else { return false } - static func orderedUniqueDirectories( - from pathURLs: [URL], - excludingDescendantsOf excludedRootURLs: [URL] = [] - ) -> [String] { - var seen: Set = [] - var directories: [String] = [] + let normalizedChars = chars.lowercased() - for url in pathURLs { - let directoryURL = resolvedDirectoryURL(from: url) - guard !excludedRootURLs.contains(where: { isSameOrDescendant(directoryURL, of: $0) }) else { - continue - } - let path = canonicalDirectoryPath(directoryURL.path(percentEncoded: false)) - guard !path.isEmpty else { continue } - if seen.insert(path).inserted { - directories.append(path) - } + if normalizedFlags == [.command] { + if normalizedChars == "a" + || normalizedChars == "c" + || normalizedChars == "v" + || normalizedChars == "x" + || normalizedChars == "z" + || normalizedChars == "y" { + return false } - return directories + switch keyCode { + case 51, 117, 123, 124: + return false + default: + break + } } -} -enum TerminalDirectoryOpenTarget: String, CaseIterable { - case androidStudio - case antigravity - case cursor - case finder - case ghostty - case intellij - case iterm2 - case terminal - case tower - case vscode - case vscodeInline - case warp - case windsurf - case xcode - case zed - - struct DetectionEnvironment { - let homeDirectoryPath: String - let fileExistsAtPath: (String) -> Bool - let isExecutableFileAtPath: (String) -> Bool - let applicationPathForName: (String) -> String? - - static let live = DetectionEnvironment( - homeDirectoryPath: FileManager.default.homeDirectoryForCurrentUser.path, - fileExistsAtPath: { FileManager.default.fileExists(atPath: $0) }, - isExecutableFileAtPath: { FileManager.default.isExecutableFile(atPath: $0) }, - applicationPathForName: { NSWorkspace.shared.fullPath(forApplication: $0) } - ) + if normalizedFlags == [.command, .shift], normalizedChars == "z" { + return false } - static var commandPaletteShortcutTargets: [Self] { - Array(allCases) + return true +} + +func shouldSubmitCommandPaletteWithReturn( + keyCode: UInt16, + flags: NSEvent.ModifierFlags, + mode: String +) -> Bool { + guard keyCode == 36 || keyCode == 76 else { return false } + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + if normalizedFlags.isEmpty { + return true + } + if normalizedFlags == [.shift] { + return mode != "workspace_description_input" } + return false +} - static func availableTargets(in environment: DetectionEnvironment = .live) -> Set { - Set(commandPaletteShortcutTargets.filter { $0.isAvailable(in: environment) }) +func commandPaletteFieldEditorHasMarkedText(in window: NSWindow) -> Bool { + if let editor = window.firstResponder as? NSTextView { + return editor.hasMarkedText() + } + if let textField = window.firstResponder as? NSTextField, + let editor = textField.currentEditor() as? NSTextView { + return editor.hasMarkedText() } + return false +} - var commandPaletteCommandId: String { - "palette.terminalOpenDirectory.\(rawValue)" +func shouldHandleCommandPaletteShortcutEvent( + _ event: NSEvent, + paletteWindow: NSWindow? +) -> Bool { + guard let paletteWindow else { return false } + if let eventWindow = event.window { + return eventWindow === paletteWindow + } + let eventWindowNumber = event.windowNumber + if eventWindowNumber > 0 { + return eventWindowNumber == paletteWindow.windowNumber + } + if let keyWindow = NSApp.keyWindow { + return keyWindow === paletteWindow } + return false +} - var commandPaletteTitle: String { - switch self { - case .androidStudio: - return String(localized: "menu.openInAndroidStudio", defaultValue: "Open Current Directory in Android Studio") - case .antigravity: - return String(localized: "menu.openInAntigravity", defaultValue: "Open Current Directory in Antigravity") - case .cursor: - return String(localized: "menu.openInCursor", defaultValue: "Open Current Directory in Cursor") - case .finder: - return String(localized: "menu.openInFinder", defaultValue: "Open Current Directory in Finder") - case .ghostty: - return String(localized: "menu.openInGhostty", defaultValue: "Open Current Directory in Ghostty") - case .intellij: - return String(localized: "menu.openInIntelliJ", defaultValue: "Open Current Directory in IntelliJ IDEA") - case .iterm2: - return String(localized: "menu.openInITerm2", defaultValue: "Open Current Directory in iTerm2") - case .terminal: - return String(localized: "menu.openInTerminal", defaultValue: "Open Current Directory in Terminal") - case .tower: - return String(localized: "menu.openInTower", defaultValue: "Open Current Directory in Tower") - case .vscode: - return String(localized: "menu.openInVSCodeDesktop", defaultValue: "Open Current Directory in VS Code") - case .vscodeInline: - return String(localized: "menu.openInVSCode", defaultValue: "Open Current Directory in VS Code (Inline)") - case .warp: - return String(localized: "menu.openInWarp", defaultValue: "Open Current Directory in Warp") - case .windsurf: - return String(localized: "menu.openInWindsurf", defaultValue: "Open Current Directory in Windsurf") - case .xcode: - return String(localized: "menu.openInXcode", defaultValue: "Open Current Directory in Xcode") - case .zed: - return String(localized: "menu.openInZed", defaultValue: "Open Current Directory in Zed") - } - } - - var commandPaletteKeywords: [String] { - let common = ["terminal", "directory", "open", "ide"] - switch self { - case .androidStudio: - return common + ["android", "studio"] - case .antigravity: - return common + ["antigravity"] - case .cursor: - return common + ["cursor"] - case .finder: - return common + ["finder", "file", "manager", "reveal"] - case .ghostty: - return common + ["ghostty", "terminal", "shell"] - case .intellij: - return common + ["intellij", "idea", "jetbrains"] - case .iterm2: - return common + ["iterm", "iterm2", "terminal", "shell"] - case .terminal: - return common + ["terminal", "shell"] - case .tower: - return common + ["tower", "git", "client"] - case .vscode: - return common + ["vs", "code", "visual", "studio", "desktop", "app"] - case .vscodeInline: - return common + ["vs", "code", "visual", "studio", "inline", "browser", "serve-web"] - case .warp: - return common + ["warp", "terminal", "shell"] - case .windsurf: - return common + ["windsurf"] - case .xcode: - return common + ["xcode", "apple"] - case .zed: - return common + ["zed"] - } - } - - func isAvailable(in environment: DetectionEnvironment = .live) -> Bool { - guard let applicationPath = applicationPath(in: environment) else { return false } - guard self == .vscodeInline else { return true } - return VSCodeCLILaunchConfigurationBuilder.launchConfiguration( - vscodeApplicationURL: URL(fileURLWithPath: applicationPath, isDirectory: true), - isExecutableAtPath: environment.isExecutableFileAtPath - ) != nil - } - - func applicationURL(in environment: DetectionEnvironment = .live) -> URL? { - guard let path = applicationPath(in: environment) else { return nil } - return URL(fileURLWithPath: path, isDirectory: true) - } - - private func applicationPath(in environment: DetectionEnvironment) -> String? { - for path in expandedCandidatePaths(in: environment) where environment.fileExistsAtPath(path) { - return path - } - - // Fall back to LaunchServices so apps outside the standard bundle paths - // still appear in the command palette. - for applicationName in applicationSearchNames { - guard let resolvedPath = environment.applicationPathForName(applicationName), - environment.fileExistsAtPath(resolvedPath) else { - continue - } - return resolvedPath - } +enum BrowserZoomShortcutAction: Equatable { + case zoomIn + case zoomOut + case reset +} - return nil - } +struct CommandPaletteDebugResultRow { + let commandId: String + let title: String + let shortcutHint: String? + let trailingLabel: String? + let score: Int +} + +struct CommandPaletteDebugSnapshot { + let query: String + let mode: String + let results: [CommandPaletteDebugResultRow] - private func expandedCandidatePaths(in environment: DetectionEnvironment) -> [String] { - let globalPrefix = "/Applications/" - let userPrefix = "\(environment.homeDirectoryPath)/Applications/" - var expanded: [String] = [] + static let empty = CommandPaletteDebugSnapshot(query: "", mode: "commands", results: []) +} - for candidate in applicationBundlePathCandidates { - expanded.append(candidate) - if candidate.hasPrefix(globalPrefix) { - let suffix = String(candidate.dropFirst(globalPrefix.count)) - expanded.append(userPrefix + suffix) - } - } +func browserZoomShortcutAction( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16, + literalChars: String? = nil +) -> BrowserZoomShortcutAction? { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + let hasCommand = normalizedFlags.contains(.command) + let hasOnlyCommandAndOptionalShift = hasCommand && normalizedFlags.isDisjoint(with: [.control, .option]) + + guard hasOnlyCommandAndOptionalShift else { return nil } + let keys = browserZoomShortcutKeyCandidates( + chars: chars, + literalChars: literalChars, + keyCode: keyCode + ) - return uniquePreservingOrder(expanded) + if keys.contains("=") || keys.contains("+") || keyCode == 24 || keyCode == 69 { // kVK_ANSI_Equal / kVK_ANSI_KeypadPlus + return .zoomIn } - private var applicationSearchNames: [String] { - uniquePreservingOrder( - applicationBundlePathCandidates.map { - URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent - } - ) + if keys.contains("-") || keys.contains("_") || keyCode == 27 || keyCode == 78 { // kVK_ANSI_Minus / kVK_ANSI_KeypadMinus + return .zoomOut } - private var applicationBundlePathCandidates: [String] { - switch self { - case .androidStudio: - return ["/Applications/Android Studio.app"] - case .antigravity: - return ["/Applications/Antigravity.app"] - case .cursor: - return [ - "/Applications/Cursor.app", - "/Applications/Cursor Preview.app", - "/Applications/Cursor Nightly.app", - ] - case .finder: - return ["/System/Library/CoreServices/Finder.app"] - case .ghostty: - return ["/Applications/Ghostty.app"] - case .intellij: - return ["/Applications/IntelliJ IDEA.app"] - case .iterm2: - return [ - "/Applications/iTerm.app", - "/Applications/iTerm2.app", - ] - case .terminal: - return ["/System/Applications/Utilities/Terminal.app"] - case .tower: - return ["/Applications/Tower.app"] - case .vscode: - return [ - "/Applications/Visual Studio Code.app", - "/Applications/Code.app", - ] - case .vscodeInline: - return [ - "/Applications/Visual Studio Code.app", - "/Applications/Code.app", - ] - case .warp: - return ["/Applications/Warp.app"] - case .windsurf: - return ["/Applications/Windsurf.app"] - case .xcode: - return ["/Applications/Xcode.app"] - case .zed: - return [ - "/Applications/Zed.app", - "/Applications/Zed Preview.app", - "/Applications/Zed Nightly.app", - ] - } - } - - private func uniquePreservingOrder(_ paths: [String]) -> [String] { - var seen: Set = [] - var deduped: [String] = [] - for path in paths where seen.insert(path).inserted { - deduped.append(path) - } - return deduped + if keys.contains("0") || keyCode == 29 || keyCode == 82 { // kVK_ANSI_0 / kVK_ANSI_Keypad0 + return .reset } + + return nil } -enum VSCodeServeWebURLBuilder { - static func extractWebUIURL(from output: String) -> URL? { - let prefix = "Web UI available at " - for line in output.split(whereSeparator: \.isNewline).reversed() { - guard let range = line.range(of: prefix) else { continue } - let rawURL = line[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines) - guard !rawURL.isEmpty, let url = URL(string: rawURL) else { continue } - return url - } - return nil +func browserZoomShortcutKeyCandidates( + chars: String, + literalChars: String?, + keyCode: UInt16 +) -> Set { + var keys: Set = [chars.lowercased()] + + if let literalChars, !literalChars.isEmpty { + keys.insert(literalChars.lowercased()) } - static func openFolderURL(baseWebUIURL: URL, directoryPath: String) -> URL? { - var components = URLComponents(url: baseWebUIURL, resolvingAgainstBaseURL: false) - var queryItems = components?.queryItems ?? [] - queryItems.removeAll { $0.name == "folder" } - queryItems.append(URLQueryItem(name: "folder", value: directoryPath)) - components?.queryItems = queryItems - return components?.url + if let layoutChar = KeyboardLayout.character(forKeyCode: keyCode), !layoutChar.isEmpty { + keys.insert(layoutChar) } -} -struct VSCodeCLILaunchConfiguration { - let executableURL: URL - let argumentsPrefix: [String] - let environment: [String: String] + return keys } -enum VSCodeCLILaunchConfigurationBuilder { - static func launchConfiguration( - vscodeApplicationURL: URL, - baseEnvironment: [String: String] = ProcessInfo.processInfo.environment, - isExecutableAtPath: (String) -> Bool = { FileManager.default.isExecutableFile(atPath: $0) } - ) -> VSCodeCLILaunchConfiguration? { - let contentsURL = vscodeApplicationURL.appendingPathComponent("Contents", isDirectory: true) - let codeTunnelURL = contentsURL.appendingPathComponent("Resources/app/bin/code-tunnel", isDirectory: false) - guard isExecutableAtPath(codeTunnelURL.path) else { return nil } - - var environment = baseEnvironment - environment["ELECTRON_RUN_AS_NODE"] = "1" - environment.removeValue(forKey: "VSCODE_NODE_OPTIONS") - environment.removeValue(forKey: "VSCODE_NODE_REPL_EXTERNAL_MODULE") - if let nodeOptions = environment["NODE_OPTIONS"] { - environment["VSCODE_NODE_OPTIONS"] = nodeOptions - } - if let nodeReplExternalModule = environment["NODE_REPL_EXTERNAL_MODULE"] { - environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"] = nodeReplExternalModule - } - environment.removeValue(forKey: "NODE_OPTIONS") - environment.removeValue(forKey: "NODE_REPL_EXTERNAL_MODULE") - environment["VSCODE_CLI_USE_FILE_KEYRING"] = "1" - - return VSCodeCLILaunchConfiguration( - executableURL: codeTunnelURL, - argumentsPrefix: [], - environment: environment - ) - } +func shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: Bool, + hostedSize: CGSize, + hostedHiddenInHierarchy: Bool, + hostedAttachedToWindow: Bool +) -> Bool { + guard firstResponderIsWindow else { return false } + let tinyGeometry = hostedSize.width <= 1 || hostedSize.height <= 1 + return tinyGeometry || hostedHiddenInHierarchy || !hostedAttachedToWindow } -final class VSCodeServeWebController { - static let shared = VSCodeServeWebController() - private static let serveWebStartupTimeoutSeconds: TimeInterval = 60 - - private let queue = DispatchQueue(label: "programa.vscode.serveWeb") - private let launchQueue = DispatchQueue(label: "programa.vscode.serveWeb.launch") - private let launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? - private var serveWebProcess: Process? - private var launchingProcess: Process? - private var connectionTokenFilesByProcessID: [ObjectIdentifier: URL] = [:] - private var serveWebURL: URL? - private var pendingCompletions: [(generation: UInt64, completion: (URL?) -> Void)] = [] - private var isLaunching = false - private var activeLaunchGeneration: UInt64? - private var lifecycleGeneration: UInt64 = 0 -#if DEBUG - private var testingTrackedProcesses: [Process] = [] -#endif +func shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: Bool, + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16, + literalChars: String? = nil +) -> Bool { + guard firstResponderIsGhostty else { return false } + return browserZoomShortcutAction( + flags: flags, + chars: chars, + keyCode: keyCode, + literalChars: literalChars + ) != nil +} - private init(launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? = nil) { - self.launchProcessOverride = launchProcessOverride +@discardableResult +func startOrFocusTerminalSearch( + _ terminalSurface: TerminalSurface, + searchFocusNotifier: @escaping (TerminalSurface) -> Void = { + NotificationCenter.default.post(name: .ghosttySearchFocus, object: $0) } - -#if DEBUG - static func makeForTesting( - launchProcessOverride: @escaping (URL, UInt64) -> (process: Process, url: URL)? - ) -> VSCodeServeWebController { - VSCodeServeWebController(launchProcessOverride: launchProcessOverride) +) -> Bool { + if terminalSurface.searchState != nil { + searchFocusNotifier(terminalSurface) + return true } - func trackConnectionTokenFileForTesting( - _ connectionTokenFileURL: URL, - setAsLaunchingProcess: Bool = false, - setAsServeWebProcess: Bool = false - ) { - let process = Process() - queue.sync { - if setAsLaunchingProcess { - self.launchingProcess = process - } - if setAsServeWebProcess { - self.serveWebProcess = process - } - if !setAsLaunchingProcess && !setAsServeWebProcess { - self.testingTrackedProcesses.append(process) - } - self.connectionTokenFilesByProcessID[ObjectIdentifier(process)] = connectionTokenFileURL + if terminalSurface.performBindingAction("start_search") { + DispatchQueue.main.async { [weak terminalSurface] in + guard let terminalSurface, terminalSurface.searchState == nil else { return } + terminalSurface.searchState = TerminalSurface.SearchState() + searchFocusNotifier(terminalSurface) } + return true } -#endif - func ensureServeWebURL(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) { - queue.async { - if let process = self.serveWebProcess, - process.isRunning, - let url = self.serveWebURL { - DispatchQueue.main.async { - completion(url) - } - return - } + terminalSurface.searchState = TerminalSurface.SearchState() + searchFocusNotifier(terminalSurface) + return true +} - let completionGeneration = self.lifecycleGeneration - self.pendingCompletions.append((generation: completionGeneration, completion: completion)) - guard !self.isLaunching else { return } +/// Let AppKit own native Cmd+` window cycling so key-window changes do not +/// re-enter our direct-to-menu shortcut path. +func shouldRouteCommandEquivalentDirectlyToMainMenu(_ event: NSEvent) -> Bool { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard flags.contains(.command) else { return false } - self.isLaunching = true - let launchGeneration = completionGeneration - self.activeLaunchGeneration = launchGeneration + let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) + if event.keyCode == 50, + normalizedFlags == [.command] || normalizedFlags == [.command, .shift] { + return false + } - self.launchQueue.async { - let shouldLaunch = self.queue.sync { - self.lifecycleGeneration == launchGeneration - } - guard shouldLaunch else { - self.queue.async { - guard self.activeLaunchGeneration == launchGeneration else { return } - self.isLaunching = false - self.activeLaunchGeneration = nil - } - return - } - let launchResult = self.launchServeWebProcess( - vscodeApplicationURL: vscodeApplicationURL, - expectedGeneration: launchGeneration - ) - self.queue.async { - guard self.activeLaunchGeneration == launchGeneration else { - if let process = launchResult?.process, process.isRunning { - process.terminate() - } - return - } - self.isLaunching = false - self.activeLaunchGeneration = nil - - guard self.lifecycleGeneration == launchGeneration else { - if let launchedProcess = launchResult?.process, - self.launchingProcess === launchedProcess { - self.launchingProcess = nil - } - if let process = launchResult?.process, process.isRunning { - process.terminate() - } - return - } + return true +} - if let launchResult { - self.launchingProcess = nil - self.serveWebProcess = launchResult.process - self.serveWebURL = launchResult.url - } else { - self.launchingProcess = nil - self.serveWebProcess = nil - self.serveWebURL = nil - } +private enum BrowserFindCommandEquivalent { + case find + case findNext + case findPrevious + case hideFind + case useSelection - var completions: [(URL?) -> Void] = [] - var remaining: [(generation: UInt64, completion: (URL?) -> Void)] = [] - for pending in self.pendingCompletions { - if pending.generation == launchGeneration { - completions.append(pending.completion) - } else { - remaining.append(pending) - } - } - self.pendingCompletions = remaining - let resolvedURL = self.serveWebURL - DispatchQueue.main.async { - completions.forEach { $0(resolvedURL) } - } - } - } + var keepsProgramaBrowserFindBarOwnershipWhenVisible: Bool { + switch self { + case .find, .findNext, .findPrevious, .hideFind: + return true + case .useSelection: + return false } } +} - func stop() { - let (processes, tokenFileURLs, completions): ([Process], [URL], [(URL?) -> Void]) = queue.sync { - self.lifecycleGeneration &+= 1 - self.isLaunching = false - self.activeLaunchGeneration = nil - var processes: [Process] = [] - if let process = self.serveWebProcess { - processes.append(process) - } - if let process = self.launchingProcess, - !processes.contains(where: { $0 === process }) { - processes.append(process) - } - self.serveWebProcess = nil - self.launchingProcess = nil -#if DEBUG - self.testingTrackedProcesses.removeAll() -#endif - var tokenFileURLs = processes.compactMap { - self.connectionTokenFilesByProcessID.removeValue(forKey: ObjectIdentifier($0)) - } - tokenFileURLs.append(contentsOf: self.connectionTokenFilesByProcessID.values) - self.connectionTokenFilesByProcessID.removeAll() - self.serveWebURL = nil - let completions = self.pendingCompletions.map(\.completion) - self.pendingCompletions.removeAll() - return (processes, tokenFileURLs, completions) +private func programaIsLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + let responderType = String(describing: type(of: responder)) + if responderType.contains("WKInspector") { + return true + } + guard let view = responder as? NSView else { return false } + var node: NSView? = view + var hops = 0 + while let current = node, hops < 64 { + if String(describing: type(of: current)).contains("WKInspector") { + return true } + node = current.superview + hops += 1 + } + return false +} - for tokenFileURL in tokenFileURLs where tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: tokenFileURL) - } +private func browserFindCommandEquivalent(for event: NSEvent) -> BrowserFindCommandEquivalent? { + let flags = event.modifierFlags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) - for process in processes where process.isRunning { - process.terminate() + let normalizedChars = KeyboardLayout.normalizedCharacters(for: event).lowercased() + let hasSingleASCIIShortcutChar = + normalizedChars.count == 1 && normalizedChars.allSatisfy(\.isASCII) + let producedAnyASCIIShortcutChar = normalizedChars.contains(where: \.isASCII) + func matches(_ chars: String, keyCode: UInt16) -> Bool { + if hasSingleASCIIShortcutChar { + return normalizedChars == chars } - - if !completions.isEmpty { - DispatchQueue.main.async { - completions.forEach { $0(nil) } - } + if !producedAnyASCIIShortcutChar { + return event.keyCode == keyCode } + return false } - func restart(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) { - stop() - ensureServeWebURL(vscodeApplicationURL: vscodeApplicationURL, completion: completion) - } - - private func launchServeWebProcess( - vscodeApplicationURL: URL, - expectedGeneration: UInt64 - ) -> (process: Process, url: URL)? { - if let launchProcessOverride { - return launchProcessOverride(vscodeApplicationURL, expectedGeneration) - } - - guard let launchConfiguration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( - vscodeApplicationURL: vscodeApplicationURL - ) else { return nil } - - guard let connectionTokenFileURL = Self.makeConnectionTokenFile() else { - return nil + switch flags { + case [.command]: + if matches("e", keyCode: 14) { // kVK_ANSI_E + return .useSelection } - - let process = Process() - process.executableURL = launchConfiguration.executableURL - // #21: reuse the port VS Code assigned on a previous run so the embedded browser - // keeps the same URL across restarts. ServeWebPortStore returns the persisted port - // only when it is still bindable, otherwise "0" (OS-assigned) — so a now-occupied - // port falls back gracefully instead of failing the launch. The --server-data-dir - // and persistent connection-token already fix Settings Sync / OAuth auth. - process.arguments = launchConfiguration.argumentsPrefix + [ - "serve-web", - "--accept-server-license-terms", - "--host", "127.0.0.1", - "--port", ServeWebPortStore.portArgument(persistedIn: Self.vscodeServerDataDir), - "--server-data-dir", Self.vscodeServerDataDir?.path ?? NSTemporaryDirectory(), - "--connection-token-file", connectionTokenFileURL.path, - ] - process.environment = launchConfiguration.environment - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - let collector = ServeWebOutputCollector() - let outputReader: (FileHandle) -> Void = { fileHandle in - let data = fileHandle.availableData - guard !data.isEmpty else { return } - collector.append(data) - } - stdoutPipe.fileHandleForReading.readabilityHandler = outputReader - stderrPipe.fileHandleForReading.readabilityHandler = outputReader - - process.terminationHandler = { [weak self] terminatedProcess in - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - Self.drainAvailableOutput(from: stdoutPipe.fileHandleForReading, collector: collector) - Self.drainAvailableOutput(from: stderrPipe.fileHandleForReading, collector: collector) - collector.markProcessExited() - self?.queue.async { - guard let self else { return } - if self.launchingProcess === terminatedProcess { - self.launchingProcess = nil - } - if self.serveWebProcess === terminatedProcess { - self.serveWebProcess = nil - self.serveWebURL = nil - } - if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( - forKey: ObjectIdentifier(terminatedProcess) - ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: tokenFileURL) - } - } + if matches("f", keyCode: 3) { // kVK_ANSI_F + return .find } - - let didStart: Bool = queue.sync { - guard self.lifecycleGeneration == expectedGeneration, - self.activeLaunchGeneration == expectedGeneration else { - return false - } - self.launchingProcess = process - self.connectionTokenFilesByProcessID[ObjectIdentifier(process)] = connectionTokenFileURL - do { - try process.run() - return true - } catch { - if self.launchingProcess === process { - self.launchingProcess = nil - } - if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( - forKey: ObjectIdentifier(process) - ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: tokenFileURL) - } - return false - } + if matches("g", keyCode: 5) { // kVK_ANSI_G + return .findNext } - guard didStart else { - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - if connectionTokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: connectionTokenFileURL) - } - return nil + return nil + case [.command, .shift]: + if matches("f", keyCode: 3) { // kVK_ANSI_F + return .hideFind } - - guard collector.waitForURL(timeoutSeconds: Self.serveWebStartupTimeoutSeconds), - let serveWebURL = collector.webUIURL else { - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - if process.isRunning { - process.terminate() - } else { - queue.sync { - if self.launchingProcess === process { - self.launchingProcess = nil - } - if self.serveWebProcess === process { - self.serveWebProcess = nil - self.serveWebURL = nil - } - if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( - forKey: ObjectIdentifier(process) - ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { - Self.removeConnectionTokenFile(at: tokenFileURL) - } - } - } - return nil + if matches("g", keyCode: 5) { // kVK_ANSI_G + return .findPrevious } + return nil + default: + return nil + } +} - // #21: remember the assigned port so the next launch can request it again. - if let assignedPort = serveWebURL.port { - ServeWebPortStore.persist(port: assignedPort, in: Self.vscodeServerDataDir) - } +/// For browser content, let the page try the Find command family before cmux's menu fallback. +/// This preserves native web-app shortcuts like VS Code's Cmd+F while still allowing cmux's +/// browser find overlay to keep owning its visible Find UI shortcuts. +func shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( + _ event: NSEvent, + responder: NSResponder? = nil, + owningWebView: ProgramaWebView? = nil +) -> Bool { + guard let shortcut = browserFindCommandEquivalent(for: event) else { + return false + } - return (process, serveWebURL) + if programaIsLikelyWebInspectorResponder(responder) { + return false } - private static func drainAvailableOutput(from fileHandle: FileHandle, collector: ServeWebOutputCollector) { - while true { - let data = fileHandle.availableData - guard !data.isEmpty else { return } - collector.append(data) + if shortcut.keepsProgramaBrowserFindBarOwnershipWhenVisible, + let owningWebView { + let browserFindBarIsVisible = MainActor.assumeIsolated { + AppDelegate.shared?.browserFindBarIsVisible(for: owningWebView) == true + } + if browserFindBarIsVisible { + return false } } - /// Stable Application Support directory for VS Code serve-web state. - /// Mirrors the "programa" subdirectory convention used elsewhere in the app. - private static var vscodeServerDataDir: URL? { - FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) - .first? - .appendingPathComponent("programa", isDirectory: true) - .appendingPathComponent("vscode-server", isDirectory: true) - } + return true +} - private static func randomConnectionToken() -> String { - UUID().uuidString.replacingOccurrences(of: "-", with: "") +func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? { + guard let responder else { return nil } + if let ghosttyView = responder as? GhosttyNSView { + return ghosttyView } - /// Returns a URL for the connection token file. Prefers a persistent file - /// under Application Support so the token (and the browser's vscode-tkn - /// cookie) survives Programa restarts (issue #21). Falls back to an - /// ephemeral temp-dir file when Application Support is unavailable. - private static func makeConnectionTokenFile() -> URL? { - if let persistentURL = vscodeServerDataDir? - .appendingPathComponent("connection-token", isDirectory: false) { - if let url = makePersistentConnectionTokenFile(at: persistentURL) { - return url - } - } - return makeEphemeralConnectionTokenFile() + if let view = responder as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: view) { + return ghosttyView } - private static func makePersistentConnectionTokenFile(at tokenFileURL: URL) -> URL? { - let dir = tokenFileURL.deletingLastPathComponent() - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - // Reuse an existing non-empty token so the browser cookie stays valid. - if let existingData = try? Data(contentsOf: tokenFileURL), !existingData.isEmpty { - return tokenFileURL - } - let token = randomConnectionToken() - guard let tokenData = token.data(using: .utf8) else { return nil } - let fd = open(tokenFileURL.path, O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR) - guard fd >= 0 else { return nil } - defer { _ = close(fd) } - let wroteAllBytes = tokenData.withUnsafeBytes { rawBuffer in - guard let baseAddress = rawBuffer.baseAddress else { return false } - return write(fd, baseAddress, rawBuffer.count) == rawBuffer.count + if let textView = responder as? NSTextView { + if textView.isFieldEditor, + let ownerView = programaFieldEditorOwnerView(textView), + let ghosttyView = cmuxOwningGhosttyView(for: ownerView) { + return ghosttyView } - guard wroteAllBytes else { - try? FileManager.default.removeItem(at: tokenFileURL) - return nil + + if !textView.isFieldEditor, + let delegateView = textView.delegate as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: delegateView) { + return ghosttyView } - return tokenFileURL } - private static func makeEphemeralConnectionTokenFile() -> URL? { - let token = randomConnectionToken() - let tokenFileName = "cmux-vscode-token-\(UUID().uuidString)" - let tokenFileURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent(tokenFileName, isDirectory: false) - guard let tokenData = token.data(using: .utf8) else { return nil } - let fileDescriptor = open(tokenFileURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR) - guard fileDescriptor >= 0 else { return nil } - defer { _ = close(fileDescriptor) } - let wroteAllBytes = tokenData.withUnsafeBytes { rawBuffer in - guard let baseAddress = rawBuffer.baseAddress else { return false } - return write(fileDescriptor, baseAddress, rawBuffer.count) == rawBuffer.count + var current = responder.nextResponder + while let next = current { + if let ghosttyView = next as? GhosttyNSView { + return ghosttyView } - guard wroteAllBytes else { - removeConnectionTokenFile(at: tokenFileURL) - return nil + if let view = next as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: view) { + return ghosttyView } - return tokenFileURL + current = next.nextResponder } - private static func removeConnectionTokenFile(at url: URL) { - try? FileManager.default.removeItem(at: url) - } + return nil } -final class ServeWebOutputCollector { - private let lock = NSLock() - private let semaphore = DispatchSemaphore(value: 0) - private var outputBuffer = "" - private var resolvedURL: URL? - private var didSignal = false - - var webUIURL: URL? { - lock.lock() - defer { lock.unlock() } - return resolvedURL - } - - func append(_ data: Data) { - guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return } - lock.lock() - defer { lock.unlock() } - guard resolvedURL == nil else { return } - outputBuffer.append(text) - while let newlineIndex = outputBuffer.firstIndex(where: \.isNewline) { - let line = String(outputBuffer[.. NSView? { + guard editor.isFieldEditor else { return nil } - func markProcessExited() { - lock.lock() - defer { lock.unlock() } - if resolvedURL == nil, !outputBuffer.isEmpty, - let parsedURL = VSCodeServeWebURLBuilder.extractWebUIURL(from: outputBuffer) { - resolvedURL = parsedURL - outputBuffer.removeAll(keepingCapacity: false) + var current = editor.nextResponder + while let next = current { + if let view = next as? NSView { + return view } - guard !didSignal else { return } - didSignal = true - semaphore.signal() + current = next.nextResponder } - func waitForURL(timeoutSeconds: TimeInterval) -> Bool { - if webUIURL != nil { return true } - _ = semaphore.wait(timeout: .now() + timeoutSeconds) - return webUIURL != nil - } + return editor.superview } -/// Persists the VS Code serve-web port across restarts (#21) so the embedded browser -/// keeps the same URL. The port `code serve-web` assigns is written under the serve-web -/// data dir and reused on the next launch — but only when it is still bindable, so a -/// now-occupied port falls back to an OS-assigned one instead of failing the launch. -enum ServeWebPortStore { - static let fileName = "serve-web-port" - - /// The `--port` argument for `code serve-web`: the persisted port when it is valid and - /// currently free, otherwise "0" (let the OS assign one). `isPortAvailable` is injectable - /// for testing; it defaults to a real loopback bind probe. - static func portArgument( - persistedIn directory: URL?, - isPortAvailable: (Int) -> Bool = ServeWebPortStore.isPortAvailable - ) -> String { - guard let url = portFileURL(in: directory), - let data = try? Data(contentsOf: url), - let raw = String(data: data, encoding: .utf8), - let port = parsePort(raw), - isPortAvailable(port) else { - return "0" - } - return String(port) - } - - /// Records the port VS Code assigned so the next launch can request it again. No-op for - /// out-of-range ports or when the data dir is unavailable. - static func persist(port: Int, in directory: URL?) { - guard isValidPort(port), let url = portFileURL(in: directory) else { return } - let dir = url.deletingLastPathComponent() - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - try? Data(String(port).utf8).write(to: url, options: .atomic) - } - - /// Parses a stored port string, returning nil for malformed or out-of-range values. - static func parsePort(_ raw: String) -> Int? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard let port = Int(trimmed), isValidPort(port) else { return nil } - return port +private func cmuxOwningGhosttyView(for view: NSView) -> GhosttyNSView? { + if let ghosttyView = view as? GhosttyNSView { + return ghosttyView } - /// True when a TCP socket can bind 127.0.0.1:port right now. - static func isPortAvailable(_ port: Int) -> Bool { - guard isValidPort(port) else { return false } - let fd = socket(AF_INET, SOCK_STREAM, 0) - guard fd >= 0 else { return false } - defer { close(fd) } - var reuse: Int32 = 1 - _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size)) - var addr = sockaddr_in() - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = in_port_t(UInt16(port)).bigEndian - addr.sin_addr.s_addr = inet_addr("127.0.0.1") - let bound = withUnsafePointer(to: &addr) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in - bind(fd, sockaddrPointer, socklen_t(MemoryLayout.size)) - } + var current: NSView? = view.superview + while let candidate = current { + if let ghosttyView = candidate as? GhosttyNSView { + return ghosttyView } - return bound == 0 + current = candidate.superview } - private static func isValidPort(_ port: Int) -> Bool { (1...65535).contains(port) } - - private static func portFileURL(in directory: URL?) -> URL? { - directory?.appendingPathComponent(fileName, isDirectory: false) - } + return nil } -enum WorkspaceShortcutMapper { - /// Maps numbered workspace shortcuts to a zero-based workspace index. - /// 1...8 target fixed indices; 9 always targets the last workspace. - static func workspaceIndex(forDigit digit: Int, workspaceCount: Int) -> Int? { - guard workspaceCount > 0 else { return nil } - guard (1...9).contains(digit) else { return nil } - - if digit == 9 { - return workspaceCount - 1 - } +#if DEBUG +func browserZoomShortcutTraceCandidate( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16, + literalChars: String? = nil +) -> Bool { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + guard normalizedFlags.contains(.command) else { return false } - let index = digit - 1 - return index < workspaceCount ? index : nil + let keys = browserZoomShortcutKeyCandidates( + chars: chars, + literalChars: literalChars, + keyCode: keyCode + ) + if keys.contains("=") || keys.contains("+") || keys.contains("-") || keys.contains("_") || keys.contains("0") { + return true } - - /// Returns the primary digit badge to display for a workspace row. - /// Picks the lowest digit that maps to that row index. - static func digitForWorkspace(at index: Int, workspaceCount: Int) -> Int? { - guard index >= 0 && index < workspaceCount else { return nil } - for digit in 1...9 { - if workspaceIndex(forDigit: digit, workspaceCount: workspaceCount) == index { - return digit - } - } - return nil + switch keyCode { + case 24, 27, 29, 69, 78, 82: // ANSI and keypad zoom keys + return true + default: + return false } } -struct ProgramaCLIPathInstaller { - struct InstallOutcome { - let usedAdministratorPrivileges: Bool - let destinationURL: URL - let sourceURL: URL - } - - struct UninstallOutcome { - let usedAdministratorPrivileges: Bool - let destinationURL: URL - let removedExistingEntry: Bool - } - - enum InstallerError: LocalizedError { - case bundledCLIMissing(expectedPath: String) - case destinationParentNotDirectory(path: String) - case destinationIsDirectory(path: String) - case installVerificationFailed(path: String) - case uninstallVerificationFailed(path: String) - case privilegedCommandFailed(message: String) - - var errorDescription: String? { - switch self { - case .bundledCLIMissing(let expectedPath): - return "Bundled Programa CLI was not found at \(expectedPath)." - case .destinationParentNotDirectory(let path): - return "Expected \(path) to be a directory." - case .destinationIsDirectory(let path): - return "\(path) is a directory. Remove or rename it and try again." - case .installVerificationFailed(let path): - return "Installed symlink at \(path) did not point to the bundled programa CLI." - case .uninstallVerificationFailed(let path): - return "Failed to remove \(path)." - case .privilegedCommandFailed(let message): - return "Administrator action failed: \(message)" - } - } - } - - typealias PrivilegedInstallHandler = (_ sourceURL: URL, _ destinationURL: URL) throws -> Void - typealias PrivilegedUninstallHandler = (_ destinationURL: URL) throws -> Void - - let fileManager: FileManager - let destinationURL: URL - private let bundledCLIURLProvider: () -> URL? - private let expectedBundledCLIPath: String - private let privilegedInstaller: PrivilegedInstallHandler - private let privilegedUninstaller: PrivilegedUninstallHandler - - init( - fileManager: FileManager = .default, - destinationURL: URL = URL(fileURLWithPath: "/usr/local/bin/programa"), - bundledCLIURLProvider: @escaping () -> URL? = { - ProgramaCLIPathInstaller.defaultBundledCLIURL() - }, - expectedBundledCLIPath: String = ProgramaCLIPathInstaller.defaultBundledCLIExpectedPath(), - privilegedInstaller: PrivilegedInstallHandler? = nil, - privilegedUninstaller: PrivilegedUninstallHandler? = nil - ) { - self.fileManager = fileManager - self.destinationURL = destinationURL - self.bundledCLIURLProvider = bundledCLIURLProvider - self.expectedBundledCLIPath = expectedBundledCLIPath - self.privilegedInstaller = privilegedInstaller ?? Self.installWithAdministratorPrivileges(sourceURL:destinationURL:) - self.privilegedUninstaller = privilegedUninstaller ?? Self.uninstallWithAdministratorPrivileges(destinationURL:) - } - - var destinationPath: String { - destinationURL.path - } +func browserZoomShortcutTraceFlagsString(_ flags: NSEvent.ModifierFlags) -> String { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + var parts: [String] = [] + if normalizedFlags.contains(.command) { parts.append("Cmd") } + if normalizedFlags.contains(.shift) { parts.append("Shift") } + if normalizedFlags.contains(.option) { parts.append("Opt") } + if normalizedFlags.contains(.control) { parts.append("Ctrl") } + return parts.isEmpty ? "none" : parts.joined(separator: "+") +} - func install() throws -> InstallOutcome { - let sourceURL = try resolveBundledCLIURL() - do { - try installWithoutAdministratorPrivileges(sourceURL: sourceURL) - return InstallOutcome( - usedAdministratorPrivileges: false, - destinationURL: destinationURL, - sourceURL: sourceURL - ) - } catch { - guard Self.isPermissionDenied(error) else { throw error } - try ensureDestinationIsNotDirectory() - try privilegedInstaller(sourceURL, destinationURL) - try verifyInstalledSymlinkTarget(sourceURL: sourceURL) - return InstallOutcome( - usedAdministratorPrivileges: true, - destinationURL: destinationURL, - sourceURL: sourceURL - ) - } +func browserZoomShortcutTraceActionString(_ action: BrowserZoomShortcutAction?) -> String { + guard let action else { return "none" } + switch action { + case .zoomIn: return "zoomIn" + case .zoomOut: return "zoomOut" + case .reset: return "reset" } +} +#endif - func uninstall() throws -> UninstallOutcome { - do { - let removedExistingEntry = try uninstallWithoutAdministratorPrivileges() - return UninstallOutcome( - usedAdministratorPrivileges: false, - destinationURL: destinationURL, - removedExistingEntry: removedExistingEntry - ) - } catch { - guard Self.isPermissionDenied(error) else { throw error } - try ensureDestinationIsNotDirectory() - let removedExistingEntry = destinationEntryExists() - try privilegedUninstaller(destinationURL) - if destinationEntryExists() { - throw InstallerError.uninstallVerificationFailed(path: destinationURL.path) - } - return UninstallOutcome( - usedAdministratorPrivileges: true, - destinationURL: destinationURL, - removedExistingEntry: removedExistingEntry - ) +func shouldSuppressWindowMoveForFolderDrag(hitView: NSView?) -> Bool { + var candidate = hitView + while let view = candidate { + if view is DraggableFolderNSView { + return true } + candidate = view.superview } + return false +} - func isInstalled() -> Bool { - guard let sourceURL = bundledCLIURLProvider()?.standardizedFileURL else { return false } - guard let installedTargetURL = symlinkDestinationURL() else { return false } - return installedTargetURL == sourceURL +func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) -> Bool { + guard event.type == .leftMouseDown, + window.isMovable, + let contentView = window.contentView else { + return false } - private func resolveBundledCLIURL() throws -> URL { - guard let sourceURL = bundledCLIURLProvider()?.standardizedFileURL else { - throw InstallerError.bundledCLIMissing(expectedPath: expectedBundledCLIPath) - } + let contentPoint = contentView.convert(event.locationInWindow, from: nil) + let hitView = contentView.hitTest(contentPoint) + return shouldSuppressWindowMoveForFolderDrag(hitView: hitView) +} - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory), !isDirectory.boolValue else { - throw InstallerError.bundledCLIMissing(expectedPath: sourceURL.path) - } - return sourceURL - } +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation { + nonisolated(unsafe) static var shared: AppDelegate? - private func installWithoutAdministratorPrivileges(sourceURL: URL) throws { - try ensureDestinationParentDirectoryExists() - try ensureDestinationIsNotDirectory() - if destinationEntryExists() { - try fileManager.removeItem(at: destinationURL) - } - try fileManager.createSymbolicLink(at: destinationURL, withDestinationURL: sourceURL) - try verifyInstalledSymlinkTarget(sourceURL: sourceURL) - } + private static let cachedIsRunningUnderXCTest = detectRunningUnderXCTest(ProcessInfo.processInfo.environment) - @discardableResult - private func uninstallWithoutAdministratorPrivileges() throws -> Bool { - try ensureDestinationIsNotDirectory() - let existed = destinationEntryExists() - if existed { - try fileManager.removeItem(at: destinationURL) - } - if destinationEntryExists() { - throw InstallerError.uninstallVerificationFailed(path: destinationURL.path) - } - return existed + private var isRunningUnderXCTestCached: Bool { + Self.cachedIsRunningUnderXCTest } - /// Check if the destination path has any filesystem entry (including dangling symlinks). - /// `FileManager.fileExists` follows symlinks, so a dangling symlink returns false. - private func destinationEntryExists() -> Bool { - (try? fileManager.attributesOfItem(atPath: destinationURL.path)) != nil + private static func detectRunningUnderXCTest(_ env: [String: String]) -> Bool { + if env["XCTestConfigurationFilePath"] != nil { return true } + if env["XCTestBundlePath"] != nil { return true } + if env["XCTestSessionIdentifier"] != nil { return true } + if env["XCInjectBundle"] != nil { return true } + if env["XCInjectBundleInto"] != nil { return true } + if env["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true { return true } + if env.keys.contains(where: { $0.hasPrefix("PROGRAMA_UI_TEST_") }) { return true } + return false } - private func verifyInstalledSymlinkTarget(sourceURL: URL) throws { - guard let installedTargetURL = symlinkDestinationURL(), - installedTargetURL == sourceURL.standardizedFileURL else { - throw InstallerError.installVerificationFailed(path: destinationURL.path) - } + private func isRunningUnderXCTest(_ env: [String: String]) -> Bool { + // On some macOS/Xcode setups, the app-under-test process doesn't get + // `XCTestConfigurationFilePath`. Use a broader set of signals so UI tests + // can reliably skip heavyweight startup work and bring up a window. + Self.detectRunningUnderXCTest(env) } - private func symlinkDestinationURL() -> URL? { - guard fileManager.fileExists(atPath: destinationURL.path) else { return nil } - guard let destinationPath = try? fileManager.destinationOfSymbolicLink(atPath: destinationURL.path) else { - return nil - } - return URL( - fileURLWithPath: destinationPath, - relativeTo: destinationURL.deletingLastPathComponent() - ).standardizedFileURL - } + final class MainWindowContext { + let windowId: UUID + let tabManager: TabManager + let sidebarState: SidebarState + let sidebarSelectionState: SidebarSelectionState + weak var window: NSWindow? - private func ensureDestinationParentDirectoryExists() throws { - let parentURL = destinationURL.deletingLastPathComponent() - var isDirectory: ObjCBool = false - if fileManager.fileExists(atPath: parentURL.path, isDirectory: &isDirectory) { - guard isDirectory.boolValue else { - throw InstallerError.destinationParentNotDirectory(path: parentURL.path) - } - return - } - try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true) - } - - private func ensureDestinationIsNotDirectory() throws { - guard let values = try resourceValuesIfFileExists( - at: destinationURL, - keys: [.isDirectoryKey, .isSymbolicLinkKey] - ) else { - return - } - - if values.isDirectory == true, values.isSymbolicLink != true { - throw InstallerError.destinationIsDirectory(path: destinationURL.path) + init( + windowId: UUID, + tabManager: TabManager, + sidebarState: SidebarState, + sidebarSelectionState: SidebarSelectionState, + window: NSWindow? + ) { + self.windowId = windowId + self.tabManager = tabManager + self.sidebarState = sidebarState + self.sidebarSelectionState = sidebarSelectionState + self.window = window } } - private func resourceValuesIfFileExists( - at url: URL, - keys: Set - ) throws -> URLResourceValues? { - do { - return try url.resourceValues(forKeys: keys) - } catch { - let nsError = error as NSError - if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError { - return nil - } - if nsError.domain == NSPOSIXErrorDomain, - POSIXErrorCode(rawValue: Int32(nsError.code)) == .ENOENT { - return nil - } - throw error + private final class MainWindowController: NSWindowController, NSWindowDelegate { + var onClose: (() -> Void)? + + func windowWillClose(_ notification: Notification) { + onClose?() } } - private static func defaultBundledCLIURL(bundle: Bundle = .main) -> URL? { - bundle.resourceURL?.appendingPathComponent("bin/programa", isDirectory: false) + struct ScriptableMainWindowState { + let windowId: UUID + let tabManager: TabManager + let window: NSWindow? } - private static func defaultBundledCLIExpectedPath(bundle: Bundle = .main) -> String { - bundle.bundleURL - .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) - .path + struct SessionDisplayGeometry { + let displayID: UInt32? + let frame: CGRect + let visibleFrame: CGRect } - private static func installWithAdministratorPrivileges(sourceURL: URL, destinationURL: URL) throws { - let destinationPath = destinationURL.path - let parentPath = destinationURL.deletingLastPathComponent().path - let command = "/bin/mkdir -p \(shellQuoted(parentPath)) && " + - "/bin/rm -f \(shellQuoted(destinationPath)) && " + - "/bin/ln -s \(shellQuoted(sourceURL.path)) \(shellQuoted(destinationPath))" - try runPrivilegedShellCommand(command) + struct PersistedWindowGeometry: Codable, Sendable { + let version: Int + let frame: SessionRectSnapshot + let display: SessionDisplaySnapshot? } - private static func uninstallWithAdministratorPrivileges(destinationURL: URL) throws { - let command = "/bin/rm -f \(shellQuoted(destinationURL.path))" - try runPrivilegedShellCommand(command) - } + nonisolated static let persistedWindowGeometrySchemaVersion = 2 + private nonisolated static let persistedWindowGeometryDefaultsKey = "programa.session.lastWindowGeometry.v2" + private nonisolated static let legacyPersistedWindowGeometryDefaultsKeys = [ + "programa.session.lastWindowGeometry.v1" + ] - private static func runPrivilegedShellCommand(_ command: String) throws { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") - process.arguments = [ - "-e", "on run argv", - "-e", "do shell script (item 1 of argv) with administrator privileges", - "-e", "end run", - command - ] - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr - try process.run() - process.waitUntilExit() - - guard process.terminationStatus == 0 else { - let stderrText = String( - data: stderr.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8 - )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let stdoutText = String( - data: stdout.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8 - )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let details = stderrText.isEmpty ? stdoutText : stderrText - let message = details.isEmpty - ? "osascript exited with status \(process.terminationStatus)." - : details - throw InstallerError.privilegedCommandFailed(message: message) - } - } - - private static func shellQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" - } - - private static func isPermissionDenied(_ error: Error) -> Bool { - isPermissionDenied(error as NSError) - } - - private static func isPermissionDenied(_ error: NSError) -> Bool { - if error.domain == NSPOSIXErrorDomain, - let code = POSIXErrorCode(rawValue: Int32(error.code)), - code == .EACCES || code == .EPERM || code == .EROFS { - return true + weak var tabManager: TabManager? + weak var notificationStore: TerminalNotificationStore? + weak var sidebarState: SidebarState? + weak var fullscreenControlsViewModel: TitlebarControlsViewModel? + weak var sidebarSelectionState: SidebarSelectionState? + var shortcutLayoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) + private var workspaceObserver: NSObjectProtocol? + private var lifecycleSnapshotObservers: [NSObjectProtocol] = [] + private var windowKeyObserver: NSObjectProtocol? + private var shortcutMonitor: Any? + private var shortcutDefaultsObserver: NSObjectProtocol? + private var menuBarVisibilityObserver: NSObjectProtocol? + private var splitButtonTooltipRefreshScheduled = false + private struct PendingConfiguredShortcutChord { + let firstStroke: ShortcutStroke + let windowNumber: Int? + } + private var pendingConfiguredShortcutChord: PendingConfiguredShortcutChord? + private var activeConfiguredShortcutChordPrefixForCurrentEvent: ShortcutStroke? + private var configuredShortcutChordActions: [KeyboardShortcutSettings.Action] = [] + private var ghosttyConfigObserver: NSObjectProtocol? + var ghosttyGotoSplitLeftShortcut: StoredShortcut? + var ghosttyGotoSplitRightShortcut: StoredShortcut? + var ghosttyGotoSplitUpShortcut: StoredShortcut? + var ghosttyGotoSplitDownShortcut: StoredShortcut? + private var browserAddressBarFocusedPanelId: UUID? + private var browserOmnibarRepeatStartWorkItem: DispatchWorkItem? + private var browserOmnibarRepeatTickWorkItem: DispatchWorkItem? + private var browserOmnibarRepeatKeyCode: UInt16? + private var browserOmnibarRepeatDelta: Int = 0 + private var browserAddressBarFocusObserver: NSObjectProtocol? + private var browserAddressBarBlurObserver: NSObjectProtocol? + private let updateController = UpdateController() + private lazy var titlebarAccessoryController = UpdateTitlebarAccessoryController(viewModel: updateViewModel) + private let windowDecorationsController = WindowDecorationsController() + private var menuBarExtraController: MenuBarExtraController? + private static let serviceErrorNoPath = NSString(string: String(localized: "error.clipboardFolderPath", defaultValue: "Could not load any folder path from the clipboard.")) + private static let didInstallWindowKeyEquivalentSwizzle: Void = { + let targetClass: AnyClass = NSWindow.self + let originalSelector = #selector(NSWindow.performKeyEquivalent(with:)) + let swizzledSelector = #selector(NSWindow.programa_performKeyEquivalent(with:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return } - - if error.domain == NSCocoaErrorDomain { - switch error.code { - case NSFileWriteNoPermissionError, NSFileReadNoPermissionError, NSFileWriteVolumeReadOnlyError: - return true - default: - break - } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + private static let didInstallWindowFirstResponderSwizzle: Void = { + let targetClass: AnyClass = NSWindow.self + let originalSelector = #selector(NSWindow.makeFirstResponder(_:)) + let swizzledSelector = #selector(NSWindow.programa_makeFirstResponder(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return } - - if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError { - return isPermissionDenied(underlying) + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + private static let didInstallWindowSendEventSwizzle: Void = { + let targetClass: AnyClass = NSWindow.self + let originalSelector = #selector(NSWindow.sendEvent(_:)) + let swizzledSelector = #selector(NSWindow.programa_sendEvent(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() + private static let didInstallApplicationSendEventSwizzle: Void = { + let targetClass: AnyClass = NSApplication.self + let originalSelector = #selector(NSApplication.sendEvent(_:)) + let swizzledSelector = #selector(NSApplication.programa_applicationSendEvent(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() - return false - } -} - -private extension NSScreen { - var programaDisplayID: UInt32? { - let key = NSDeviceDescriptionKey("NSScreenNumber") - guard let value = deviceDescription[key] as? NSNumber else { return nil } - return value.uint32Value +#if DEBUG + var didSetupJumpUnreadUITest = false + var jumpUnreadFocusExpectation: (tabId: UUID, surfaceId: UUID)? + var jumpUnreadFocusObserver: NSObjectProtocol? + var didSetupTerminalCmdClickUITest = false + var didSetupGotoSplitUITest = false + var didSetupBonsplitTabDragUITest = false + var terminalCmdClickUITestPoller: DispatchSourceTimer? + var bonsplitTabDragUITestRecorder: DispatchSourceTimer? + var gotoSplitUITestRecorder: DispatchSourceTimer? + var gotoSplitUITestObservers: [NSObjectProtocol] = [] + var didSetupMultiWindowNotificationsUITest = false + var didSetupDisplayResolutionUITestDiagnostics = false + var displayResolutionUITestObservers: [NSObjectProtocol] = [] + private struct UITestRenderDiagnosticsSnapshot { + let panelId: UUID + let drawCount: Int + let presentCount: Int + let lastPresentTime: Double + let windowVisible: Bool + let appIsActive: Bool + let desiredFocus: Bool + let isFirstResponder: Bool } -} - -func browserOmnibarSelectionDeltaForCommandNavigation( - hasFocusedAddressBar: Bool, - flags: NSEvent.ModifierFlags, - chars: String -) -> Int? { - guard hasFocusedAddressBar else { return nil } - let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) - let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] - guard isCommandOrControlOnly else { return nil } - if chars == "n" { return 1 } - if chars == "p" { return -1 } - return nil -} + var debugCloseMainWindowConfirmationHandler: ((NSWindow) -> Bool)? + var debugCreateMainWindowSourceIsNativeFullScreenOverride: Bool? + // Keep debug-only windows alive when tests intentionally inject key mismatches. + private var debugDetachedContextWindows: [NSWindow] = [] -func browserOmnibarSelectionDeltaForArrowNavigation( - hasFocusedAddressBar: Bool, - flags: NSEvent.ModifierFlags, - keyCode: UInt16 -) -> Int? { - guard hasFocusedAddressBar else { return nil } - let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) - guard normalizedFlags == [] else { return nil } - switch keyCode { - case 125: return 1 - case 126: return -1 - default: return nil + private func childExitKeyboardProbePath() -> String? { + let env = ProcessInfo.processInfo.environment + guard env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] == "1", + let path = env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"], + !path.isEmpty else { + return nil + } + return path } -} - -func browserOmnibarNormalizedModifierFlags(_ flags: NSEvent.ModifierFlags) -> NSEvent.ModifierFlags { - flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) -} - -func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { - let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) - return normalizedFlags == [] || normalizedFlags == [.shift] -} - -func browserResponderHasMarkedText(_ responder: NSResponder?) -> Bool { - guard let responder else { return false } - // During IME composition, Return/Enter belongs to the text system so the - // candidate list can commit or confirm the marked text. - if let textInputClient = responder as? NSTextInputClient { - if textInputClient.hasMarkedText() { return true } + private func childExitKeyboardProbeHex(_ value: String?) -> String { + guard let value else { return "" } + return value.unicodeScalars + .map { String(format: "%04X", $0.value) } + .joined(separator: ",") } - if let textField = responder as? NSTextField, - let editor = textField.currentEditor() as? NSTextView { - if editor.hasMarkedText() { return true } + private func writeChildExitKeyboardProbe(_ updates: [String: String], increments: [String: Int] = [:]) { + guard let path = childExitKeyboardProbePath() else { return } + var payload: [String: String] = { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { + return [:] + } + return object + }() + for (key, by) in increments { + let current = Int(payload[key] ?? "") ?? 0 + payload[key] = String(current + by) + } + for (key, value) in updates { + payload[key] = value + } + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } + try? data.write(to: URL(fileURLWithPath: path), options: .atomic) } +#endif - // WKWebView clears marked text before performKeyEquivalent fires, so the - // synchronous hasMarkedText() check above can return false even though an IME - // composition just ended on the same Enter keystroke. Check the JS bridge's - // composition timestamp to detect this race condition (#2626). - if let webView = responder.programaEnclosingProgramaWebView { - if webView.webViewIsComposing { return true } - let age = ProcessInfo.processInfo.systemUptime - webView.recentCompositionEndTimestamp - if age >= 0 && age < 0.15 { return true } - } + var mainWindowContexts: [ObjectIdentifier: MainWindowContext] = [:] + private var mainWindowControllers: [MainWindowController] = [] - return false -} - -private extension NSResponder { - /// Walk the responder chain to find the enclosing ProgramaWebView. - var programaEnclosingProgramaWebView: ProgramaWebView? { - var current: NSResponder? = self - while let responder = current { - if let webView = responder as? ProgramaWebView { return webView } - current = responder.nextResponder - } - return nil + /// Tracks the cascade point for new windows, matching Ghostty's upstream algorithm. + /// Reset to `.zero` so the first window seeds the point from its own position. + private var lastCascadePoint = NSPoint.zero + private var startupSessionSnapshot: AppSessionSnapshot? + private var didPrepareStartupSessionSnapshot = false + private var didAttemptStartupSessionRestore = false + private var isApplyingStartupSessionRestore = false + private var sessionAutosaveTimer: DispatchSourceTimer? + private var sessionAutosaveTickInFlight = false + private var sessionAutosaveDeferredRetryPending = false + private let sessionPersistenceQueue = DispatchQueue( + label: "com.cmuxterm.app.sessionPersistence", + qos: .utility + ) + private nonisolated static let launchServicesRegistrationQueue = DispatchQueue( + label: "com.cmuxterm.app.launchServicesRegistration", + qos: .utility + ) + private nonisolated static func enqueueLaunchServicesRegistrationWork(_ work: @escaping @Sendable () -> Void) { + launchServicesRegistrationQueue.async(execute: work) } -} - -func shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: UInt16, - firstResponderIsBrowser: Bool, - firstResponderHasMarkedText: Bool = false, - flags: NSEvent.ModifierFlags -) -> Bool { - guard firstResponderIsBrowser else { return false } - guard !firstResponderHasMarkedText else { return false } - guard keyCode == 36 || keyCode == 76 else { return false } - // Keep browser Return forwarding narrow: only plain/Shift Return should be - // treated as submit-intent. Command-modified Return is reserved for app shortcuts - // like Toggle Pane Zoom (Cmd+Shift+Enter). - return browserOmnibarShouldSubmitOnReturn(flags: flags) -} + private var lastSessionAutosaveFingerprint: Int? + private var lastSessionAutosavePersistedAt: Date = .distantPast + private var lastTypingActivityAt: TimeInterval = 0 + private var didHandleExplicitOpenIntentAtStartup = false + private var isTerminatingApp = false + // Set to true when the user has already confirmed quit via the warning dialog, + // so applicationShouldTerminate does not show a second alert. + private var isQuitWarningConfirmed = false + private var didInstallLifecycleSnapshotObservers = false + private var didDisableSuddenTermination = false + private var commandPaletteVisibilityByWindowId: [UUID: Bool] = [:] + private var commandPalettePendingOpenByWindowId: [UUID: Bool] = [:] + private var commandPaletteRecentRequestAtByWindowId: [UUID: TimeInterval] = [:] + private var commandPaletteEscapeSuppressionByWindowId: Set = [] + private var commandPaletteEscapeSuppressionStartedAtByWindowId: [UUID: TimeInterval] = [:] + private var commandPaletteSelectionByWindowId: [UUID: Int] = [:] + 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 -func shouldToggleMainWindowFullScreenForCommandControlFShortcut( - flags: NSEvent.ModifierFlags, - chars: String, - keyCode: UInt16, - layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) -) -> Bool { - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - guard normalizedFlags == [.command, .control] else { return false } - let normalizedChars = chars.lowercased() - if normalizedChars == "f" { - return true - } - let charsAreControlSequence = !normalizedChars.isEmpty - && normalizedChars.unicodeScalars.allSatisfy { CharacterSet.controlCharacters.contains($0) } - if !normalizedChars.isEmpty && !charsAreControlSequence { - return false + var updateViewModel: UpdateViewModel { + updateController.viewModel } - // Fallback to layout translation only when characters are unavailable (for - // synthetic/key-equivalent paths that can report an empty string). - if let translatedCharacter = layoutCharacterProvider(keyCode, flags), !translatedCharacter.isEmpty { - return translatedCharacter == "f" +#if DEBUG + private func pointerString(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) } - // Keep ANSI fallback as a final safety net when layout translation is unavailable. - return keyCode == 3 -} - -func commandPaletteSelectionDeltaForKeyboardNavigation( - flags: NSEvent.ModifierFlags, - chars: String, - keyCode: UInt16 -) -> Int? { - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - let normalizedChars = chars.lowercased() - - if normalizedFlags == [] { - switch keyCode { - case 125: return 1 // Down arrow - case 126: return -1 // Up arrow - default: break - } + private func summarizeContextForWorkspaceRouting(_ context: MainWindowContext?) -> String { + guard let context else { return "nil" } + let window = context.window ?? windowForMainWindowId(context.windowId) + let windowNumber = window?.windowNumber ?? -1 + let key = window?.isKeyWindow == true ? 1 : 0 + let main = window?.isMainWindow == true ? 1 : 0 + let visible = window?.isVisible == true ? 1 : 0 + let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(8)) } ?? "nil" + return "wid=\(context.windowId.uuidString.prefix(8)) win=\(windowNumber) key=\(key) main=\(main) vis=\(visible) tabs=\(context.tabManager.tabs.count) sel=\(selected) tm=\(pointerString(context.tabManager))" } - if normalizedFlags == [.control] { - // Control modifiers can surface as either printable chars or ASCII control chars. - // Keep Emacs-style next/previous navigation, but leave other control bindings - // (for example Ctrl+K text editing in the palette search field) to AppKit. - if keyCode == 45 || normalizedChars == "n" || normalizedChars == "\u{0e}" { return 1 } // Ctrl+N - if keyCode == 35 || normalizedChars == "p" || normalizedChars == "\u{10}" { return -1 } // Ctrl+P + private func summarizeAllContextsForWorkspaceRouting() -> String { + guard !mainWindowContexts.isEmpty else { return "" } + return mainWindowContexts.values + .map { summarizeContextForWorkspaceRouting($0) } + .joined(separator: " | ") } - return nil -} + private func logWorkspaceCreationRouting( + phase: String, + source: String, + reason: String, + event: NSEvent?, + chosenContext: MainWindowContext?, + workspaceId: UUID? = nil, + workingDirectory: String? = nil + ) { + let eventWindowNumber = event?.window?.windowNumber ?? -1 + let eventNumber = event?.windowNumber ?? -1 + let eventChars = event?.charactersIgnoringModifiers ?? "" + let eventKeyCode = event.map { String($0.keyCode) } ?? "nil" + let keyWindowNumber = NSApp.keyWindow?.windowNumber ?? -1 + let mainWindowNumber = NSApp.mainWindow?.windowNumber ?? -1 + let ws = workspaceId.map { String($0.uuidString.prefix(8)) } ?? "nil" + let wd = workingDirectory.map { String($0.prefix(120)) } ?? "-" + FocusLogStore.shared.append( + "cmdn.route phase=\(phase) src=\(source) reason=\(reason) eventWin=\(eventWindowNumber) eventNum=\(eventNumber) keyCode=\(eventKeyCode) chars=\(eventChars) keyWin=\(keyWindowNumber) mainWin=\(mainWindowNumber) activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(chosenContext))} ws=\(ws) wd=\(wd) contexts=[\(summarizeAllContextsForWorkspaceRouting())]" + ) + } +#endif -func shouldRouteCommandPaletteSelectionNavigation( - delta: Int?, - isInteractive: Bool, - usesInlineTextHandling: Bool -) -> Bool { - guard delta != nil, isInteractive else { return false } - return !usesInlineTextHandling -} + override init() { + super.init() + Self.shared = self + } -func shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: Bool, - normalizedFlags: NSEvent.ModifierFlags, - chars: String, - keyCode: UInt16 -) -> Bool { - guard isCommandPaletteVisible else { return false } + func application(_ application: NSApplication, open urls: [URL]) { + let directories = externalOpenDirectories(from: urls) + guard !directories.isEmpty else { return } - // Escape dismisses the palette, and must not leak through to the - // underlying terminal or browser content. - if normalizedFlags.isEmpty, keyCode == 53 { - return true + prepareForExplicitOpenIntentAtStartup() + for directory in directories { + openWorkspaceForExternalDirectory( + workingDirectory: directory, + debugSource: "application.openURLs" + ) + } } - guard normalizedFlags.contains(.command) else { return false } + func applicationDidFinishLaunching(_ notification: Notification) { + let env = ProcessInfo.processInfo.environment + let isRunningUnderXCTest = isRunningUnderXCTest(env) - let normalizedChars = chars.lowercased() + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(handleThemesReloadNotification(_:)), + name: ProgramaThemeNotifications.reloadConfig, + object: nil, + suspensionBehavior: .deliverImmediately + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleReactGrabDidCopySelection(_:)), + name: .reactGrabDidCopySelection, + object: nil + ) - if normalizedFlags == [.command] { - if normalizedChars == "a" - || normalizedChars == "c" - || normalizedChars == "v" - || normalizedChars == "x" - || normalizedChars == "z" - || normalizedChars == "y" { - return false +#if DEBUG + // UI tests run on a shared VM user profile, so persisted shortcuts can drift and make + // key-equivalent routing flaky. Force defaults for deterministic tests. + if isRunningUnderXCTest { + KeyboardShortcutSettings.resetAll() } +#endif - switch keyCode { - case 51, 117, 123, 124: - return false - default: - break +#if DEBUG + writeUITestDiagnosticsIfNeeded(stage: "didFinishLaunching") + ProgramaMainRunLoopStallMonitor.shared.installIfNeeded() + ProgramaMainThreadTurnProfiler.shared.installIfNeeded() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.writeUITestDiagnosticsIfNeeded(stage: "after1s") } - } - - if normalizedFlags == [.command, .shift], normalizedChars == "z" { - return false - } +#endif - return true -} + let forceDuplicateLaunchObserver = env["PROGRAMA_UI_TEST_ENABLE_DUPLICATE_LAUNCH_OBSERVER"] == "1" -func shouldSubmitCommandPaletteWithReturn( - keyCode: UInt16, - flags: NSEvent.ModifierFlags, - mode: String -) -> Bool { - guard keyCode == 36 || keyCode == 76 else { return false } - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - if normalizedFlags.isEmpty { - return true - } - if normalizedFlags == [.shift] { - return mode != "workspace_description_input" - } - return false -} - -func commandPaletteFieldEditorHasMarkedText(in window: NSWindow) -> Bool { - if let editor = window.firstResponder as? NSTextView { - return editor.hasMarkedText() - } - if let textField = window.firstResponder as? NSTextField, - let editor = textField.currentEditor() as? NSTextView { - return editor.hasMarkedText() - } - return false -} + // UI tests frequently time out waiting for the main window if we do heavyweight + // LaunchServices registration / single-instance enforcement synchronously at startup. + // Skip these during XCTest (the app-under-test) so the window can appear quickly. + if !isRunningUnderXCTest { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.scheduleLaunchServicesBundleRegistration() + self.enforceSingleInstance() + self.observeDuplicateLaunches() + } + } else if forceDuplicateLaunchObserver { + // Some UI regressions specifically exercise launch-observer behavior while still + // running under XCTest. Allow an explicit opt-in for those cases only. + DispatchQueue.main.async { [weak self] in + self?.observeDuplicateLaunches() + } + } + NSWindow.allowsAutomaticWindowTabbing = false + disableNativeTabbingShortcut() + ensureApplicationIcon() + if !isRunningUnderXCTest { + configureUserNotifications() + installMenuBarVisibilityObserver() + syncMenuBarExtraVisibility() + updateController.startUpdaterIfNeeded() + } + titlebarAccessoryController.start() + windowDecorationsController.start() + installMainWindowKeyObserver() + refreshGhosttyGotoSplitShortcuts() + installGhosttyConfigObserver() + installWindowResponderSwizzles() + installBrowserAddressBarFocusObservers() + installShortcutMonitor() + installShortcutDefaultsObserver() + NSApp.servicesProvider = self +#if DEBUG + UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) + if env["PROGRAMA_UI_TEST_MODE"] == "1" { + let trigger = env["PROGRAMA_UI_TEST_TRIGGER_UPDATE_CHECK"] ?? "" + let feed = env["PROGRAMA_UI_TEST_FEED_URL"] ?? "" + UpdateLogStore.shared.append("ui test env: trigger=\(trigger) feed=\(feed)") + } + if env["PROGRAMA_UI_TEST_TRIGGER_UPDATE_CHECK"] == "1" { + UpdateLogStore.shared.append("ui test trigger update check detected") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + guard let self else { return } + let windowIds = NSApp.windows.map { $0.identifier?.rawValue ?? "" } + UpdateLogStore.shared.append("ui test windows: count=\(NSApp.windows.count) ids=\(windowIds.joined(separator: ","))") + if UpdateTestSupport.performMockFeedCheckIfNeeded(on: self.updateController.viewModel) { + return + } + self.checkForUpdates(nil) + } + } -func shouldHandleCommandPaletteShortcutEvent( - _ event: NSEvent, - paletteWindow: NSWindow? -) -> Bool { - guard let paletteWindow else { return false } - if let eventWindow = event.window { - return eventWindow === paletteWindow - } - let eventWindowNumber = event.windowNumber - if eventWindowNumber > 0 { - return eventWindowNumber == paletteWindow.windowNumber - } - if let keyWindow = NSApp.keyWindow { - return keyWindow === paletteWindow + // In UI tests, `WindowGroup` occasionally fails to materialize a window quickly on the VM. + // If there are no windows shortly after launch, force-create one so XCUITest can proceed. + if isRunningUnderXCTest { + if let rawShow = env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_SHOW"] { + UserDefaults.standard.set( + rawShow == "1", + forKey: BrowserImportHintSettings.showOnBlankTabsKey + ) + } + if let rawDismissed = env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_DISMISSED"] { + UserDefaults.standard.set( + rawDismissed == "1", + forKey: BrowserImportHintSettings.dismissedKey + ) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + guard let self else { return } + if NSApp.windows.isEmpty { + self.openNewMainWindow(nil) + } + self.moveUITestWindowToTargetDisplayIfNeeded() + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + // On headless CI runners, activate() silently fails (no GUI session). + // Force windows visible so the terminal surface starts rendering. + for window in NSApp.windows { + window.orderFrontRegardless() + } + self.writeUITestDiagnosticsIfNeeded(stage: "afterForceWindow") + } + if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] == "1" { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in + guard let self else { return } + _ = self.openBrowserAndFocusAddressBar(insertAtEnd: true) + } + } + if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_OPEN_SETTINGS"] == "1" { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) { [weak self] in + self?.openPreferencesWindow( + debugSource: "uiTest.browserImportHint", + navigationTarget: .browser + ) + } + } + if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_AUTO_OPEN"] == "1" { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + BrowserDataImportCoordinator.shared.presentImportDialog() + } + } + } +#endif } - return false -} - -enum BrowserZoomShortcutAction: Equatable { - case zoomIn - case zoomOut - case reset -} -struct CommandPaletteDebugResultRow { - let commandId: String - let title: String - let shortcutHint: String? - let trailingLabel: String? - let score: Int -} - -struct CommandPaletteDebugSnapshot { - let query: String - let mode: String - let results: [CommandPaletteDebugResultRow] +#if DEBUG + func writeUITestDiagnosticsIfNeeded(stage: String) { + let env = ProcessInfo.processInfo.environment + guard let path = env["PROGRAMA_UI_TEST_DIAGNOSTICS_PATH"], !path.isEmpty else { return } - static let empty = CommandPaletteDebugSnapshot(query: "", mode: "commands", results: []) -} + var payload = loadUITestDiagnostics(at: path) + let isRunningUnderXCTest = isRunningUnderXCTest(env) -func browserZoomShortcutAction( - flags: NSEvent.ModifierFlags, - chars: String, - keyCode: UInt16, - literalChars: String? = nil -) -> BrowserZoomShortcutAction? { - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - let hasCommand = normalizedFlags.contains(.command) - let hasOnlyCommandAndOptionalShift = hasCommand && normalizedFlags.isDisjoint(with: [.control, .option]) + let windows = NSApp.windows + let ids = windows.map { $0.identifier?.rawValue ?? "" }.joined(separator: ",") + let vis = windows.map { $0.isVisible ? "1" : "0" }.joined(separator: ",") + let screenIDs = windows.map { $0.screen?.programaDisplayID.map(String.init) ?? "" }.joined(separator: ",") + let targetDisplayID = env["PROGRAMA_UI_TEST_TARGET_DISPLAY_ID"] ?? "" - guard hasOnlyCommandAndOptionalShift else { return nil } - let keys = browserZoomShortcutKeyCandidates( - chars: chars, - literalChars: literalChars, - keyCode: keyCode - ) + payload["stage"] = stage + payload["pid"] = String(ProcessInfo.processInfo.processIdentifier) + payload["bundleId"] = Bundle.main.bundleIdentifier ?? "" + payload["isRunningUnderXCTest"] = isRunningUnderXCTest ? "1" : "0" + payload["windowsCount"] = String(windows.count) + payload["windowIdentifiers"] = ids + payload["windowVisibleFlags"] = vis + payload["windowScreenDisplayIDs"] = screenIDs + payload["uiTestTargetDisplayID"] = targetDisplayID + if let rawDisplayID = UInt32(targetDisplayID) { + let screenPresent = NSScreen.screens.contains(where: { $0.programaDisplayID == rawDisplayID }) + let movedWindow = windows.contains(where: { $0.screen?.programaDisplayID == rawDisplayID }) + payload["targetDisplayPresent"] = screenPresent ? "1" : "0" + payload["targetDisplayMoveSucceeded"] = movedWindow ? "1" : "0" + } + appendUITestRenderDiagnosticsIfNeeded(&payload, environment: env) + appendUITestSocketDiagnosticsIfNeeded(&payload, environment: env) - if keys.contains("=") || keys.contains("+") || keyCode == 24 || keyCode == 69 { // kVK_ANSI_Equal / kVK_ANSI_KeypadPlus - return .zoomIn + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } + try? data.write(to: URL(fileURLWithPath: path), options: .atomic) } - if keys.contains("-") || keys.contains("_") || keyCode == 27 || keyCode == 78 { // kVK_ANSI_Minus / kVK_ANSI_KeypadMinus - return .zoomOut + private func loadUITestDiagnostics(at path: String) -> [String: String] { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { + return [:] + } + return object } - if keys.contains("0") || keyCode == 29 || keyCode == 82 { // kVK_ANSI_0 / kVK_ANSI_Keypad0 - return .reset - } + private func appendUITestSocketDiagnosticsIfNeeded( + _ payload: inout [String: String], + environment env: [String: String] + ) { + guard env["PROGRAMA_UI_TEST_SOCKET_SANITY"] == "1" else { return } - return nil -} + guard let config = socketListenerConfigurationIfEnabled() else { + payload["socketExpectedPath"] = env["PROGRAMA_SOCKET_PATH"] ?? "" + payload["socketMode"] = "off" + payload["socketReady"] = "0" + payload["socketPingResponse"] = "" + payload["socketIsRunning"] = "0" + payload["socketAcceptLoopAlive"] = "0" + payload["socketPathMatches"] = "0" + payload["socketPathExists"] = "0" + payload["socketFailureSignals"] = "socket_disabled" + return + } -func browserZoomShortcutKeyCandidates( - chars: String, - literalChars: String?, - keyCode: UInt16 -) -> Set { - var keys: Set = [chars.lowercased()] + let socketPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) + let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: socketPath) + let pingResponse = health.isHealthy + ? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) + : nil + let isReady = health.isHealthy && pingResponse == "PONG" + var failureSignals = health.failureSignals + if health.isHealthy && pingResponse != "PONG" { + failureSignals.append("ping_timeout") + } - if let literalChars, !literalChars.isEmpty { - keys.insert(literalChars.lowercased()) - } - - if let layoutChar = KeyboardLayout.character(forKeyCode: keyCode), !layoutChar.isEmpty { - keys.insert(layoutChar) + payload["socketExpectedPath"] = socketPath + payload["socketMode"] = config.mode.rawValue + payload["socketReady"] = isReady ? "1" : "0" + payload["socketPingResponse"] = pingResponse ?? "" + payload["socketIsRunning"] = health.isRunning ? "1" : "0" + payload["socketAcceptLoopAlive"] = health.acceptLoopAlive ? "1" : "0" + payload["socketPathMatches"] = health.socketPathMatches ? "1" : "0" + payload["socketPathExists"] = health.socketPathExists ? "1" : "0" + payload["socketFailureSignals"] = failureSignals.joined(separator: ",") } - return keys -} - -func shouldSuppressSplitShortcutForTransientTerminalFocusInputs( - firstResponderIsWindow: Bool, - hostedSize: CGSize, - hostedHiddenInHierarchy: Bool, - hostedAttachedToWindow: Bool -) -> Bool { - guard firstResponderIsWindow else { return false } - let tinyGeometry = hostedSize.width <= 1 || hostedSize.height <= 1 - return tinyGeometry || hostedHiddenInHierarchy || !hostedAttachedToWindow -} + private func appendUITestRenderDiagnosticsIfNeeded( + _ payload: inout [String: String], + environment env: [String: String] + ) { + guard env["PROGRAMA_UI_TEST_DISPLAY_RENDER_STATS"] == "1" else { return } -func shouldRouteTerminalFontZoomShortcutToGhostty( - firstResponderIsGhostty: Bool, - flags: NSEvent.ModifierFlags, - chars: String, - keyCode: UInt16, - literalChars: String? = nil -) -> Bool { - guard firstResponderIsGhostty else { return false } - return browserZoomShortcutAction( - flags: flags, - chars: chars, - keyCode: keyCode, - literalChars: literalChars - ) != nil -} + guard let renderState = currentUITestRenderDiagnostics() else { + payload["renderStatsAvailable"] = "0" + payload["renderPanelId"] = "" + payload["renderDrawCount"] = "" + payload["renderPresentCount"] = "" + payload["renderLastPresentTime"] = "" + payload["renderWindowVisible"] = "" + payload["renderAppIsActive"] = "" + payload["renderDesiredFocus"] = "" + payload["renderIsFirstResponder"] = "" + payload["renderDiagnosticsUpdatedAt"] = String(format: "%.6f", ProcessInfo.processInfo.systemUptime) + return + } -@discardableResult -func startOrFocusTerminalSearch( - _ terminalSurface: TerminalSurface, - searchFocusNotifier: @escaping (TerminalSurface) -> Void = { - NotificationCenter.default.post(name: .ghosttySearchFocus, object: $0) - } -) -> Bool { - if terminalSurface.searchState != nil { - searchFocusNotifier(terminalSurface) - return true + payload["renderStatsAvailable"] = "1" + payload["renderPanelId"] = renderState.panelId.uuidString + payload["renderDrawCount"] = String(renderState.drawCount) + payload["renderPresentCount"] = String(renderState.presentCount) + payload["renderLastPresentTime"] = String(format: "%.6f", renderState.lastPresentTime) + payload["renderWindowVisible"] = renderState.windowVisible ? "1" : "0" + payload["renderAppIsActive"] = renderState.appIsActive ? "1" : "0" + payload["renderDesiredFocus"] = renderState.desiredFocus ? "1" : "0" + payload["renderIsFirstResponder"] = renderState.isFirstResponder ? "1" : "0" + payload["renderDiagnosticsUpdatedAt"] = String(format: "%.6f", ProcessInfo.processInfo.systemUptime) } - if terminalSurface.performBindingAction("start_search") { - DispatchQueue.main.async { [weak terminalSurface] in - guard let terminalSurface, terminalSurface.searchState == nil else { return } - terminalSurface.searchState = TerminalSurface.SearchState() - searchFocusNotifier(terminalSurface) + private func currentUITestRenderDiagnostics() -> UITestRenderDiagnosticsSnapshot? { + guard let tabManager, + let tabId = tabManager.selectedTabId, + let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { + return nil } - return true - } - - terminalSurface.searchState = TerminalSurface.SearchState() - searchFocusNotifier(terminalSurface) - return true -} -/// Let AppKit own native Cmd+` window cycling so key-window changes do not -/// re-enter our direct-to-menu shortcut path. -func shouldRouteCommandEquivalentDirectlyToMainMenu(_ event: NSEvent) -> Bool { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard flags.contains(.command) else { return false } + let terminalPanel: TerminalPanel? = { + if let focusedPanelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: focusedPanelId) { + return terminalPanel + } + if let focusedTerminalPanel = workspace.focusedTerminalPanel { + return focusedTerminalPanel + } + return workspace.panels.values.compactMap { $0 as? TerminalPanel }.first + }() - let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) - if event.keyCode == 50, - normalizedFlags == [.command] || normalizedFlags == [.command, .shift] { - return false + guard let terminalPanel else { return nil } + let stats = terminalPanel.hostedView.debugRenderStats() + return UITestRenderDiagnosticsSnapshot( + panelId: terminalPanel.id, + drawCount: stats.drawCount, + presentCount: stats.presentCount, + lastPresentTime: stats.lastPresentTime, + windowVisible: stats.windowOcclusionVisible, + appIsActive: stats.appIsActive, + desiredFocus: stats.desiredFocus, + isFirstResponder: stats.isFirstResponder + ) } - return true -} + private func moveUITestWindowToTargetDisplayIfNeeded(attempt: Int = 0) { + let env = ProcessInfo.processInfo.environment + guard let rawDisplayID = env["PROGRAMA_UI_TEST_TARGET_DISPLAY_ID"], + let targetDisplayID = UInt32(rawDisplayID) else { + return + } -private enum BrowserFindCommandEquivalent { - case find - case findNext - case findPrevious - case hideFind - case useSelection + guard let screen = NSScreen.screens.first(where: { $0.programaDisplayID == targetDisplayID }) else { + if attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) + } + } + self.writeUITestDiagnosticsIfNeeded(stage: "targetDisplayMissing") + return + } - var keepsProgramaBrowserFindBarOwnershipWhenVisible: Bool { - switch self { - case .find, .findNext, .findPrevious, .hideFind: - return true - case .useSelection: - return false + guard let window = NSApp.windows.first else { + if attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) + } + } + self.writeUITestDiagnosticsIfNeeded(stage: "targetDisplayNoWindow") + return } - } -} -private func programaIsLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool { - guard let responder else { return false } - let responderType = String(describing: type(of: responder)) - if responderType.contains("WKInspector") { - return true - } - guard let view = responder as? NSView else { return false } - var node: NSView? = view - var hops = 0 - while let current = node, hops < 64 { - if String(describing: type(of: current)).contains("WKInspector") { - return true + let visibleFrame = screen.visibleFrame + let width = min(window.frame.width, max(visibleFrame.width - 80, 480)) + let height = min(window.frame.height, max(visibleFrame.height - 80, 360)) + let frame = NSRect( + x: visibleFrame.midX - (width / 2), + y: visibleFrame.midY - (height / 2), + width: width, + height: height + ).integral + + window.setFrame(frame, display: true, animate: false) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + if window.screen?.programaDisplayID != targetDisplayID, attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) + } + return } - node = current.superview - hops += 1 + self.writeUITestDiagnosticsIfNeeded(stage: "afterMoveToTargetDisplay") } - return false -} +#endif -private func browserFindCommandEquivalent(for event: NSEvent) -> BrowserFindCommandEquivalent? { - let flags = event.modifierFlags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) + func applicationDidBecomeActive(_ notification: Notification) { + guard let notificationStore else { return } + notificationStore.handleApplicationDidBecomeActive() + guard let tabManager else { return } + guard let tabId = tabManager.selectedTabId else { return } + let surfaceId = tabManager.focusedSurfaceId(for: tabId) + guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return } - let normalizedChars = KeyboardLayout.normalizedCharacters(for: event).lowercased() - let hasSingleASCIIShortcutChar = - normalizedChars.count == 1 && normalizedChars.allSatisfy(\.isASCII) - let producedAnyASCIIShortcutChar = normalizedChars.contains(where: \.isASCII) - func matches(_ chars: String, keyCode: UInt16) -> Bool { - if hasSingleASCIIShortcutChar { - return normalizedChars == chars - } - if !producedAnyASCIIShortcutChar { - return event.keyCode == keyCode + if let surfaceId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }) { + tab.triggerNotificationFocusFlash(panelId: surfaceId, requiresSplit: false, shouldFocus: false) } - return false + notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) } - switch flags { - case [.command]: - if matches("e", keyCode: 14) { // kVK_ANSI_E - return .useSelection + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + + // Tagged DEV builds are ephemeral, skip quit confirmation entirely. + if SocketControlSettings.isTaggedDevBuild() { + return .terminateNow } - if matches("f", keyCode: 3) { // kVK_ANSI_F - return .find + + // If the user already confirmed via the Cmd+Q shortcut warning dialog + // (handleQuitShortcutWarning), skip the check to avoid a second alert. + if isQuitWarningConfirmed { + return .terminateNow } - if matches("g", keyCode: 5) { // kVK_ANSI_G - return .findNext + + // Respect the "Warn Before Quit" setting even when Cmd+Q arrives via + // the Cmd+Tab app switcher, bypassing handleCustomShortcut. + guard QuitWarningSettings.isEnabled() else { + return .terminateNow } - return nil - case [.command, .shift]: - if matches("f", keyCode: 3) { // kVK_ANSI_F - return .hideFind - } - if matches("g", keyCode: 5) { // kVK_ANSI_G - return .findPrevious + + // Show the same confirmation dialog used by the Cmd+Q shortcut path, + // then reply asynchronously so we can return .terminateLater now. + DispatchQueue.main.async { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "dialog.quitPrograma.title", defaultValue: "Quit Programa?") + alert.informativeText = String(localized: "dialog.quitPrograma.message", defaultValue: "This will close all windows and workspaces.") + alert.addButton(withTitle: String(localized: "dialog.quitPrograma.quit", defaultValue: "Quit")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.showsSuppressionButton = true + alert.suppressionButton?.title = String(localized: "dialog.dontWarnCmdQ", defaultValue: "Don't warn again for Cmd+Q") + + let response = alert.runModal() + if alert.suppressionButton?.state == .on { + QuitWarningSettings.setEnabled(false) + } + + let shouldQuit = response == .alertFirstButtonReturn + if shouldQuit { + self.isQuitWarningConfirmed = true + } else { + // Reset so that the next quit attempt can show the dialog again. + self.isTerminatingApp = false + } + NSApp.reply(toApplicationShouldTerminate: shouldQuit) } - return nil - default: - return nil + return .terminateLater } -} -/// For browser content, let the page try the Find command family before cmux's menu fallback. -/// This preserves native web-app shortcuts like VS Code's Cmd+F while still allowing cmux's -/// browser find overlay to keep owning its visible Find UI shortcuts. -func shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( - _ event: NSEvent, - responder: NSResponder? = nil, - owningWebView: ProgramaWebView? = nil -) -> Bool { - guard let shortcut = browserFindCommandEquivalent(for: event) else { - return false + func applicationWillTerminate(_ notification: Notification) { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + stopSessionAutosaveTimer() + TerminalController.shared.stop() + VSCodeServeWebController.shared.stop() + BrowserProfileStore.shared.flushPendingSaves() + notificationStore?.clearAll() + SurfacePool.shared.teardownAll() + enableSuddenTerminationIfNeeded() } - if programaIsLikelyWebInspectorResponder(responder) { - return false + func applicationWillResignActive(_ notification: Notification) { + guard !isTerminatingApp else { return } + clearConfiguredShortcutChordState() + _ = saveSessionSnapshot(includeScrollback: false) } - if shortcut.keepsProgramaBrowserFindBarOwnershipWhenVisible, - let owningWebView { - let browserFindBarIsVisible = MainActor.assumeIsolated { - AppDelegate.shared?.browserFindBarIsVisible(for: owningWebView) == true - } - if browserFindBarIsVisible { - return false - } + func persistSessionForUpdateRelaunch() { + isTerminatingApp = true + _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) } - return true -} + func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore, sidebarState: SidebarState) { + self.tabManager = tabManager + self.notificationStore = notificationStore + self.sidebarState = sidebarState + disableSuddenTerminationIfNeeded() + installLifecycleSnapshotObserversIfNeeded() + prepareStartupSessionSnapshotIfNeeded() + startSessionAutosaveTimerIfNeeded() +#if DEBUG + setupJumpUnreadUITestIfNeeded() + setupTerminalCmdClickUITestIfNeeded() + setupGotoSplitUITestIfNeeded() + setupBonsplitTabDragUITestIfNeeded() + setupMultiWindowNotificationsUITestIfNeeded() + setupDisplayResolutionUITestDiagnosticsIfNeeded() -func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? { - guard let responder else { return nil } - if let ghosttyView = responder as? GhosttyNSView { - return ghosttyView + // UI tests sometimes don't run SwiftUI `.onAppear` soon enough (or at all) on the VM. + // The automation socket is a core testing primitive, so ensure it's started here when + // we detect XCTest, even if the main view lifecycle is flaky. + let env = ProcessInfo.processInfo.environment + if isRunningUnderXCTest(env) { + let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) + ?? SocketControlSettings.defaultMode.rawValue + let userMode = SocketControlSettings.migrateMode(raw) + let mode = SocketControlSettings.effectiveMode(userMode: userMode) + if mode != .off { + TerminalController.shared.start( + tabManager: tabManager, + socketPath: SocketControlSettings.socketPath(), + accessMode: mode + ) + scheduleUITestSocketSanityCheckIfNeeded() + } + } +#endif } - if let view = responder as? NSView, - let ghosttyView = cmuxOwningGhosttyView(for: view) { - return ghosttyView + + private func prepareStartupSessionSnapshotIfNeeded() { + guard !didPrepareStartupSessionSnapshot else { return } + didPrepareStartupSessionSnapshot = true + guard SessionRestorePolicy.shouldAttemptRestore() else { return } + Self.removeLegacyPersistedWindowGeometry() + startupSessionSnapshot = SessionPersistenceStore.load() } - if let textView = responder as? NSTextView { - if textView.isFieldEditor, - let ownerView = programaFieldEditorOwnerView(textView), - let ghosttyView = cmuxOwningGhosttyView(for: ownerView) { - return ghosttyView + private func persistedWindowGeometry( + defaults: UserDefaults = .standard + ) -> PersistedWindowGeometry? { + Self.removeLegacyPersistedWindowGeometry(defaults: defaults) + guard let data = defaults.data(forKey: Self.persistedWindowGeometryDefaultsKey) else { + return nil } - - if !textView.isFieldEditor, - let delegateView = textView.delegate as? NSView, - let ghosttyView = cmuxOwningGhosttyView(for: delegateView) { - return ghosttyView + guard let payload = Self.decodedPersistedWindowGeometryData(data) else { + defaults.removeObject(forKey: Self.persistedWindowGeometryDefaultsKey) + return nil } + return payload } - var current = responder.nextResponder - while let next = current { - if let ghosttyView = next as? GhosttyNSView { - return ghosttyView - } - if let view = next as? NSView, - let ghosttyView = cmuxOwningGhosttyView(for: view) { - return ghosttyView + private func persistWindowGeometry( + frame: SessionRectSnapshot?, + display: SessionDisplaySnapshot?, + defaults: UserDefaults = .standard + ) { + Self.removeLegacyPersistedWindowGeometry(defaults: defaults) + guard let data = Self.encodedPersistedWindowGeometryData(frame: frame, display: display) else { + return } - current = next.nextResponder + defaults.set(data, forKey: Self.persistedWindowGeometryDefaultsKey) } - return nil -} - -private func programaFieldEditorOwnerView(_ editor: NSTextView) -> NSView? { - guard editor.isFieldEditor else { return nil } + private nonisolated static func encodedPersistedWindowGeometryData( + frame: SessionRectSnapshot?, + display: SessionDisplaySnapshot? + ) -> Data? { + guard let frame else { return nil } + let payload = PersistedWindowGeometry( + version: persistedWindowGeometrySchemaVersion, + frame: frame, + display: display + ) + return try? JSONEncoder().encode(payload) + } - var current = editor.nextResponder - while let next = current { - if let view = next as? NSView { - return view + nonisolated static func decodedPersistedWindowGeometryData(_ data: Data) -> PersistedWindowGeometry? { + guard let payload = try? JSONDecoder().decode(PersistedWindowGeometry.self, from: data), + payload.version == persistedWindowGeometrySchemaVersion else { + return nil } - current = next.nextResponder + return payload } - return editor.superview -} + private nonisolated static func removeLegacyPersistedWindowGeometry( + defaults: UserDefaults = .standard + ) { + legacyPersistedWindowGeometryDefaultsKeys.forEach { defaults.removeObject(forKey: $0) } + } -private func cmuxOwningGhosttyView(for view: NSView) -> GhosttyNSView? { - if let ghosttyView = view as? GhosttyNSView { - return ghosttyView + private func persistWindowGeometry(from window: NSWindow?) { + guard let window else { return } + persistWindowGeometry( + frame: SessionRectSnapshot(window.frame), + display: displaySnapshot(for: window) + ) } - var current: NSView? = view.superview - while let candidate = current { - if let ghosttyView = candidate as? GhosttyNSView { - return ghosttyView + private func currentDisplayGeometries() -> ( + available: [SessionDisplayGeometry], + fallback: SessionDisplayGeometry? + ) { + let available = NSScreen.screens.map { screen in + SessionDisplayGeometry( + displayID: screen.programaDisplayID, + frame: screen.frame, + visibleFrame: screen.visibleFrame + ) } - current = candidate.superview + let fallback = (NSScreen.main ?? NSScreen.screens.first).map { screen in + SessionDisplayGeometry( + displayID: screen.programaDisplayID, + frame: screen.frame, + visibleFrame: screen.visibleFrame + ) + } + return (available, fallback) } - return nil -} + private func attemptStartupSessionRestoreIfNeeded(primaryWindow: NSWindow) { + guard !didAttemptStartupSessionRestore else { return } + didAttemptStartupSessionRestore = true + guard !didHandleExplicitOpenIntentAtStartup else { return } + guard let primaryContext = contextForMainTerminalWindow(primaryWindow) else { return } + let startupSnapshot = startupSessionSnapshot + let primaryWindowSnapshot = startupSnapshot?.windows.first + if let primaryWindowSnapshot { + isApplyingStartupSessionRestore = true #if DEBUG -func browserZoomShortcutTraceCandidate( - flags: NSEvent.ModifierFlags, - chars: String, - keyCode: UInt16, - literalChars: String? = nil -) -> Bool { - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - guard normalizedFlags.contains(.command) else { return false } - - let keys = browserZoomShortcutKeyCandidates( - chars: chars, - literalChars: literalChars, - keyCode: keyCode - ) - if keys.contains("=") || keys.contains("+") || keys.contains("-") || keys.contains("_") || keys.contains("0") { - return true - } - switch keyCode { - case 24, 27, 29, 69, 78, 82: // ANSI and keypad zoom keys - return true - default: - return false - } -} - -func browserZoomShortcutTraceFlagsString(_ flags: NSEvent.ModifierFlags) -> String { - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - var parts: [String] = [] - if normalizedFlags.contains(.command) { parts.append("Cmd") } - if normalizedFlags.contains(.shift) { parts.append("Shift") } - if normalizedFlags.contains(.option) { parts.append("Opt") } - if normalizedFlags.contains(.control) { parts.append("Ctrl") } - return parts.isEmpty ? "none" : parts.joined(separator: "+") -} - -func browserZoomShortcutTraceActionString(_ action: BrowserZoomShortcutAction?) -> String { - guard let action else { return "none" } - switch action { - case .zoomIn: return "zoomIn" - case .zoomOut: return "zoomOut" - case .reset: return "reset" - } -} + dlog( + "session.restore.start windows=\(startupSnapshot?.windows.count ?? 0) " + + "primaryFrame={\(debugSessionRectDescription(primaryWindowSnapshot.frame))} " + + "primaryDisplay={\(debugSessionDisplayDescription(primaryWindowSnapshot.display))}" + ) #endif + applySessionWindowSnapshot( + primaryWindowSnapshot, + to: primaryContext, + window: primaryWindow + ) + } else { + let displays = currentDisplayGeometries() + let fallbackGeometry = persistedWindowGeometry() + if let restoredFrame = Self.resolvedStartupPrimaryWindowFrame( + primarySnapshot: nil, + fallbackFrame: fallbackGeometry?.frame, + fallbackDisplaySnapshot: fallbackGeometry?.display, + availableDisplays: displays.available, + fallbackDisplay: displays.fallback + ) { + primaryWindow.setFrame(restoredFrame, display: true) + } + } -func shouldSuppressWindowMoveForFolderDrag(hitView: NSView?) -> Bool { - var candidate = hitView - while let view = candidate { - if view is DraggableFolderNSView { - return true + if let startupSnapshot { + let additionalWindows = Array(startupSnapshot + .windows + .dropFirst() + .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1))) +#if DEBUG + for (index, windowSnapshot) in additionalWindows.enumerated() { + dlog( + "session.restore.enqueueAdditional idx=\(index + 1) " + + "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + + "display={\(debugSessionDisplayDescription(windowSnapshot.display))}" + ) + } +#endif + if !additionalWindows.isEmpty { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + for windowSnapshot in additionalWindows { + _ = self.createMainWindow(sessionWindowSnapshot: windowSnapshot) + } + self.completeStartupSessionRestore() + } + } else { + completeStartupSessionRestore() + } } - candidate = view.superview } - return false -} -func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) -> Bool { - guard event.type == .leftMouseDown, - window.isMovable, - let contentView = window.contentView else { - return false + private func completeStartupSessionRestore() { + startupSessionSnapshot = nil + isApplyingStartupSessionRestore = false + _ = saveSessionSnapshot(includeScrollback: false) } - let contentPoint = contentView.convert(event.locationInWindow, from: nil) - let hitView = contentView.hitTest(contentPoint) - return shouldSuppressWindowMoveForFolderDrag(hitView: hitView) -} - -@MainActor -final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation { - nonisolated(unsafe) static var shared: AppDelegate? - - private static let cachedIsRunningUnderXCTest = detectRunningUnderXCTest(ProcessInfo.processInfo.environment) - - private var isRunningUnderXCTestCached: Bool { - Self.cachedIsRunningUnderXCTest - } + private func applySessionWindowSnapshot( + _ snapshot: SessionWindowSnapshot, + to context: MainWindowContext, + window: NSWindow? + ) { +#if DEBUG + dlog( + "session.restore.apply window=\(context.windowId.uuidString.prefix(8)) " + + "liveWin=\(window?.windowNumber ?? -1) " + + "snapshotFrame={\(debugSessionRectDescription(snapshot.frame))} " + + "snapshotDisplay={\(debugSessionDisplayDescription(snapshot.display))}" + ) +#endif + context.tabManager.restoreSessionSnapshot(snapshot.tabManager) + context.sidebarState.isVisible = snapshot.sidebar.isVisible + context.sidebarState.persistedWidth = CGFloat( + SessionPersistencePolicy.sanitizedSidebarWidth(snapshot.sidebar.width) + ) + context.sidebarSelectionState.selection = snapshot.sidebar.selection.sidebarSelection - private static func detectRunningUnderXCTest(_ env: [String: String]) -> Bool { - if env["XCTestConfigurationFilePath"] != nil { return true } - if env["XCTestBundlePath"] != nil { return true } - if env["XCTestSessionIdentifier"] != nil { return true } - if env["XCInjectBundle"] != nil { return true } - if env["XCInjectBundleInto"] != nil { return true } - if env["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true { return true } - if env.keys.contains(where: { $0.hasPrefix("PROGRAMA_UI_TEST_") }) { return true } - return false + if let restoredFrame = resolvedWindowFrame(from: snapshot), let window { + window.setFrame(restoredFrame, display: true) +#if DEBUG + dlog( + "session.restore.frameApplied window=\(context.windowId.uuidString.prefix(8)) " + + "applied={\(debugNSRectDescription(window.frame))}" + ) +#endif + } } - private func isRunningUnderXCTest(_ env: [String: String]) -> Bool { - // On some macOS/Xcode setups, the app-under-test process doesn't get - // `XCTestConfigurationFilePath`. Use a broader set of signals so UI tests - // can reliably skip heavyweight startup work and bring up a window. - Self.detectRunningUnderXCTest(env) + private func resolvedWindowFrame(from snapshot: SessionWindowSnapshot?) -> NSRect? { + let displays = currentDisplayGeometries() + return Self.resolvedWindowFrame( + from: snapshot?.frame, + display: snapshot?.display, + availableDisplays: displays.available, + fallbackDisplay: displays.fallback + ) } - final class MainWindowContext { - let windowId: UUID - let tabManager: TabManager - let sidebarState: SidebarState - let sidebarSelectionState: SidebarSelectionState - weak var window: NSWindow? - - init( - windowId: UUID, - tabManager: TabManager, - sidebarState: SidebarState, - sidebarSelectionState: SidebarSelectionState, - window: NSWindow? + nonisolated static func resolvedStartupPrimaryWindowFrame( + primarySnapshot: SessionWindowSnapshot?, + fallbackFrame: SessionRectSnapshot?, + fallbackDisplaySnapshot: SessionDisplaySnapshot?, + availableDisplays: [SessionDisplayGeometry], + fallbackDisplay: SessionDisplayGeometry? + ) -> CGRect? { + if let primary = resolvedWindowFrame( + from: primarySnapshot?.frame, + display: primarySnapshot?.display, + availableDisplays: availableDisplays, + fallbackDisplay: fallbackDisplay ) { - self.windowId = windowId - self.tabManager = tabManager - self.sidebarState = sidebarState - self.sidebarSelectionState = sidebarSelectionState - self.window = window + return primary } - } - private final class MainWindowController: NSWindowController, NSWindowDelegate { - var onClose: (() -> Void)? + return resolvedWindowFrame( + from: fallbackFrame, + display: fallbackDisplaySnapshot, + availableDisplays: availableDisplays, + fallbackDisplay: fallbackDisplay + ) + } - func windowWillClose(_ notification: Notification) { - onClose?() + nonisolated static func resolvedWindowFrame( + from frameSnapshot: SessionRectSnapshot?, + display displaySnapshot: SessionDisplaySnapshot?, + availableDisplays: [SessionDisplayGeometry], + fallbackDisplay: SessionDisplayGeometry? + ) -> CGRect? { + guard let frameSnapshot else { return nil } + let frame = frameSnapshot.cgRect + guard frame.width.isFinite, + frame.height.isFinite, + frame.origin.x.isFinite, + frame.origin.y.isFinite else { + return nil } - } - struct ScriptableMainWindowState { - let windowId: UUID - let tabManager: TabManager - let window: NSWindow? - } + let minWidth = CGFloat(SessionPersistencePolicy.minimumWindowWidth) + let minHeight = CGFloat(SessionPersistencePolicy.minimumWindowHeight) + guard frame.width >= minWidth, + frame.height >= minHeight else { + return nil + } - struct SessionDisplayGeometry { - let displayID: UInt32? - let frame: CGRect - let visibleFrame: CGRect - } + guard !availableDisplays.isEmpty else { return frame } - struct PersistedWindowGeometry: Codable, Sendable { - let version: Int - let frame: SessionRectSnapshot - let display: SessionDisplaySnapshot? - } + if let targetDisplay = display(for: displaySnapshot, in: availableDisplays) { + if shouldPreserveExactFrame( + frame: frame, + displaySnapshot: displaySnapshot, + targetDisplay: targetDisplay + ) { + return frame + } + return resolvedWindowFrame( + frame: frame, + displaySnapshot: displaySnapshot, + targetDisplay: targetDisplay, + minWidth: minWidth, + minHeight: minHeight + ) + } - nonisolated static let persistedWindowGeometrySchemaVersion = 2 - private nonisolated static let persistedWindowGeometryDefaultsKey = "programa.session.lastWindowGeometry.v2" - private nonisolated static let legacyPersistedWindowGeometryDefaultsKeys = [ - "programa.session.lastWindowGeometry.v1" - ] - - weak var tabManager: TabManager? - weak var notificationStore: TerminalNotificationStore? - weak var sidebarState: SidebarState? - weak var fullscreenControlsViewModel: TitlebarControlsViewModel? - weak var sidebarSelectionState: SidebarSelectionState? - var shortcutLayoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:) - private var workspaceObserver: NSObjectProtocol? - private var lifecycleSnapshotObservers: [NSObjectProtocol] = [] - private var windowKeyObserver: NSObjectProtocol? - private var shortcutMonitor: Any? - private var shortcutDefaultsObserver: NSObjectProtocol? - private var menuBarVisibilityObserver: NSObjectProtocol? - private var splitButtonTooltipRefreshScheduled = false - private struct PendingConfiguredShortcutChord { - let firstStroke: ShortcutStroke - let windowNumber: Int? - } - private var pendingConfiguredShortcutChord: PendingConfiguredShortcutChord? - private var activeConfiguredShortcutChordPrefixForCurrentEvent: ShortcutStroke? - private var configuredShortcutChordActions: [KeyboardShortcutSettings.Action] = [] - private var ghosttyConfigObserver: NSObjectProtocol? - var ghosttyGotoSplitLeftShortcut: StoredShortcut? - var ghosttyGotoSplitRightShortcut: StoredShortcut? - var ghosttyGotoSplitUpShortcut: StoredShortcut? - var ghosttyGotoSplitDownShortcut: StoredShortcut? - private var browserAddressBarFocusedPanelId: UUID? - private var browserOmnibarRepeatStartWorkItem: DispatchWorkItem? - private var browserOmnibarRepeatTickWorkItem: DispatchWorkItem? - private var browserOmnibarRepeatKeyCode: UInt16? - private var browserOmnibarRepeatDelta: Int = 0 - private var browserAddressBarFocusObserver: NSObjectProtocol? - private var browserAddressBarBlurObserver: NSObjectProtocol? - private let updateController = UpdateController() - private lazy var titlebarAccessoryController = UpdateTitlebarAccessoryController(viewModel: updateViewModel) - private let windowDecorationsController = WindowDecorationsController() - private var menuBarExtraController: MenuBarExtraController? - private static let serviceErrorNoPath = NSString(string: String(localized: "error.clipboardFolderPath", defaultValue: "Could not load any folder path from the clipboard.")) - private static let didInstallWindowKeyEquivalentSwizzle: Void = { - let targetClass: AnyClass = NSWindow.self - let originalSelector = #selector(NSWindow.performKeyEquivalent(with:)) - let swizzledSelector = #selector(NSWindow.programa_performKeyEquivalent(with:)) - guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), - let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { - return + if let intersectingDisplay = availableDisplays.first(where: { $0.visibleFrame.intersects(frame) }) { + return clampFrame( + frame, + within: intersectingDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) } - method_exchangeImplementations(originalMethod, swizzledMethod) - }() - private static let didInstallWindowFirstResponderSwizzle: Void = { - let targetClass: AnyClass = NSWindow.self - let originalSelector = #selector(NSWindow.makeFirstResponder(_:)) - let swizzledSelector = #selector(NSWindow.programa_makeFirstResponder(_:)) - guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), - let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { - return + + guard let fallbackDisplay else { return frame } + if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { + return remappedFrame( + frame, + from: sourceReference, + to: fallbackDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) } - method_exchangeImplementations(originalMethod, swizzledMethod) - }() - private static let didInstallWindowSendEventSwizzle: Void = { - let targetClass: AnyClass = NSWindow.self - let originalSelector = #selector(NSWindow.sendEvent(_:)) - let swizzledSelector = #selector(NSWindow.programa_sendEvent(_:)) - guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), - let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { - return + + return centeredFrame( + frame, + in: fallbackDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) + } + + private nonisolated static func resolvedWindowFrame( + frame: CGRect, + displaySnapshot: SessionDisplaySnapshot?, + targetDisplay: SessionDisplayGeometry, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + if targetDisplay.visibleFrame.intersects(frame) { + // Preserve the user's exact frame when enough of the top of the window + // remains reachable on-screen; only clamp when the saved frame would + // reopen with an inaccessible titlebar/top strip. + if shouldPreserveAccessibleFrame( + frame: frame, + targetDisplay: targetDisplay + ) { + return frame + } + return clampFrame( + frame, + within: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) } - method_exchangeImplementations(originalMethod, swizzledMethod) - }() - private static let didInstallApplicationSendEventSwizzle: Void = { - let targetClass: AnyClass = NSApplication.self - let originalSelector = #selector(NSApplication.sendEvent(_:)) - let swizzledSelector = #selector(NSApplication.programa_applicationSendEvent(_:)) - guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), - let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { - return + + if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { + return remappedFrame( + frame, + from: sourceReference, + to: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) } - method_exchangeImplementations(originalMethod, swizzledMethod) - }() -#if DEBUG - var didSetupJumpUnreadUITest = false - var jumpUnreadFocusExpectation: (tabId: UUID, surfaceId: UUID)? - var jumpUnreadFocusObserver: NSObjectProtocol? - var didSetupTerminalCmdClickUITest = false - var didSetupGotoSplitUITest = false - var didSetupBonsplitTabDragUITest = false - var terminalCmdClickUITestPoller: DispatchSourceTimer? - var bonsplitTabDragUITestRecorder: DispatchSourceTimer? - var gotoSplitUITestRecorder: DispatchSourceTimer? - var gotoSplitUITestObservers: [NSObjectProtocol] = [] - var didSetupMultiWindowNotificationsUITest = false - var didSetupDisplayResolutionUITestDiagnostics = false - var displayResolutionUITestObservers: [NSObjectProtocol] = [] - private struct UITestRenderDiagnosticsSnapshot { - let panelId: UUID - let drawCount: Int - let presentCount: Int - let lastPresentTime: Double - let windowVisible: Bool - let appIsActive: Bool - let desiredFocus: Bool - let isFirstResponder: Bool + return centeredFrame( + frame, + in: targetDisplay.visibleFrame, + minWidth: minWidth, + minHeight: minHeight + ) } - var debugCloseMainWindowConfirmationHandler: ((NSWindow) -> Bool)? - var debugCreateMainWindowSourceIsNativeFullScreenOverride: Bool? - // Keep debug-only windows alive when tests intentionally inject key mismatches. - private var debugDetachedContextWindows: [NSWindow] = [] - private func childExitKeyboardProbePath() -> String? { - let env = ProcessInfo.processInfo.environment - guard env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] == "1", - let path = env["PROGRAMA_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"], - !path.isEmpty else { - return nil + private nonisolated static func shouldPreserveAccessibleFrame( + frame: CGRect, + targetDisplay: SessionDisplayGeometry, + minimumVisibleTopStripWidth: CGFloat = 120, + topStripHeight: CGFloat = 64, + minimumVisibleTopStripHeight: CGFloat = 24 + ) -> Bool { + let standardizedFrame = frame.standardized + guard standardizedFrame.width.isFinite, + standardizedFrame.height.isFinite, + standardizedFrame.width > 0, + standardizedFrame.height > 0, + standardizedFrame.intersects(targetDisplay.frame) else { + return false } - return path - } - private func childExitKeyboardProbeHex(_ value: String?) -> String { - guard let value else { return "" } - return value.unicodeScalars - .map { String(format: "%04X", $0.value) } - .joined(separator: ",") + let stripHeight = min(topStripHeight, standardizedFrame.height) + let topStrip = CGRect( + x: standardizedFrame.minX, + y: standardizedFrame.maxY - stripHeight, + width: standardizedFrame.width, + height: stripHeight + ) + let visibleTopStrip = topStrip.intersection(targetDisplay.visibleFrame) + guard !visibleTopStrip.isNull else { return false } + + let requiredWidth = min(minimumVisibleTopStripWidth, standardizedFrame.width) + let requiredHeight = min(minimumVisibleTopStripHeight, stripHeight) + return visibleTopStrip.width >= requiredWidth + && visibleTopStrip.height >= requiredHeight } - private func writeChildExitKeyboardProbe(_ updates: [String: String], increments: [String: Int] = [:]) { - guard let path = childExitKeyboardProbePath() else { return } - var payload: [String: String] = { - guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { - return [:] - } - return object - }() - for (key, by) in increments { - let current = Int(payload[key] ?? "") ?? 0 - payload[key] = String(current + by) + private nonisolated static func display( + for snapshot: SessionDisplaySnapshot?, + in displays: [SessionDisplayGeometry] + ) -> SessionDisplayGeometry? { + guard let snapshot else { return nil } + if let displayID = snapshot.displayID, + let exact = displays.first(where: { $0.displayID == displayID }) { + return exact } - for (key, value) in updates { - payload[key] = value + + guard let referenceRect = (snapshot.visibleFrame ?? snapshot.frame)?.cgRect else { + return nil } - guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } - try? data.write(to: URL(fileURLWithPath: path), options: .atomic) - } -#endif - var mainWindowContexts: [ObjectIdentifier: MainWindowContext] = [:] - private var mainWindowControllers: [MainWindowController] = [] + let overlaps = displays.map { display -> (display: SessionDisplayGeometry, area: CGFloat) in + (display, intersectionArea(referenceRect, display.visibleFrame)) + } + if let bestOverlap = overlaps.max(by: { $0.area < $1.area }), bestOverlap.area > 0 { + return bestOverlap.display + } - /// Tracks the cascade point for new windows, matching Ghostty's upstream algorithm. - /// Reset to `.zero` so the first window seeds the point from its own position. - private var lastCascadePoint = NSPoint.zero - private var startupSessionSnapshot: AppSessionSnapshot? - private var didPrepareStartupSessionSnapshot = false - private var didAttemptStartupSessionRestore = false - private var isApplyingStartupSessionRestore = false - private var sessionAutosaveTimer: DispatchSourceTimer? - private var sessionAutosaveTickInFlight = false - private var sessionAutosaveDeferredRetryPending = false - private let sessionPersistenceQueue = DispatchQueue( - label: "com.cmuxterm.app.sessionPersistence", - qos: .utility - ) - private nonisolated static let launchServicesRegistrationQueue = DispatchQueue( - label: "com.cmuxterm.app.launchServicesRegistration", - qos: .utility - ) - private nonisolated static func enqueueLaunchServicesRegistrationWork(_ work: @escaping @Sendable () -> Void) { - launchServicesRegistrationQueue.async(execute: work) + let referenceCenter = CGPoint(x: referenceRect.midX, y: referenceRect.midY) + return displays.min { lhs, rhs in + let lhsDistance = distanceSquared(lhs.visibleFrame, referenceCenter) + let rhsDistance = distanceSquared(rhs.visibleFrame, referenceCenter) + return lhsDistance < rhsDistance + } } - private var lastSessionAutosaveFingerprint: Int? - private var lastSessionAutosavePersistedAt: Date = .distantPast - private var lastTypingActivityAt: TimeInterval = 0 - private var didHandleExplicitOpenIntentAtStartup = false - private var isTerminatingApp = false - // Set to true when the user has already confirmed quit via the warning dialog, - // so applicationShouldTerminate does not show a second alert. - private var isQuitWarningConfirmed = false - private var didInstallLifecycleSnapshotObservers = false - private var didDisableSuddenTermination = false - private var commandPaletteVisibilityByWindowId: [UUID: Bool] = [:] - private var commandPalettePendingOpenByWindowId: [UUID: Bool] = [:] - private var commandPaletteRecentRequestAtByWindowId: [UUID: TimeInterval] = [:] - private var commandPaletteEscapeSuppressionByWindowId: Set = [] - private var commandPaletteEscapeSuppressionStartedAtByWindowId: [UUID: TimeInterval] = [:] - private var commandPaletteSelectionByWindowId: [UUID: Int] = [:] - 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 - } + private nonisolated static func remappedFrame( + _ frame: CGRect, + from sourceRect: CGRect, + to targetRect: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + let source = sourceRect.standardized + let target = targetRect.standardized + guard source.width.isFinite, + source.height.isFinite, + source.width > 1, + source.height > 1, + target.width.isFinite, + target.height.isFinite, + target.width > 0, + target.height > 0 else { + return centeredFrame(frame, in: targetRect, minWidth: minWidth, minHeight: minHeight) + } -#if DEBUG - private func pointerString(_ object: AnyObject?) -> String { - guard let object else { return "nil" } - return String(describing: Unmanaged.passUnretained(object).toOpaque()) - } + let relativeX = (frame.minX - source.minX) / source.width + let relativeY = (frame.minY - source.minY) / source.height + let relativeWidth = frame.width / source.width + let relativeHeight = frame.height / source.height - private func summarizeContextForWorkspaceRouting(_ context: MainWindowContext?) -> String { - guard let context else { return "nil" } - let window = context.window ?? windowForMainWindowId(context.windowId) - let windowNumber = window?.windowNumber ?? -1 - let key = window?.isKeyWindow == true ? 1 : 0 - let main = window?.isMainWindow == true ? 1 : 0 - let visible = window?.isVisible == true ? 1 : 0 - let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(8)) } ?? "nil" - return "wid=\(context.windowId.uuidString.prefix(8)) win=\(windowNumber) key=\(key) main=\(main) vis=\(visible) tabs=\(context.tabManager.tabs.count) sel=\(selected) tm=\(pointerString(context.tabManager))" + let remapped = CGRect( + x: target.minX + (relativeX * target.width), + y: target.minY + (relativeY * target.height), + width: target.width * relativeWidth, + height: target.height * relativeHeight + ) + return clampFrame(remapped, within: target, minWidth: minWidth, minHeight: minHeight) } - private func summarizeAllContextsForWorkspaceRouting() -> String { - guard !mainWindowContexts.isEmpty else { return "" } - return mainWindowContexts.values - .map { summarizeContextForWorkspaceRouting($0) } - .joined(separator: " | ") + private nonisolated static func centeredFrame( + _ frame: CGRect, + in visibleFrame: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + let centered = CGRect( + x: visibleFrame.midX - (frame.width / 2), + y: visibleFrame.midY - (frame.height / 2), + width: frame.width, + height: frame.height + ) + return clampFrame(centered, within: visibleFrame, minWidth: minWidth, minHeight: minHeight) } - private func logWorkspaceCreationRouting( - phase: String, - source: String, - reason: String, - event: NSEvent?, - chosenContext: MainWindowContext?, - workspaceId: UUID? = nil, - workingDirectory: String? = nil - ) { - let eventWindowNumber = event?.window?.windowNumber ?? -1 - let eventNumber = event?.windowNumber ?? -1 - let eventChars = event?.charactersIgnoringModifiers ?? "" - let eventKeyCode = event.map { String($0.keyCode) } ?? "nil" - let keyWindowNumber = NSApp.keyWindow?.windowNumber ?? -1 - let mainWindowNumber = NSApp.mainWindow?.windowNumber ?? -1 - let ws = workspaceId.map { String($0.uuidString.prefix(8)) } ?? "nil" - let wd = workingDirectory.map { String($0.prefix(120)) } ?? "-" - FocusLogStore.shared.append( - "cmdn.route phase=\(phase) src=\(source) reason=\(reason) eventWin=\(eventWindowNumber) eventNum=\(eventNumber) keyCode=\(eventKeyCode) chars=\(eventChars) keyWin=\(keyWindowNumber) mainWin=\(mainWindowNumber) activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(chosenContext))} ws=\(ws) wd=\(wd) contexts=[\(summarizeAllContextsForWorkspaceRouting())]" - ) + private nonisolated static func clampFrame( + _ frame: CGRect, + within visibleFrame: CGRect, + minWidth: CGFloat, + minHeight: CGFloat + ) -> CGRect { + guard visibleFrame.width.isFinite, + visibleFrame.height.isFinite, + visibleFrame.width > 0, + visibleFrame.height > 0 else { + return frame + } + + let maxWidth = max(visibleFrame.width, 1) + let maxHeight = max(visibleFrame.height, 1) + let widthFloor = min(minWidth, maxWidth) + let heightFloor = min(minHeight, maxHeight) + + let width = min(max(frame.width, widthFloor), maxWidth) + let height = min(max(frame.height, heightFloor), maxHeight) + let maxX = visibleFrame.maxX - width + let maxY = visibleFrame.maxY - height + let x = min(max(frame.minX, visibleFrame.minX), maxX) + let y = min(max(frame.minY, visibleFrame.minY), maxY) + + return CGRect(x: x, y: y, width: width, height: height) } -#endif - override init() { - super.init() - Self.shared = self + private nonisolated static func intersectionArea(_ lhs: CGRect, _ rhs: CGRect) -> CGFloat { + let intersection = lhs.intersection(rhs) + guard !intersection.isNull else { return 0 } + return max(0, intersection.width) * max(0, intersection.height) } - func application(_ application: NSApplication, open urls: [URL]) { - let directories = externalOpenDirectories(from: urls) - guard !directories.isEmpty else { return } + private nonisolated static func distanceSquared(_ rect: CGRect, _ point: CGPoint) -> CGFloat { + let dx = rect.midX - point.x + let dy = rect.midY - point.y + return (dx * dx) + (dy * dy) + } - prepareForExplicitOpenIntentAtStartup() - for directory in directories { - openWorkspaceForExternalDirectory( - workingDirectory: directory, - debugSource: "application.openURLs" - ) + private nonisolated static func shouldPreserveExactFrame( + frame: CGRect, + displaySnapshot: SessionDisplaySnapshot?, + targetDisplay: SessionDisplayGeometry + ) -> Bool { + guard let displaySnapshot else { return false } + guard let snapshotDisplayID = displaySnapshot.displayID, + let targetDisplayID = targetDisplay.displayID, + snapshotDisplayID == targetDisplayID else { + return false } + + let visibleMatches = displaySnapshot.visibleFrame.map { + rectApproximatelyEqual($0.cgRect, targetDisplay.visibleFrame) + } ?? false + let frameMatches = displaySnapshot.frame.map { + rectApproximatelyEqual($0.cgRect, targetDisplay.frame) + } ?? false + guard visibleMatches || frameMatches else { return false } + + return frame.width.isFinite + && frame.height.isFinite + && frame.origin.x.isFinite + && frame.origin.y.isFinite } - func applicationDidFinishLaunching(_ notification: Notification) { - let env = ProcessInfo.processInfo.environment - let isRunningUnderXCTest = isRunningUnderXCTest(env) + private nonisolated static func rectApproximatelyEqual( + _ lhs: CGRect, + _ rhs: CGRect, + tolerance: CGFloat = 1 + ) -> Bool { + let lhsStd = lhs.standardized + let rhsStd = rhs.standardized + return abs(lhsStd.origin.x - rhsStd.origin.x) <= tolerance + && abs(lhsStd.origin.y - rhsStd.origin.y) <= tolerance + && abs(lhsStd.size.width - rhsStd.size.width) <= tolerance + && abs(lhsStd.size.height - rhsStd.size.height) <= tolerance + } - DistributedNotificationCenter.default().addObserver( - self, - selector: #selector(handleThemesReloadNotification(_:)), - name: ProgramaThemeNotifications.reloadConfig, - object: nil, - suspensionBehavior: .deliverImmediately - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleReactGrabDidCopySelection(_:)), - name: .reactGrabDidCopySelection, - object: nil + private func displaySnapshot(for window: NSWindow?) -> SessionDisplaySnapshot? { + guard let window else { return nil } + let screen = window.screen + ?? NSScreen.screens.first(where: { $0.frame.intersects(window.frame) }) + guard let screen else { return nil } + + return SessionDisplaySnapshot( + displayID: screen.programaDisplayID, + frame: SessionRectSnapshot(screen.frame), + visibleFrame: SessionRectSnapshot(screen.visibleFrame) ) + } -#if DEBUG - // UI tests run on a shared VM user profile, so persisted shortcuts can drift and make - // key-equivalent routing flaky. Force defaults for deterministic tests. - if isRunningUnderXCTest { - KeyboardShortcutSettings.resetAll() - } -#endif + private func startSessionAutosaveTimerIfNeeded() { + guard sessionAutosaveTimer == nil else { return } + let env = ProcessInfo.processInfo.environment + guard !isRunningUnderXCTest(env) else { return } -#if DEBUG - writeUITestDiagnosticsIfNeeded(stage: "didFinishLaunching") - ProgramaMainRunLoopStallMonitor.shared.installIfNeeded() - ProgramaMainThreadTurnProfiler.shared.installIfNeeded() - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.writeUITestDiagnosticsIfNeeded(stage: "after1s") + let timer = DispatchSource.makeTimerSource(queue: .main) + let interval = SessionPersistencePolicy.autosaveInterval + timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1)) + timer.setEventHandler { [weak self] in + guard let self, + Self.shouldRunSessionAutosaveTick(isTerminatingApp: self.isTerminatingApp) else { + return + } + self.runSessionAutosaveTick(source: "timer") } -#endif - - let forceDuplicateLaunchObserver = env["PROGRAMA_UI_TEST_ENABLE_DUPLICATE_LAUNCH_OBSERVER"] == "1" + sessionAutosaveTimer = timer + timer.resume() + } - // UI tests frequently time out waiting for the main window if we do heavyweight - // LaunchServices registration / single-instance enforcement synchronously at startup. - // Skip these during XCTest (the app-under-test) so the window can appear quickly. - if !isRunningUnderXCTest { - DispatchQueue.main.async { [weak self] in + private func stopSessionAutosaveTimer() { + sessionAutosaveTimer?.cancel() + sessionAutosaveTimer = nil + sessionAutosaveTickInFlight = false + sessionAutosaveDeferredRetryPending = false + } + + private func installLifecycleSnapshotObserversIfNeeded() { + guard !didInstallLifecycleSnapshotObservers else { return } + didInstallLifecycleSnapshotObservers = true + + let workspaceCenter = NSWorkspace.shared.notificationCenter + let powerOffObserver = workspaceCenter.addObserver( + forName: NSWorkspace.willPowerOffNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in guard let self else { return } - self.scheduleLaunchServicesBundleRegistration() - self.enforceSingleInstance() - self.observeDuplicateLaunches() - } - } else if forceDuplicateLaunchObserver { - // Some UI regressions specifically exercise launch-observer behavior while still - // running under XCTest. Allow an explicit opt-in for those cases only. - DispatchQueue.main.async { [weak self] in - self?.observeDuplicateLaunches() + self.isTerminatingApp = true + _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) } } - NSWindow.allowsAutomaticWindowTabbing = false - disableNativeTabbingShortcut() - ensureApplicationIcon() - if !isRunningUnderXCTest { - configureUserNotifications() - installMenuBarVisibilityObserver() - syncMenuBarExtraVisibility() - updateController.startUpdaterIfNeeded() - } - titlebarAccessoryController.start() - windowDecorationsController.start() - installMainWindowKeyObserver() - refreshGhosttyGotoSplitShortcuts() - installGhosttyConfigObserver() - installWindowResponderSwizzles() - installBrowserAddressBarFocusObservers() - installShortcutMonitor() - installShortcutDefaultsObserver() - NSApp.servicesProvider = self -#if DEBUG - UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) - if env["PROGRAMA_UI_TEST_MODE"] == "1" { - let trigger = env["PROGRAMA_UI_TEST_TRIGGER_UPDATE_CHECK"] ?? "" - let feed = env["PROGRAMA_UI_TEST_FEED_URL"] ?? "" - UpdateLogStore.shared.append("ui test env: trigger=\(trigger) feed=\(feed)") - } - if env["PROGRAMA_UI_TEST_TRIGGER_UPDATE_CHECK"] == "1" { - UpdateLogStore.shared.append("ui test trigger update check detected") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + lifecycleSnapshotObservers.append(powerOffObserver) + + let sessionResignObserver = workspaceCenter.addObserver( + forName: NSWorkspace.sessionDidResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in guard let self else { return } - let windowIds = NSApp.windows.map { $0.identifier?.rawValue ?? "" } - UpdateLogStore.shared.append("ui test windows: count=\(NSApp.windows.count) ids=\(windowIds.joined(separator: ","))") - if UpdateTestSupport.performMockFeedCheckIfNeeded(on: self.updateController.viewModel) { - return + if self.isTerminatingApp { + _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + } else { + _ = self.saveSessionSnapshot(includeScrollback: false) } - self.checkForUpdates(nil) } } + lifecycleSnapshotObservers.append(sessionResignObserver) - // In UI tests, `WindowGroup` occasionally fails to materialize a window quickly on the VM. - // If there are no windows shortly after launch, force-create one so XCUITest can proceed. - if isRunningUnderXCTest { - if let rawShow = env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_SHOW"] { - UserDefaults.standard.set( - rawShow == "1", - forKey: BrowserImportHintSettings.showOnBlankTabsKey - ) - } - if let rawDismissed = env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_DISMISSED"] { - UserDefaults.standard.set( - rawDismissed == "1", - forKey: BrowserImportHintSettings.dismissedKey - ) - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - guard let self else { return } - if NSApp.windows.isEmpty { - self.openNewMainWindow(nil) - } - self.moveUITestWindowToTargetDisplayIfNeeded() - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) - // On headless CI runners, activate() silently fails (no GUI session). - // Force windows visible so the terminal surface starts rendering. - for window in NSApp.windows { - window.orderFrontRegardless() - } - self.writeUITestDiagnosticsIfNeeded(stage: "afterForceWindow") - } - if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] == "1" { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in - guard let self else { return } - _ = self.openBrowserAndFocusAddressBar(insertAtEnd: true) - } - } - if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_HINT_OPEN_SETTINGS"] == "1" { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) { [weak self] in - self?.openPreferencesWindow( - debugSource: "uiTest.browserImportHint", - navigationTarget: .browser - ) - } - } - if env["PROGRAMA_UI_TEST_BROWSER_IMPORT_AUTO_OPEN"] == "1" { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - BrowserDataImportCoordinator.shared.presentImportDialog() - } + let didWakeObserver = workspaceCenter.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.restartSocketListenerIfEnabled(source: "workspace.didWake") } } -#endif + lifecycleSnapshotObservers.append(didWakeObserver) } -#if DEBUG - func writeUITestDiagnosticsIfNeeded(stage: String) { - let env = ProcessInfo.processInfo.environment - guard let path = env["PROGRAMA_UI_TEST_DIAGNOSTICS_PATH"], !path.isEmpty else { return } - - var payload = loadUITestDiagnostics(at: path) - let isRunningUnderXCTest = isRunningUnderXCTest(env) - - let windows = NSApp.windows - let ids = windows.map { $0.identifier?.rawValue ?? "" }.joined(separator: ",") - let vis = windows.map { $0.isVisible ? "1" : "0" }.joined(separator: ",") - let screenIDs = windows.map { $0.screen?.programaDisplayID.map(String.init) ?? "" }.joined(separator: ",") - let targetDisplayID = env["PROGRAMA_UI_TEST_TARGET_DISPLAY_ID"] ?? "" + func socketListenerConfigurationIfEnabled() -> (mode: SocketControlMode, path: String)? { + let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) + ?? SocketControlSettings.defaultMode.rawValue + let userMode = SocketControlSettings.migrateMode(raw) + let mode = SocketControlSettings.effectiveMode(userMode: userMode) + guard mode != .off else { return nil } + return (mode: mode, path: SocketControlSettings.socketPath()) + } - payload["stage"] = stage - payload["pid"] = String(ProcessInfo.processInfo.processIdentifier) - payload["bundleId"] = Bundle.main.bundleIdentifier ?? "" - payload["isRunningUnderXCTest"] = isRunningUnderXCTest ? "1" : "0" - payload["windowsCount"] = String(windows.count) - payload["windowIdentifiers"] = ids - payload["windowVisibleFlags"] = vis - payload["windowScreenDisplayIDs"] = screenIDs - payload["uiTestTargetDisplayID"] = targetDisplayID - if let rawDisplayID = UInt32(targetDisplayID) { - let screenPresent = NSScreen.screens.contains(where: { $0.programaDisplayID == rawDisplayID }) - let movedWindow = windows.contains(where: { $0.screen?.programaDisplayID == rawDisplayID }) - payload["targetDisplayPresent"] = screenPresent ? "1" : "0" - payload["targetDisplayMoveSucceeded"] = movedWindow ? "1" : "0" - } - appendUITestRenderDiagnosticsIfNeeded(&payload, environment: env) - appendUITestSocketDiagnosticsIfNeeded(&payload, environment: env) + func restartSocketListenerIfEnabled(source: String) { + guard let tabManager, + let config = socketListenerConfigurationIfEnabled() else { return } + let restartPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) + TerminalController.shared.stop() + TerminalController.shared.start(tabManager: tabManager, socketPath: restartPath, accessMode: config.mode) + } - guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } - try? data.write(to: URL(fileURLWithPath: path), options: .atomic) + private func disableSuddenTerminationIfNeeded() { + guard !didDisableSuddenTermination else { return } + ProcessInfo.processInfo.disableSuddenTermination() + didDisableSuddenTermination = true } - private func loadUITestDiagnostics(at path: String) -> [String: String] { - guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { - return [:] - } - return object + private func enableSuddenTerminationIfNeeded() { + guard didDisableSuddenTermination else { return } + ProcessInfo.processInfo.enableSuddenTermination() + didDisableSuddenTermination = false } - private func appendUITestSocketDiagnosticsIfNeeded( - _ payload: inout [String: String], - environment env: [String: String] - ) { - guard env["PROGRAMA_UI_TEST_SOCKET_SANITY"] == "1" else { return } + private func sessionAutosaveFingerprint(includeScrollback: Bool) -> Int? { + guard !includeScrollback else { return nil } - guard let config = socketListenerConfigurationIfEnabled() else { - payload["socketExpectedPath"] = env["PROGRAMA_SOCKET_PATH"] ?? "" - payload["socketMode"] = "off" - payload["socketReady"] = "0" - payload["socketPingResponse"] = "" - payload["socketIsRunning"] = "0" - payload["socketAcceptLoopAlive"] = "0" - payload["socketPathMatches"] = "0" - payload["socketPathExists"] = "0" - payload["socketFailureSignals"] = "socket_disabled" - return + var hasher = Hasher() + let contexts = mainWindowContexts.values.sorted { lhs, rhs in + lhs.windowId.uuidString < rhs.windowId.uuidString } + hasher.combine(contexts.count) - let socketPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) - let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: socketPath) - let pingResponse = health.isHealthy - ? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) - : nil - let isReady = health.isHealthy && pingResponse == "PONG" - var failureSignals = health.failureSignals - if health.isHealthy && pingResponse != "PONG" { - failureSignals.append("ping_timeout") - } + for context in contexts.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) { + hasher.combine(context.windowId) + hasher.combine(context.tabManager.sessionAutosaveFingerprint()) + hasher.combine(context.sidebarState.isVisible) + hasher.combine( + Int(SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)).rounded()) + ) - payload["socketExpectedPath"] = socketPath - payload["socketMode"] = config.mode.rawValue - payload["socketReady"] = isReady ? "1" : "0" - payload["socketPingResponse"] = pingResponse ?? "" - payload["socketIsRunning"] = health.isRunning ? "1" : "0" - payload["socketAcceptLoopAlive"] = health.acceptLoopAlive ? "1" : "0" - payload["socketPathMatches"] = health.socketPathMatches ? "1" : "0" - payload["socketPathExists"] = health.socketPathExists ? "1" : "0" - payload["socketFailureSignals"] = failureSignals.joined(separator: ",") - } + switch context.sidebarSelectionState.selection { + case .tabs: + hasher.combine(0) + case .notifications: + hasher.combine(1) + } - private func appendUITestRenderDiagnosticsIfNeeded( - _ payload: inout [String: String], - environment env: [String: String] - ) { - guard env["PROGRAMA_UI_TEST_DISPLAY_RENDER_STATS"] == "1" else { return } - - guard let renderState = currentUITestRenderDiagnostics() else { - payload["renderStatsAvailable"] = "0" - payload["renderPanelId"] = "" - payload["renderDrawCount"] = "" - payload["renderPresentCount"] = "" - payload["renderLastPresentTime"] = "" - payload["renderWindowVisible"] = "" - payload["renderAppIsActive"] = "" - payload["renderDesiredFocus"] = "" - payload["renderIsFirstResponder"] = "" - payload["renderDiagnosticsUpdatedAt"] = String(format: "%.6f", ProcessInfo.processInfo.systemUptime) - return + if let window = context.window ?? windowForMainWindowId(context.windowId) { + Self.hashFrame(window.frame, into: &hasher) + } else { + hasher.combine(-1) + } } - payload["renderStatsAvailable"] = "1" - payload["renderPanelId"] = renderState.panelId.uuidString - payload["renderDrawCount"] = String(renderState.drawCount) - payload["renderPresentCount"] = String(renderState.presentCount) - payload["renderLastPresentTime"] = String(format: "%.6f", renderState.lastPresentTime) - payload["renderWindowVisible"] = renderState.windowVisible ? "1" : "0" - payload["renderAppIsActive"] = renderState.appIsActive ? "1" : "0" - payload["renderDesiredFocus"] = renderState.desiredFocus ? "1" : "0" - payload["renderIsFirstResponder"] = renderState.isFirstResponder ? "1" : "0" - payload["renderDiagnosticsUpdatedAt"] = String(format: "%.6f", ProcessInfo.processInfo.systemUptime) + return hasher.finalize() } - private func currentUITestRenderDiagnostics() -> UITestRenderDiagnosticsSnapshot? { - guard let tabManager, - let tabId = tabManager.selectedTabId, - let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { - return nil + @discardableResult + private func saveSessionSnapshot(includeScrollback: Bool, removeWhenEmpty: Bool = false) -> Bool { + if Self.shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: isApplyingStartupSessionRestore, + includeScrollback: includeScrollback + ) { +#if DEBUG + dlog("session.save.skipped reason=startup_restore_in_progress includeScrollback=0") +#endif + return false } - let terminalPanel: TerminalPanel? = { - if let focusedPanelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: focusedPanelId) { - return terminalPanel - } - if let focusedTerminalPanel = workspace.focusedTerminalPanel { - return focusedTerminalPanel - } - return workspace.panels.values.compactMap { $0 as? TerminalPanel }.first - }() - - guard let terminalPanel else { return nil } - let stats = terminalPanel.hostedView.debugRenderStats() - return UITestRenderDiagnosticsSnapshot( - panelId: terminalPanel.id, - drawCount: stats.drawCount, - presentCount: stats.presentCount, - lastPresentTime: stats.lastPresentTime, - windowVisible: stats.windowOcclusionVisible, - appIsActive: stats.appIsActive, - desiredFocus: stats.desiredFocus, - isFirstResponder: stats.isFirstResponder + let writeSynchronously = Self.shouldWriteSessionSnapshotSynchronously( + isTerminatingApp: isTerminatingApp, + includeScrollback: includeScrollback ) - } - - private func moveUITestWindowToTargetDisplayIfNeeded(attempt: Int = 0) { - let env = ProcessInfo.processInfo.environment - guard let rawDisplayID = env["PROGRAMA_UI_TEST_TARGET_DISPLAY_ID"], - let targetDisplayID = UInt32(rawDisplayID) else { - return +#if DEBUG + let timingStart = ProgramaTypingTiming.start() + defer { + ProgramaTypingTiming.logDuration( + path: "session.saveSnapshot", + startedAt: timingStart, + extra: "includeScrollback=\(includeScrollback ? 1 : 0) removeWhenEmpty=\(removeWhenEmpty ? 1 : 0) sync=\(writeSynchronously ? 1 : 0)" + ) } +#endif - guard let screen = NSScreen.screens.first(where: { $0.programaDisplayID == targetDisplayID }) else { - if attempt < 20 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) - } - } - self.writeUITestDiagnosticsIfNeeded(stage: "targetDisplayMissing") - return + guard let snapshot = buildSessionSnapshot(includeScrollback: includeScrollback) else { + persistSessionSnapshot( + nil, + removeWhenEmpty: removeWhenEmpty, + persistedGeometryData: nil, + synchronously: writeSynchronously + ) + return false } - guard let window = NSApp.windows.first else { - if attempt < 20 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) - } - } - self.writeUITestDiagnosticsIfNeeded(stage: "targetDisplayNoWindow") - return + let persistedGeometryData = snapshot.windows.first.flatMap { primaryWindow in + Self.encodedPersistedWindowGeometryData( + frame: primaryWindow.frame, + display: primaryWindow.display + ) } - let visibleFrame = screen.visibleFrame - let width = min(window.frame.width, max(visibleFrame.width - 80, 480)) - let height = min(window.frame.height, max(visibleFrame.height - 80, 360)) - let frame = NSRect( - x: visibleFrame.midX - (width / 2), - y: visibleFrame.midY - (height / 2), - width: width, - height: height - ).integral +#if DEBUG + debugLogSessionSaveSnapshot(snapshot, includeScrollback: includeScrollback) +#endif + persistSessionSnapshot( + snapshot, + removeWhenEmpty: false, + persistedGeometryData: persistedGeometryData, + synchronously: writeSynchronously + ) + return true + } - window.setFrame(frame, display: true, animate: false) - window.makeKeyAndOrderFront(nil) - window.orderFrontRegardless() - if window.screen?.programaDisplayID != targetDisplayID, attempt < 20 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - self?.moveUITestWindowToTargetDisplayIfNeeded(attempt: attempt + 1) - } - return - } - self.writeUITestDiagnosticsIfNeeded(stage: "afterMoveToTargetDisplay") + nonisolated static func shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: Bool) -> Bool { + !isTerminatingApp } -#endif - func applicationDidBecomeActive(_ notification: Notification) { - guard let notificationStore else { return } - notificationStore.handleApplicationDidBecomeActive() - guard let tabManager else { return } - guard let tabId = tabManager.selectedTabId else { return } - let surfaceId = tabManager.focusedSurfaceId(for: tabId) - guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return } + nonisolated static func shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister( + isTerminatingApp: Bool + ) -> Bool { + !isTerminatingApp + } - if let surfaceId, - let tab = tabManager.tabs.first(where: { $0.id == tabId }) { - tab.triggerNotificationFocusFlash(panelId: surfaceId, requiresSplit: false, shouldFocus: false) - } - notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) + nonisolated static func shouldSkipSessionSaveDuringStartupRestore( + isApplyingStartupSessionRestore: Bool, + includeScrollback: Bool + ) -> Bool { + isApplyingStartupSessionRestore && !includeScrollback } - func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - isTerminatingApp = true - _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) + nonisolated static func shouldRunSessionAutosaveTick(isTerminatingApp: Bool) -> Bool { + !isTerminatingApp + } - // Tagged DEV builds are ephemeral, skip quit confirmation entirely. - if SocketControlSettings.isTaggedDevBuild() { - return .terminateNow + 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") + } } + } - // If the user already confirmed via the Cmd+Q shortcut warning dialog - // (handleQuitShortcutWarning), skip the check to avoid a second alert. - if isQuitWarningConfirmed { - return .terminateNow + 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 } - // Respect the "Warn Before Quit" setting even when Cmd+Q arrives via - // the Cmd+Tab app switcher, bypassing handleCustomShortcut. - guard QuitWarningSettings.isEnabled() else { - return .terminateNow + sessionAutosaveTickInFlight = true +#if DEBUG + let timingStart = ProgramaTypingTiming.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 + ProgramaTypingTiming.logBreakdown( + path: "session.autosaveTick.phase", + totalMs: totalMs, + thresholdMs: 2.0, + parts: [ + ("fingerprintMs", fingerprintMs), + ("saveMs", saveMs), + ], + extra: "source=\(source)" + ) + ProgramaTypingTiming.logDuration( + path: "session.autosaveTick", + startedAt: timingStart, + extra: "source=\(source)" + ) } +#else + defer { sessionAutosaveTickInFlight = false } +#endif - // Show the same confirmation dialog used by the Cmd+Q shortcut path, - // then reply asynchronously so we can return .terminateLater now. - DispatchQueue.main.async { - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String(localized: "dialog.quitPrograma.title", defaultValue: "Quit Programa?") - alert.informativeText = String(localized: "dialog.quitPrograma.message", defaultValue: "This will close all windows and workspaces.") - alert.addButton(withTitle: String(localized: "dialog.quitPrograma.quit", defaultValue: "Quit")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - alert.showsSuppressionButton = true - alert.suppressionButton?.title = String(localized: "dialog.dontWarnCmdQ", defaultValue: "Don't warn again for Cmd+Q") - - let response = alert.runModal() - if alert.suppressionButton?.state == .on { - QuitWarningSettings.setEnabled(false) - } - - let shouldQuit = response == .alertFirstButtonReturn - if shouldQuit { - self.isQuitWarningConfirmed = true - } else { - // Reset so that the next quit attempt can show the dialog again. - self.isTerminatingApp = false - } - NSApp.reply(toApplicationShouldTerminate: shouldQuit) + 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 } - return .terminateLater - } - - func applicationWillTerminate(_ notification: Notification) { - isTerminatingApp = true - _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) - stopSessionAutosaveTimer() - TerminalController.shared.stop() - VSCodeServeWebController.shared.stop() - BrowserProfileStore.shared.flushPendingSaves() - notificationStore?.clearAll() - SurfacePool.shared.teardownAll() - enableSuddenTerminationIfNeeded() - } - func applicationWillResignActive(_ notification: Notification) { - guard !isTerminatingApp else { return } - clearConfiguredShortcutChordState() +#if DEBUG + let saveStart = ProcessInfo.processInfo.systemUptime +#endif _ = saveSessionSnapshot(includeScrollback: false) - } - - func persistSessionForUpdateRelaunch() { - isTerminatingApp = true - _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) - } - - func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore, sidebarState: SidebarState) { - self.tabManager = tabManager - self.notificationStore = notificationStore - self.sidebarState = sidebarState - disableSuddenTerminationIfNeeded() - installLifecycleSnapshotObserversIfNeeded() - prepareStartupSessionSnapshotIfNeeded() - startSessionAutosaveTimerIfNeeded() #if DEBUG - setupJumpUnreadUITestIfNeeded() - setupTerminalCmdClickUITestIfNeeded() - setupGotoSplitUITestIfNeeded() - setupBonsplitTabDragUITestIfNeeded() - setupMultiWindowNotificationsUITestIfNeeded() - setupDisplayResolutionUITestDiagnosticsIfNeeded() - - // UI tests sometimes don't run SwiftUI `.onAppear` soon enough (or at all) on the VM. - // The automation socket is a core testing primitive, so ensure it's started here when - // we detect XCTest, even if the main view lifecycle is flaky. - let env = ProcessInfo.processInfo.environment - if isRunningUnderXCTest(env) { - let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) - ?? SocketControlSettings.defaultMode.rawValue - let userMode = SocketControlSettings.migrateMode(raw) - let mode = SocketControlSettings.effectiveMode(userMode: userMode) - if mode != .off { - TerminalController.shared.start( - tabManager: tabManager, - socketPath: SocketControlSettings.socketPath(), - accessMode: mode - ) - scheduleUITestSocketSanityCheckIfNeeded() - } - } + saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0 #endif + updateSessionAutosaveSaveState( + includeScrollback: false, + persistedAt: now, + fingerprint: autosaveFingerprint + ) } - - private func prepareStartupSessionSnapshotIfNeeded() { - guard !didPrepareStartupSessionSnapshot else { return } - didPrepareStartupSessionSnapshot = true - guard SessionRestorePolicy.shouldAttemptRestore() else { return } - Self.removeLegacyPersistedWindowGeometry() - startupSessionSnapshot = SessionPersistenceStore.load() + // Widened from `fileprivate` to `internal`: NSWindow.programa_sendEvent(_:) (now in + // WindowSwizzles.swift) calls AppDelegate.shared?.recordTypingActivity() from a different file. Refs #95. + func recordTypingActivity() { + lastTypingActivityAt = ProcessInfo.processInfo.systemUptime } - private func persistedWindowGeometry( - defaults: UserDefaults = .standard - ) -> PersistedWindowGeometry? { - Self.removeLegacyPersistedWindowGeometry(defaults: defaults) - guard let data = defaults.data(forKey: Self.persistedWindowGeometryDefaultsKey) else { - return nil - } - guard let payload = Self.decodedPersistedWindowGeometryData(data) else { - defaults.removeObject(forKey: Self.persistedWindowGeometryDefaultsKey) - return nil - } - return payload + nonisolated static func shouldWriteSessionSnapshotSynchronously( + isTerminatingApp: Bool, + includeScrollback: Bool + ) -> Bool { + isTerminatingApp && includeScrollback } - private func persistWindowGeometry( - frame: SessionRectSnapshot?, - display: SessionDisplaySnapshot?, - defaults: UserDefaults = .standard - ) { - Self.removeLegacyPersistedWindowGeometry(defaults: defaults) - guard let data = Self.encodedPersistedWindowGeometryData(frame: frame, display: display) else { - return + nonisolated static func shouldSkipSessionAutosaveForUnchangedFingerprint( + isTerminatingApp: Bool, + includeScrollback: Bool, + previousFingerprint: Int?, + currentFingerprint: Int?, + lastPersistedAt: Date, + now: Date, + maximumAutosaveSkippableInterval: TimeInterval = 60 + ) -> Bool { + guard !isTerminatingApp, + !includeScrollback, + let previousFingerprint, + let currentFingerprint, + previousFingerprint == currentFingerprint else { + return false } - defaults.set(data, forKey: Self.persistedWindowGeometryDefaultsKey) - } - - private nonisolated static func encodedPersistedWindowGeometryData( - frame: SessionRectSnapshot?, - display: SessionDisplaySnapshot? - ) -> Data? { - guard let frame else { return nil } - let payload = PersistedWindowGeometry( - version: persistedWindowGeometrySchemaVersion, - frame: frame, - display: display - ) - return try? JSONEncoder().encode(payload) - } - nonisolated static func decodedPersistedWindowGeometryData(_ data: Data) -> PersistedWindowGeometry? { - guard let payload = try? JSONDecoder().decode(PersistedWindowGeometry.self, from: data), - payload.version == persistedWindowGeometrySchemaVersion else { - return nil - } - return payload + return now.timeIntervalSince(lastPersistedAt) < maximumAutosaveSkippableInterval } - private nonisolated static func removeLegacyPersistedWindowGeometry( - defaults: UserDefaults = .standard + private func updateSessionAutosaveSaveState( + includeScrollback: Bool, + persistedAt: Date, + fingerprint: Int? ) { - legacyPersistedWindowGeometryDefaultsKeys.forEach { defaults.removeObject(forKey: $0) } + guard !isTerminatingApp, !includeScrollback else { return } + lastSessionAutosaveFingerprint = fingerprint + lastSessionAutosavePersistedAt = persistedAt } - private func persistWindowGeometry(from window: NSWindow?) { - guard let window else { return } - persistWindowGeometry( - frame: SessionRectSnapshot(window.frame), - display: displaySnapshot(for: window) - ) + private nonisolated static func hashFrame(_ frame: NSRect, into hasher: inout Hasher) { + let standardized = frame.standardized + let quantized = [ + standardized.origin.x, + standardized.origin.y, + standardized.size.width, + standardized.size.height, + ].map { Int(($0 * 2).rounded()) } + quantized.forEach { hasher.combine($0) } } - private func currentDisplayGeometries() -> ( - available: [SessionDisplayGeometry], - fallback: SessionDisplayGeometry? + private func persistSessionSnapshot( + _ snapshot: AppSessionSnapshot?, + removeWhenEmpty: Bool, + persistedGeometryData: Data?, + synchronously: Bool ) { - let available = NSScreen.screens.map { screen in - SessionDisplayGeometry( - displayID: screen.programaDisplayID, - frame: screen.frame, - visibleFrame: screen.visibleFrame - ) + guard snapshot != nil || removeWhenEmpty || persistedGeometryData != nil else { return } + + let writeBlock = { + Self.removeLegacyPersistedWindowGeometry() + if let persistedGeometryData { + UserDefaults.standard.set( + persistedGeometryData, + forKey: Self.persistedWindowGeometryDefaultsKey + ) + } + if let snapshot { + _ = SessionPersistenceStore.save(snapshot) + } else if removeWhenEmpty { + SessionPersistenceStore.removeSnapshot() + } } - let fallback = (NSScreen.main ?? NSScreen.screens.first).map { screen in - SessionDisplayGeometry( - displayID: screen.programaDisplayID, - frame: screen.frame, - visibleFrame: screen.visibleFrame - ) + + if synchronously { + writeBlock() + } else { + sessionPersistenceQueue.async(execute: writeBlock) } - return (available, fallback) } - private func attemptStartupSessionRestoreIfNeeded(primaryWindow: NSWindow) { - guard !didAttemptStartupSessionRestore else { return } - didAttemptStartupSessionRestore = true - guard !didHandleExplicitOpenIntentAtStartup else { return } - guard let primaryContext = contextForMainTerminalWindow(primaryWindow) else { return } - - let startupSnapshot = startupSessionSnapshot - let primaryWindowSnapshot = startupSnapshot?.windows.first - if let primaryWindowSnapshot { - isApplyingStartupSessionRestore = true -#if DEBUG - dlog( - "session.restore.start windows=\(startupSnapshot?.windows.count ?? 0) " + - "primaryFrame={\(debugSessionRectDescription(primaryWindowSnapshot.frame))} " + - "primaryDisplay={\(debugSessionDisplayDescription(primaryWindowSnapshot.display))}" - ) -#endif - applySessionWindowSnapshot( - primaryWindowSnapshot, - to: primaryContext, - window: primaryWindow - ) - } else { - let displays = currentDisplayGeometries() - let fallbackGeometry = persistedWindowGeometry() - if let restoredFrame = Self.resolvedStartupPrimaryWindowFrame( - primarySnapshot: nil, - fallbackFrame: fallbackGeometry?.frame, - fallbackDisplaySnapshot: fallbackGeometry?.display, - availableDisplays: displays.available, - fallbackDisplay: displays.fallback - ) { - primaryWindow.setFrame(restoredFrame, display: true) + private func buildSessionSnapshot(includeScrollback: Bool) -> AppSessionSnapshot? { + let contexts = mainWindowContexts.values.sorted { lhs, rhs in + let lhsWindow = lhs.window ?? windowForMainWindowId(lhs.windowId) + let rhsWindow = rhs.window ?? windowForMainWindowId(rhs.windowId) + let lhsIsKey = lhsWindow?.isKeyWindow ?? false + let rhsIsKey = rhsWindow?.isKeyWindow ?? false + if lhsIsKey != rhsIsKey { + return lhsIsKey && !rhsIsKey } + return lhs.windowId.uuidString < rhs.windowId.uuidString } - if let startupSnapshot { - let additionalWindows = Array(startupSnapshot - .windows - .dropFirst() - .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1))) -#if DEBUG - for (index, windowSnapshot) in additionalWindows.enumerated() { - dlog( - "session.restore.enqueueAdditional idx=\(index + 1) " + - "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + - "display={\(debugSessionDisplayDescription(windowSnapshot.display))}" + guard !contexts.isEmpty else { return nil } + + let windows: [SessionWindowSnapshot] = contexts + .prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) + .map { context in + let window = context.window ?? windowForMainWindowId(context.windowId) + return SessionWindowSnapshot( + frame: window.map { SessionRectSnapshot($0.frame) }, + display: displaySnapshot(for: window), + tabManager: context.tabManager.sessionSnapshot(includeScrollback: includeScrollback), + sidebar: SessionSidebarSnapshot( + isVisible: context.sidebarState.isVisible, + selection: SessionSidebarSelection(selection: context.sidebarSelectionState.selection), + width: SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)) + ) ) } -#endif - if !additionalWindows.isEmpty { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - for windowSnapshot in additionalWindows { - _ = self.createMainWindow(sessionWindowSnapshot: windowSnapshot) - } - self.completeStartupSessionRestore() - } - } else { - completeStartupSessionRestore() - } - } - } - private func completeStartupSessionRestore() { - startupSessionSnapshot = nil - isApplyingStartupSessionRestore = false - _ = saveSessionSnapshot(includeScrollback: false) + guard !windows.isEmpty else { return nil } + return AppSessionSnapshot( + version: SessionSnapshotSchema.currentVersion, + createdAt: Date().timeIntervalSince1970, + windows: windows + ) } - private func applySessionWindowSnapshot( - _ snapshot: SessionWindowSnapshot, - to context: MainWindowContext, - window: NSWindow? - ) { #if DEBUG + private func debugLogSessionSaveSnapshot( + _ snapshot: AppSessionSnapshot, + includeScrollback: Bool + ) { dlog( - "session.restore.apply window=\(context.windowId.uuidString.prefix(8)) " + - "liveWin=\(window?.windowNumber ?? -1) " + - "snapshotFrame={\(debugSessionRectDescription(snapshot.frame))} " + - "snapshotDisplay={\(debugSessionDisplayDescription(snapshot.display))}" - ) -#endif - context.tabManager.restoreSessionSnapshot(snapshot.tabManager) - context.sidebarState.isVisible = snapshot.sidebar.isVisible - context.sidebarState.persistedWidth = CGFloat( - SessionPersistencePolicy.sanitizedSidebarWidth(snapshot.sidebar.width) + "session.save includeScrollback=\(includeScrollback ? 1 : 0) " + + "windows=\(snapshot.windows.count)" ) - context.sidebarSelectionState.selection = snapshot.sidebar.selection.sidebarSelection - - if let restoredFrame = resolvedWindowFrame(from: snapshot), let window { - window.setFrame(restoredFrame, display: true) -#if DEBUG + for (index, windowSnapshot) in snapshot.windows.enumerated() { + let workspaceCount = windowSnapshot.tabManager.workspaces.count + let selectedWorkspace = windowSnapshot.tabManager.selectedWorkspaceIndex.map(String.init) ?? "nil" dlog( - "session.restore.frameApplied window=\(context.windowId.uuidString.prefix(8)) " + - "applied={\(debugNSRectDescription(window.frame))}" + "session.save.window idx=\(index) " + + "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + + "display={\(debugSessionDisplayDescription(windowSnapshot.display))} " + + "workspaces=\(workspaceCount) selected=\(selectedWorkspace)" ) -#endif } } - private func resolvedWindowFrame(from snapshot: SessionWindowSnapshot?) -> NSRect? { - let displays = currentDisplayGeometries() - return Self.resolvedWindowFrame( - from: snapshot?.frame, - display: snapshot?.display, - availableDisplays: displays.available, - fallbackDisplay: displays.fallback - ) + private func debugSessionRectDescription(_ rect: SessionRectSnapshot?) -> String { + guard let rect else { return "nil" } + return "x=\(debugSessionNumber(rect.x)) y=\(debugSessionNumber(rect.y)) " + + "w=\(debugSessionNumber(rect.width)) h=\(debugSessionNumber(rect.height))" } - nonisolated static func resolvedStartupPrimaryWindowFrame( - primarySnapshot: SessionWindowSnapshot?, - fallbackFrame: SessionRectSnapshot?, - fallbackDisplaySnapshot: SessionDisplaySnapshot?, - availableDisplays: [SessionDisplayGeometry], - fallbackDisplay: SessionDisplayGeometry? - ) -> CGRect? { - if let primary = resolvedWindowFrame( - from: primarySnapshot?.frame, - display: primarySnapshot?.display, - availableDisplays: availableDisplays, - fallbackDisplay: fallbackDisplay - ) { - return primary - } + private func debugNSRectDescription(_ rect: NSRect?) -> String { + guard let rect else { return "nil" } + return "x=\(debugSessionNumber(Double(rect.origin.x))) " + + "y=\(debugSessionNumber(Double(rect.origin.y))) " + + "w=\(debugSessionNumber(Double(rect.size.width))) " + + "h=\(debugSessionNumber(Double(rect.size.height)))" + } - return resolvedWindowFrame( - from: fallbackFrame, - display: fallbackDisplaySnapshot, - availableDisplays: availableDisplays, - fallbackDisplay: fallbackDisplay - ) + private func debugSessionDisplayDescription(_ display: SessionDisplaySnapshot?) -> String { + guard let display else { return "nil" } + let displayIdText = display.displayID.map(String.init) ?? "nil" + return "id=\(displayIdText) " + + "frame={\(debugSessionRectDescription(display.frame))} " + + "visible={\(debugSessionRectDescription(display.visibleFrame))}" } - nonisolated static func resolvedWindowFrame( - from frameSnapshot: SessionRectSnapshot?, - display displaySnapshot: SessionDisplaySnapshot?, - availableDisplays: [SessionDisplayGeometry], - fallbackDisplay: SessionDisplayGeometry? - ) -> CGRect? { - guard let frameSnapshot else { return nil } - let frame = frameSnapshot.cgRect - guard frame.width.isFinite, - frame.height.isFinite, - frame.origin.x.isFinite, - frame.origin.y.isFinite else { - return nil - } + private func debugSessionNumber(_ value: Double) -> String { + String(format: "%.1f", value) + } +#endif - let minWidth = CGFloat(SessionPersistencePolicy.minimumWindowWidth) - let minHeight = CGFloat(SessionPersistencePolicy.minimumWindowHeight) - guard frame.width >= minWidth, - frame.height >= minHeight else { - return nil - } + private func notifyMainWindowContextsDidChange() { + NotificationCenter.default.post(name: .mainWindowContextsDidChange, object: self) + } - guard !availableDisplays.isEmpty else { return frame } + /// Register a terminal window with the AppDelegate so menu commands and socket control + /// can target whichever window is currently active. + func registerMainWindow( + _ window: NSWindow, + windowId: UUID, + tabManager: TabManager, + sidebarState: SidebarState, + sidebarSelectionState: SidebarSelectionState + ) { + tabManager.window = window - if let targetDisplay = display(for: displaySnapshot, in: availableDisplays) { - if shouldPreserveExactFrame( - frame: frame, - displaySnapshot: displaySnapshot, - targetDisplay: targetDisplay - ) { - return frame - } - return resolvedWindowFrame( - frame: frame, - displaySnapshot: displaySnapshot, - targetDisplay: targetDisplay, - minWidth: minWidth, - minHeight: minHeight + let key = ObjectIdentifier(window) + #if DEBUG + let priorManagerToken = debugManagerToken(self.tabManager) + #endif + if let existing = mainWindowContexts[key] { + existing.window = window + } else if let existing = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { + existing.window = window + reindexMainWindowContextIfNeeded(existing, for: window) + } else { + mainWindowContexts[key] = MainWindowContext( + windowId: windowId, + tabManager: tabManager, + sidebarState: sidebarState, + sidebarSelectionState: sidebarSelectionState, + window: window ) + NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self] note in + guard let self, let closing = note.object as? NSWindow else { return } + self.unregisterMainWindow(closing) + } } + commandPaletteVisibilityByWindowId[windowId] = false + commandPaletteSelectionByWindowId[windowId] = 0 + commandPaletteSnapshotByWindowId[windowId] = .empty - if let intersectingDisplay = availableDisplays.first(where: { $0.visibleFrame.intersects(frame) }) { - return clampFrame( - frame, - within: intersectingDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight - ) +#if DEBUG + dlog( + "mainWindow.register windowId=\(String(windowId.uuidString.prefix(8))) window={\(debugWindowToken(window))} manager=\(debugManagerToken(tabManager)) priorActiveMgr=\(priorManagerToken) \(debugShortcutRouteSnapshot())" + ) +#endif + notifyMainWindowContextsDidChange() + if window.isKeyWindow { + setActiveMainWindow(window) } - guard let fallbackDisplay else { return frame } - if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { - return remappedFrame( - frame, - from: sourceReference, - to: fallbackDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight - ) + attemptStartupSessionRestoreIfNeeded(primaryWindow: window) + if !isTerminatingApp { + _ = saveSessionSnapshot(includeScrollback: false) } + } - return centeredFrame( - frame, - in: fallbackDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight - ) + struct MainWindowSummary { + let windowId: UUID + let isKeyWindow: Bool + let isVisible: Bool + let workspaceCount: Int + let selectedWorkspaceId: UUID? } - private nonisolated static func resolvedWindowFrame( - frame: CGRect, - displaySnapshot: SessionDisplaySnapshot?, - targetDisplay: SessionDisplayGeometry, - minWidth: CGFloat, - minHeight: CGFloat - ) -> CGRect { - if targetDisplay.visibleFrame.intersects(frame) { - // Preserve the user's exact frame when enough of the top of the window - // remains reachable on-screen; only clamp when the saved frame would - // reopen with an inaccessible titlebar/top strip. - if shouldPreserveAccessibleFrame( - frame: frame, - targetDisplay: targetDisplay - ) { - return frame - } - return clampFrame( - frame, - within: targetDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight - ) + struct WindowMoveTarget: Identifiable { + let windowId: UUID + let label: String + let tabManager: TabManager + let isCurrentWindow: Bool + + var id: UUID { windowId } + } + + struct WorkspaceMoveTarget: Identifiable { + let windowId: UUID + let workspaceId: UUID + let windowLabel: String + let workspaceTitle: String + let tabManager: TabManager + let isCurrentWindow: Bool + + var id: String { "\(windowId.uuidString):\(workspaceId.uuidString)" } + var label: String { + isCurrentWindow ? workspaceTitle : "\(workspaceTitle) (\(windowLabel))" } + } - if let sourceReference = displaySnapshot?.visibleFrame?.cgRect ?? displaySnapshot?.frame?.cgRect { - return remappedFrame( - frame, - from: sourceReference, - to: targetDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight + func listMainWindowSummaries() -> [MainWindowSummary] { + let contexts = Array(mainWindowContexts.values) + return contexts.map { ctx in + let window = ctx.window ?? windowForMainWindowId(ctx.windowId) + return MainWindowSummary( + windowId: ctx.windowId, + isKeyWindow: window?.isKeyWindow ?? false, + isVisible: window?.isVisible ?? false, + workspaceCount: ctx.tabManager.tabs.count, + selectedWorkspaceId: ctx.tabManager.selectedTabId ) } - - return centeredFrame( - frame, - in: targetDisplay.visibleFrame, - minWidth: minWidth, - minHeight: minHeight - ) } - private nonisolated static func shouldPreserveAccessibleFrame( - frame: CGRect, - targetDisplay: SessionDisplayGeometry, - minimumVisibleTopStripWidth: CGFloat = 120, - topStripHeight: CGFloat = 64, - minimumVisibleTopStripHeight: CGFloat = 24 - ) -> Bool { - let standardizedFrame = frame.standardized - guard standardizedFrame.width.isFinite, - standardizedFrame.height.isFinite, - standardizedFrame.width > 0, - standardizedFrame.height > 0, - standardizedFrame.intersects(targetDisplay.frame) else { - return false + func windowMoveTargets(referenceWindowId: UUID?) -> [WindowMoveTarget] { + let orderedSummaries = orderedMainWindowSummaries(referenceWindowId: referenceWindowId) + let labels = windowLabelsById(orderedSummaries: orderedSummaries, referenceWindowId: referenceWindowId) + return orderedSummaries.compactMap { summary in + guard let manager = tabManagerFor(windowId: summary.windowId) else { return nil } + let label = labels[summary.windowId] ?? "Window" + return WindowMoveTarget( + windowId: summary.windowId, + label: label, + tabManager: manager, + isCurrentWindow: summary.windowId == referenceWindowId + ) } + } - let stripHeight = min(topStripHeight, standardizedFrame.height) - let topStrip = CGRect( - x: standardizedFrame.minX, - y: standardizedFrame.maxY - stripHeight, - width: standardizedFrame.width, - height: stripHeight - ) - let visibleTopStrip = topStrip.intersection(targetDisplay.visibleFrame) - guard !visibleTopStrip.isNull else { return false } + func workspaceMoveTargets(excludingWorkspaceId: UUID? = nil, referenceWindowId: UUID?) -> [WorkspaceMoveTarget] { + let orderedSummaries = orderedMainWindowSummaries(referenceWindowId: referenceWindowId) + let labels = windowLabelsById(orderedSummaries: orderedSummaries, referenceWindowId: referenceWindowId) - let requiredWidth = min(minimumVisibleTopStripWidth, standardizedFrame.width) - let requiredHeight = min(minimumVisibleTopStripHeight, stripHeight) - return visibleTopStrip.width >= requiredWidth - && visibleTopStrip.height >= requiredHeight - } + var targets: [WorkspaceMoveTarget] = [] + targets.reserveCapacity(orderedSummaries.reduce(0) { partial, summary in + partial + summary.workspaceCount + }) - private nonisolated static func display( - for snapshot: SessionDisplaySnapshot?, - in displays: [SessionDisplayGeometry] - ) -> SessionDisplayGeometry? { - guard let snapshot else { return nil } - if let displayID = snapshot.displayID, - let exact = displays.first(where: { $0.displayID == displayID }) { - return exact + for summary in orderedSummaries { + guard let manager = tabManagerFor(windowId: summary.windowId) else { continue } + let windowLabel = labels[summary.windowId] ?? "Window" + let isCurrentWindow = summary.windowId == referenceWindowId + for workspace in manager.tabs { + if workspace.id == excludingWorkspaceId { + continue + } + targets.append( + WorkspaceMoveTarget( + windowId: summary.windowId, + workspaceId: workspace.id, + windowLabel: windowLabel, + workspaceTitle: workspaceDisplayName(workspace), + tabManager: manager, + isCurrentWindow: isCurrentWindow + ) + ) + } } - guard let referenceRect = (snapshot.visibleFrame ?? snapshot.frame)?.cgRect else { - return nil - } + return targets + } - let overlaps = displays.map { display -> (display: SessionDisplayGeometry, area: CGFloat) in - (display, intersectionArea(referenceRect, display.visibleFrame)) + @discardableResult + func moveWorkspaceToWindow(workspaceId: UUID, windowId: UUID, focus: Bool = true) -> Bool { + guard let sourceManager = tabManagerFor(tabId: workspaceId), + let destinationManager = tabManagerFor(windowId: windowId) else { + return false } - if let bestOverlap = overlaps.max(by: { $0.area < $1.area }), bestOverlap.area > 0 { - return bestOverlap.display + + if sourceManager === destinationManager { + if focus { + destinationManager.focusTab(workspaceId, suppressFlash: true) + _ = focusMainWindow(windowId: windowId) + TerminalController.shared.setActiveTabManager(destinationManager) + } + return true } - let referenceCenter = CGPoint(x: referenceRect.midX, y: referenceRect.midY) - return displays.min { lhs, rhs in - let lhsDistance = distanceSquared(lhs.visibleFrame, referenceCenter) - let rhsDistance = distanceSquared(rhs.visibleFrame, referenceCenter) - return lhsDistance < rhsDistance + guard let workspace = sourceManager.detachWorkspace(tabId: workspaceId) else { return false } + destinationManager.attachWorkspace(workspace, select: focus) + + if focus { + _ = focusMainWindow(windowId: windowId) + TerminalController.shared.setActiveTabManager(destinationManager) } + return true } - private nonisolated static func remappedFrame( - _ frame: CGRect, - from sourceRect: CGRect, - to targetRect: CGRect, - minWidth: CGFloat, - minHeight: CGFloat - ) -> CGRect { - let source = sourceRect.standardized - let target = targetRect.standardized - guard source.width.isFinite, - source.height.isFinite, - source.width > 1, - source.height > 1, - target.width.isFinite, - target.height.isFinite, - target.width > 0, - target.height > 0 else { - return centeredFrame(frame, in: targetRect, minWidth: minWidth, minHeight: minHeight) - } + @discardableResult + func moveWorkspaceToNewWindow(workspaceId: UUID, focus: Bool = true) -> UUID? { + let windowId = createMainWindow() + guard let destinationManager = tabManagerFor(windowId: windowId) else { return nil } + let bootstrapWorkspaceId = destinationManager.tabs.first?.id - let relativeX = (frame.minX - source.minX) / source.width - let relativeY = (frame.minY - source.minY) / source.height - let relativeWidth = frame.width / source.width - let relativeHeight = frame.height / source.height + guard moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: focus) else { + _ = closeMainWindow(windowId: windowId) + return nil + } - let remapped = CGRect( - x: target.minX + (relativeX * target.width), - y: target.minY + (relativeY * target.height), - width: target.width * relativeWidth, - height: target.height * relativeHeight - ) - return clampFrame(remapped, within: target, minWidth: minWidth, minHeight: minHeight) + // Remove the bootstrap workspace from the new window once the moved workspace arrives. + if let bootstrapWorkspaceId, + bootstrapWorkspaceId != workspaceId, + let bootstrapWorkspace = destinationManager.tabs.first(where: { $0.id == bootstrapWorkspaceId }), + destinationManager.tabs.count > 1 { + destinationManager.closeWorkspace(bootstrapWorkspace) + } + return windowId } - private nonisolated static func centeredFrame( - _ frame: CGRect, - in visibleFrame: CGRect, - minWidth: CGFloat, - minHeight: CGFloat - ) -> CGRect { - let centered = CGRect( - x: visibleFrame.midX - (frame.width / 2), - y: visibleFrame.midY - (frame.height / 2), - width: frame.width, - height: frame.height - ) - return clampFrame(centered, within: visibleFrame, minWidth: minWidth, minHeight: minHeight) - } - - private nonisolated static func clampFrame( - _ frame: CGRect, - within visibleFrame: CGRect, - minWidth: CGFloat, - minHeight: CGFloat - ) -> CGRect { - guard visibleFrame.width.isFinite, - visibleFrame.height.isFinite, - visibleFrame.width > 0, - visibleFrame.height > 0 else { - return frame + func locateBonsplitSurface(tabId: UUID) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { + let bonsplitTabId = TabID(uuid: tabId) + for context in mainWindowContexts.values { + for workspace in context.tabManager.tabs { + if let panelId = workspace.panelIdFromSurfaceId(bonsplitTabId) { + return (context.windowId, workspace.id, panelId, context.tabManager) + } + } } - - let maxWidth = max(visibleFrame.width, 1) - let maxHeight = max(visibleFrame.height, 1) - let widthFloor = min(minWidth, maxWidth) - let heightFloor = min(minHeight, maxHeight) - - let width = min(max(frame.width, widthFloor), maxWidth) - let height = min(max(frame.height, heightFloor), maxHeight) - let maxX = visibleFrame.maxX - width - let maxY = visibleFrame.maxY - height - let x = min(max(frame.minX, visibleFrame.minX), maxX) - let y = min(max(frame.minY, visibleFrame.minY), maxY) - - return CGRect(x: x, y: y, width: width, height: height) - } - - private nonisolated static func intersectionArea(_ lhs: CGRect, _ rhs: CGRect) -> CGFloat { - let intersection = lhs.intersection(rhs) - guard !intersection.isNull else { return 0 } - return max(0, intersection.width) * max(0, intersection.height) - } - - private nonisolated static func distanceSquared(_ rect: CGRect, _ point: CGPoint) -> CGFloat { - let dx = rect.midX - point.x - let dy = rect.midY - point.y - return (dx * dx) + (dy * dy) + return nil } - private nonisolated static func shouldPreserveExactFrame( - frame: CGRect, - displaySnapshot: SessionDisplaySnapshot?, - targetDisplay: SessionDisplayGeometry + @discardableResult + func moveSurface( + panelId: UUID, + toWorkspace targetWorkspaceId: UUID, + targetPane: PaneID? = nil, + targetIndex: Int? = nil, + splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? = nil, + focus: Bool = true, + focusWindow: Bool = true ) -> Bool { - guard let displaySnapshot else { return false } - guard let snapshotDisplayID = displaySnapshot.displayID, - let targetDisplayID = targetDisplay.displayID, - snapshotDisplayID == targetDisplayID else { - return false +#if DEBUG + let moveStart = ProcessInfo.processInfo.systemUptime + let splitLabel = splitTarget.map { split in + "\(split.orientation.rawValue):\(split.insertFirst ? 1 : 0)" + } ?? "none" + func elapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) } - - let visibleMatches = displaySnapshot.visibleFrame.map { - rectApproximatelyEqual($0.cgRect, targetDisplay.visibleFrame) - } ?? false - let frameMatches = displaySnapshot.frame.map { - rectApproximatelyEqual($0.cgRect, targetDisplay.frame) - } ?? false - guard visibleMatches || frameMatches else { return false } - - return frame.width.isFinite - && frame.height.isFinite - && frame.origin.x.isFinite - && frame.origin.y.isFinite - } - - private nonisolated static func rectApproximatelyEqual( - _ lhs: CGRect, - _ rhs: CGRect, - tolerance: CGFloat = 1 - ) -> Bool { - let lhsStd = lhs.standardized - let rhsStd = rhs.standardized - return abs(lhsStd.origin.x - rhsStd.origin.x) <= tolerance - && abs(lhsStd.origin.y - rhsStd.origin.y) <= tolerance - && abs(lhsStd.size.width - rhsStd.size.width) <= tolerance - && abs(lhsStd.size.height - rhsStd.size.height) <= tolerance - } - - private func displaySnapshot(for window: NSWindow?) -> SessionDisplaySnapshot? { - guard let window else { return nil } - let screen = window.screen - ?? NSScreen.screens.first(where: { $0.frame.intersects(window.frame) }) - guard let screen else { return nil } - - return SessionDisplaySnapshot( - displayID: screen.programaDisplayID, - frame: SessionRectSnapshot(screen.frame), - visibleFrame: SessionRectSnapshot(screen.visibleFrame) + dlog( + "surface.move.begin panel=\(panelId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + + "targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil") " + + "split=\(splitLabel) focus=\(focus ? 1 : 0) focusWindow=\(focusWindow ? 1 : 0)" ) - } - - private func startSessionAutosaveTimerIfNeeded() { - guard sessionAutosaveTimer == nil else { return } - let env = ProcessInfo.processInfo.environment - guard !isRunningUnderXCTest(env) else { return } - - let timer = DispatchSource.makeTimerSource(queue: .main) - let interval = SessionPersistencePolicy.autosaveInterval - timer.schedule(deadline: .now() + interval, repeating: interval, leeway: .seconds(1)) - timer.setEventHandler { [weak self] in - guard let self, - Self.shouldRunSessionAutosaveTick(isTerminatingApp: self.isTerminatingApp) else { - return - } - self.runSessionAutosaveTick(source: "timer") +#endif + guard let source = locateSurface(surfaceId: panelId) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourcePanelNotFound elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false } - sessionAutosaveTimer = timer - timer.resume() - } - - private func stopSessionAutosaveTimer() { - sessionAutosaveTimer?.cancel() - sessionAutosaveTimer = nil - sessionAutosaveTickInFlight = false - sessionAutosaveDeferredRetryPending = false - } + guard let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourceWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } + guard let destinationManager = tabManagerFor(tabId: targetWorkspaceId) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationManagerMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } + guard let destinationWorkspace = destinationManager.tabs.first(where: { $0.id == targetWorkspaceId }) else { +#if DEBUG + dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") +#endif + return false + } +#if DEBUG + dlog( + "surface.move.route panel=\(panelId.uuidString.prefix(5)) sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) " + + "sourceWin=\(source.windowId.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " + + "sameWorkspace=\(destinationWorkspace.id == sourceWorkspace.id ? 1 : 0)" + ) +#endif - private func installLifecycleSnapshotObserversIfNeeded() { - guard !didInstallLifecycleSnapshotObservers else { return } - didInstallLifecycleSnapshotObservers = true + let resolvedTargetPane = targetPane.flatMap { pane in + destinationWorkspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) + } ?? destinationWorkspace.bonsplitController.focusedPaneId + ?? destinationWorkspace.bonsplitController.allPaneIds.first - let workspaceCenter = NSWorkspace.shared.notificationCenter - let powerOffObserver = workspaceCenter.addObserver( - forName: NSWorkspace.willPowerOffNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - guard let self else { return } - self.isTerminatingApp = true - _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) - } + guard let resolvedTargetPane else { +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=targetPaneMissing " + + "destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false } - lifecycleSnapshotObservers.append(powerOffObserver) - let sessionResignObserver = workspaceCenter.addObserver( - forName: NSWorkspace.sessionDidResignActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - guard let self else { return } - if self.isTerminatingApp { - _ = self.saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) - } else { - _ = self.saveSessionSnapshot(includeScrollback: false) + if destinationWorkspace.id == sourceWorkspace.id { + if let splitTarget { + guard let sourceTabId = sourceWorkspace.surfaceIdFromPanelId(panelId), + sourceWorkspace.bonsplitController.splitPane( + resolvedTargetPane, + orientation: splitTarget.orientation, + movingTab: sourceTabId, + insertFirst: splitTarget.insertFirst + ) != nil else { +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sameWorkspaceSplitFailed " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) split=\(splitLabel) " + + "elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } + if focus { + source.tabManager.focusTab(sourceWorkspace.id, surfaceId: panelId, suppressFlash: true) } +#if DEBUG + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceSplit moved=1 " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return true } - } - lifecycleSnapshotObservers.append(sessionResignObserver) - let didWakeObserver = workspaceCenter.addObserver( - forName: NSWorkspace.didWakeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.restartSocketListenerIfEnabled(source: "workspace.didWake") - } - } - lifecycleSnapshotObservers.append(didWakeObserver) - } - - func socketListenerConfigurationIfEnabled() -> (mode: SocketControlMode, path: String)? { - let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) - ?? SocketControlSettings.defaultMode.rawValue - let userMode = SocketControlSettings.migrateMode(raw) - let mode = SocketControlSettings.effectiveMode(userMode: userMode) - guard mode != .off else { return nil } - return (mode: mode, path: SocketControlSettings.socketPath()) - } - - func restartSocketListenerIfEnabled(source: String) { - guard let tabManager, - let config = socketListenerConfigurationIfEnabled() else { return } - let restartPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) - TerminalController.shared.stop() - TerminalController.shared.start(tabManager: tabManager, socketPath: restartPath, accessMode: config.mode) - } - - private func disableSuddenTerminationIfNeeded() { - guard !didDisableSuddenTermination else { return } - ProcessInfo.processInfo.disableSuddenTermination() - didDisableSuddenTermination = true - } - - private func enableSuddenTerminationIfNeeded() { - guard didDisableSuddenTermination else { return } - ProcessInfo.processInfo.enableSuddenTermination() - didDisableSuddenTermination = false - } - - private func sessionAutosaveFingerprint(includeScrollback: Bool) -> Int? { - guard !includeScrollback else { return nil } - - var hasher = Hasher() - let contexts = mainWindowContexts.values.sorted { lhs, rhs in - lhs.windowId.uuidString < rhs.windowId.uuidString - } - hasher.combine(contexts.count) - - for context in contexts.prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) { - hasher.combine(context.windowId) - hasher.combine(context.tabManager.sessionAutosaveFingerprint()) - hasher.combine(context.sidebarState.isVisible) - hasher.combine( - Int(SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)).rounded()) + let moved = sourceWorkspace.moveSurface( + panelId: panelId, + toPane: resolvedTargetPane, + atIndex: targetIndex, + focus: focus ) - - switch context.sidebarSelectionState.selection { - case .tabs: - hasher.combine(0) - case .notifications: - hasher.combine(1) - } - - if let window = context.window ?? windowForMainWindowId(context.windowId) { - Self.hashFrame(window.frame, into: &hasher) - } else { - hasher.combine(-1) - } - } - - return hasher.finalize() - } - - @discardableResult - private func saveSessionSnapshot(includeScrollback: Bool, removeWhenEmpty: Bool = false) -> Bool { - if Self.shouldSkipSessionSaveDuringStartupRestore( - isApplyingStartupSessionRestore: isApplyingStartupSessionRestore, - includeScrollback: includeScrollback - ) { -#if DEBUG - dlog("session.save.skipped reason=startup_restore_in_progress includeScrollback=0") -#endif - return false - } - - let writeSynchronously = Self.shouldWriteSessionSnapshotSynchronously( - isTerminatingApp: isTerminatingApp, - includeScrollback: includeScrollback - ) #if DEBUG - let timingStart = ProgramaTypingTiming.start() - defer { - ProgramaTypingTiming.logDuration( - path: "session.saveSnapshot", - startedAt: timingStart, - extra: "includeScrollback=\(includeScrollback ? 1 : 0) removeWhenEmpty=\(removeWhenEmpty ? 1 : 0) sync=\(writeSynchronously ? 1 : 0)" + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceMove moved=\(moved ? 1 : 0) " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " + + "elapsedMs=\(elapsedMs(since: moveStart))" ) - } #endif - - guard let snapshot = buildSessionSnapshot(includeScrollback: includeScrollback) else { - persistSessionSnapshot( - nil, - removeWhenEmpty: removeWhenEmpty, - persistedGeometryData: nil, - synchronously: writeSynchronously - ) - return false - } - - let persistedGeometryData = snapshot.windows.first.flatMap { primaryWindow in - Self.encodedPersistedWindowGeometryData( - frame: primaryWindow.frame, - display: primaryWindow.display - ) + return moved } + let sourcePane = sourceWorkspace.paneId(forPanelId: panelId) + let sourceIndex = sourceWorkspace.indexInPane(forPanelId: panelId) #if DEBUG - debugLogSessionSaveSnapshot(snapshot, includeScrollback: includeScrollback) + let detachStart = ProcessInfo.processInfo.systemUptime #endif - persistSessionSnapshot( - snapshot, - removeWhenEmpty: false, - persistedGeometryData: persistedGeometryData, - synchronously: writeSynchronously - ) - return true - } - - nonisolated static func shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: Bool) -> Bool { - !isTerminatingApp - } - nonisolated static func shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister( - isTerminatingApp: Bool - ) -> Bool { - !isTerminatingApp - } - - nonisolated static func shouldSkipSessionSaveDuringStartupRestore( - isApplyingStartupSessionRestore: Bool, - includeScrollback: Bool - ) -> Bool { - isApplyingStartupSessionRestore && !includeScrollback - } - - nonisolated static func shouldRunSessionAutosaveTick(isTerminatingApp: Bool) -> Bool { - !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() { + guard let detached = sourceWorkspace.detachSurface(panelId: panelId) else { #if DEBUG dlog( - "session.save.skipped reason=typing_recent includeScrollback=0 source=\(source) " + - "retryMs=\(Int((remainingQuietPeriod * 1000).rounded()))" + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=detachFailed " + + "elapsedMs=\(elapsedMs(since: moveStart))" ) #endif - scheduleDeferredSessionAutosaveRetry(after: remainingQuietPeriod) - return - } - - sessionAutosaveTickInFlight = true -#if DEBUG - let timingStart = ProgramaTypingTiming.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 - ProgramaTypingTiming.logBreakdown( - path: "session.autosaveTick.phase", - totalMs: totalMs, - thresholdMs: 2.0, - parts: [ - ("fingerprintMs", fingerprintMs), - ("saveMs", saveMs), - ], - extra: "source=\(source)" - ) - ProgramaTypingTiming.logDuration( - path: "session.autosaveTick", - startedAt: timingStart, - extra: "source=\(source)" - ) + return false } -#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 + let detachMs = elapsedMs(since: detachStart) + let attachStart = ProcessInfo.processInfo.systemUptime #endif - if Self.shouldSkipSessionAutosaveForUnchangedFingerprint( - isTerminatingApp: isTerminatingApp, - includeScrollback: false, - previousFingerprint: lastSessionAutosaveFingerprint, - currentFingerprint: autosaveFingerprint, - lastPersistedAt: lastSessionAutosavePersistedAt, - now: now - ) { + guard destinationWorkspace.attachDetachedSurface( + detached, + inPane: resolvedTargetPane, + atIndex: targetIndex, + focus: focus + ) != nil else { + rollbackDetachedSurface( + detached, + to: sourceWorkspace, + sourcePane: sourcePane, + sourceIndex: sourceIndex, + focus: focus + ) #if DEBUG dlog( - "session.save.skipped reason=unchanged_autosave_fingerprint includeScrollback=0 source=\(source)" + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=attachFailed " + + "detachMs=\(detachMs) elapsedMs=\(elapsedMs(since: moveStart))" ) #endif - return + return false } - #if DEBUG - let saveStart = ProcessInfo.processInfo.systemUptime + let attachMs = elapsedMs(since: attachStart) + var splitMs = "0.00" #endif - _ = saveSessionSnapshot(includeScrollback: false) + + if let splitTarget { #if DEBUG - saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0 + let splitStart = ProcessInfo.processInfo.systemUptime #endif - updateSessionAutosaveSaveState( - includeScrollback: false, - persistedAt: now, - fingerprint: autosaveFingerprint + guard let movedTabId = destinationWorkspace.surfaceIdFromPanelId(panelId), + destinationWorkspace.bonsplitController.splitPane( + resolvedTargetPane, + orientation: splitTarget.orientation, + movingTab: movedTabId, + insertFirst: splitTarget.insertFirst + ) != nil else { + if let detachedFromDestination = destinationWorkspace.detachSurface(panelId: panelId) { + rollbackDetachedSurface( + detachedFromDestination, + to: sourceWorkspace, + sourcePane: sourcePane, + sourceIndex: sourceIndex, + focus: focus + ) + } +#if DEBUG + dlog( + "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=postAttachSplitFailed " + + "detachMs=\(detachMs) attachMs=\(attachMs) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return false + } +#if DEBUG + splitMs = elapsedMs(since: splitStart) +#endif + } + +#if DEBUG + let cleanupStart = ProcessInfo.processInfo.systemUptime +#endif + cleanupEmptySourceWorkspaceAfterSurfaceMove( + sourceWorkspace: sourceWorkspace, + sourceManager: source.tabManager, + sourceWindowId: source.windowId ) - } +#if DEBUG + let cleanupMs = elapsedMs(since: cleanupStart) + let focusStart = ProcessInfo.processInfo.systemUptime +#endif - fileprivate func recordTypingActivity() { - lastTypingActivityAt = ProcessInfo.processInfo.systemUptime - } + if focus { + let destinationWindowId = focusWindow ? windowId(for: destinationManager) : nil + if let destinationWindowId { + _ = focusMainWindow(windowId: destinationWindowId) + } + destinationManager.focusTab(targetWorkspaceId, surfaceId: panelId, suppressFlash: true) + if let destinationWindowId { + reassertCrossWindowSurfaceMoveFocusIfNeeded( + destinationWindowId: destinationWindowId, + sourceWindowId: source.windowId, + destinationWorkspaceId: targetWorkspaceId, + destinationPanelId: panelId, + destinationManager: destinationManager + ) + } + } +#if DEBUG + let focusMs = elapsedMs(since: focusStart) + dlog( + "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=crossWorkspace moved=1 " + + "sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " + + "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " + + "split=\(splitLabel) detachMs=\(detachMs) attachMs=\(attachMs) splitMs=\(splitMs) " + + "cleanupMs=\(cleanupMs) focusMs=\(focusMs) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif - nonisolated static func shouldWriteSessionSnapshotSynchronously( - isTerminatingApp: Bool, - includeScrollback: Bool - ) -> Bool { - isTerminatingApp && includeScrollback + return true } - nonisolated static func shouldSkipSessionAutosaveForUnchangedFingerprint( - isTerminatingApp: Bool, - includeScrollback: Bool, - previousFingerprint: Int?, - currentFingerprint: Int?, - lastPersistedAt: Date, - now: Date, - maximumAutosaveSkippableInterval: TimeInterval = 60 + @discardableResult + func moveBonsplitTab( + tabId: UUID, + toWorkspace targetWorkspaceId: UUID, + targetPane: PaneID? = nil, + targetIndex: Int? = nil, + splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? = nil, + focus: Bool = true, + focusWindow: Bool = true ) -> Bool { - guard !isTerminatingApp, - !includeScrollback, - let previousFingerprint, - let currentFingerprint, - previousFingerprint == currentFingerprint else { +#if DEBUG + let moveStart = ProcessInfo.processInfo.systemUptime + func elapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) + } + dlog( + "surface.moveBonsplit.begin tab=\(tabId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + + "targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil")" + ) +#endif + guard let located = locateBonsplitSurface(tabId: tabId) else { +#if DEBUG + dlog( + "surface.moveBonsplit.fail tab=\(tabId.uuidString.prefix(5)) reason=tabNotFound " + + "targetWs=\(targetWorkspaceId.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif return false } - - return now.timeIntervalSince(lastPersistedAt) < maximumAutosaveSkippableInterval +#if DEBUG + dlog( + "surface.moveBonsplit.located tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + + "sourceWs=\(located.workspaceId.uuidString.prefix(5)) sourceWin=\(located.windowId.uuidString.prefix(5))" + ) +#endif + let moved = moveSurface( + panelId: located.panelId, + toWorkspace: targetWorkspaceId, + targetPane: targetPane, + targetIndex: targetIndex, + splitTarget: splitTarget, + focus: focus, + focusWindow: focusWindow + ) +#if DEBUG + dlog( + "surface.moveBonsplit.end tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + + "moved=\(moved ? 1 : 0) elapsedMs=\(elapsedMs(since: moveStart))" + ) +#endif + return moved } - private func updateSessionAutosaveSaveState( - includeScrollback: Bool, - persistedAt: Date, - fingerprint: Int? - ) { - guard !isTerminatingApp, !includeScrollback else { return } - lastSessionAutosaveFingerprint = fingerprint - lastSessionAutosavePersistedAt = persistedAt + func tabManagerFor(windowId: UUID) -> TabManager? { + mainWindowContexts.values.first(where: { $0.windowId == windowId })?.tabManager } - private nonisolated static func hashFrame(_ frame: NSRect, into hasher: inout Hasher) { - let standardized = frame.standardized - let quantized = [ - standardized.origin.x, - standardized.origin.y, - standardized.size.width, - standardized.size.height, - ].map { Int(($0 * 2).rounded()) } - quantized.forEach { hasher.combine($0) } + func windowId(for tabManager: TabManager) -> UUID? { + mainWindowContexts.values.first(where: { $0.tabManager === tabManager })?.windowId } - private func persistSessionSnapshot( - _ snapshot: AppSessionSnapshot?, - removeWhenEmpty: Bool, - persistedGeometryData: Data?, - synchronously: Bool - ) { - guard snapshot != nil || removeWhenEmpty || persistedGeometryData != nil else { return } + func mainWindow(for windowId: UUID) -> NSWindow? { + windowForMainWindowId(windowId) + } - let writeBlock = { - Self.removeLegacyPersistedWindowGeometry() - if let persistedGeometryData { - UserDefaults.standard.set( - persistedGeometryData, - forKey: Self.persistedWindowGeometryDefaultsKey - ) - } - if let snapshot { - _ = SessionPersistenceStore.save(snapshot) - } else if removeWhenEmpty { - SessionPersistenceStore.removeSnapshot() + func mainWindowContainingWorkspace(_ workspaceId: UUID) -> NSWindow? { + for context in mainWindowContexts.values where context.tabManager.tabs.contains(where: { $0.id == workspaceId }) { + if let window = context.window ?? windowForMainWindowId(context.windowId) { + return window } } - - if synchronously { - writeBlock() - } else { - sessionPersistenceQueue.async(execute: writeBlock) - } + return nil } - private func buildSessionSnapshot(includeScrollback: Bool) -> AppSessionSnapshot? { - let contexts = mainWindowContexts.values.sorted { lhs, rhs in - let lhsWindow = lhs.window ?? windowForMainWindowId(lhs.windowId) - let rhsWindow = rhs.window ?? windowForMainWindowId(rhs.windowId) - let lhsIsKey = lhsWindow?.isKeyWindow ?? false - let rhsIsKey = rhsWindow?.isKeyWindow ?? false - if lhsIsKey != rhsIsKey { - return lhsIsKey && !rhsIsKey - } - return lhs.windowId.uuidString < rhs.windowId.uuidString + func scriptableMainWindows() -> [ScriptableMainWindowState] { + var results: [ScriptableMainWindowState] = [] + var seen: Set = [] + + for window in NSApp.orderedWindows { + guard let context = contextForMainTerminalWindow(window, reindex: false) else { continue } + guard seen.insert(context.windowId).inserted else { continue } + results.append( + ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) + ) + ) } - guard !contexts.isEmpty else { return nil } + let remaining = mainWindowContexts.values + .sorted { $0.windowId.uuidString < $1.windowId.uuidString } + .filter { seen.insert($0.windowId).inserted } - let windows: [SessionWindowSnapshot] = contexts - .prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) - .map { context in - let window = context.window ?? windowForMainWindowId(context.windowId) - return SessionWindowSnapshot( - frame: window.map { SessionRectSnapshot($0.frame) }, - display: displaySnapshot(for: window), - tabManager: context.tabManager.sessionSnapshot(includeScrollback: includeScrollback), - sidebar: SessionSidebarSnapshot( - isVisible: context.sidebarState.isVisible, - selection: SessionSidebarSelection(selection: context.sidebarSelectionState.selection), - width: SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)) - ) + for context in remaining { + results.append( + ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) ) - } + ) + } - guard !windows.isEmpty else { return nil } - return AppSessionSnapshot( - version: SessionSnapshotSchema.currentVersion, - createdAt: Date().timeIntervalSince1970, - windows: windows - ) + return results } -#if DEBUG - private func debugLogSessionSaveSnapshot( - _ snapshot: AppSessionSnapshot, - includeScrollback: Bool - ) { - dlog( - "session.save includeScrollback=\(includeScrollback ? 1 : 0) " + - "windows=\(snapshot.windows.count)" - ) - for (index, windowSnapshot) in snapshot.windows.enumerated() { - let workspaceCount = windowSnapshot.tabManager.workspaces.count - let selectedWorkspace = windowSnapshot.tabManager.selectedWorkspaceIndex.map(String.init) ?? "nil" - dlog( - "session.save.window idx=\(index) " + - "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + - "display={\(debugSessionDisplayDescription(windowSnapshot.display))} " + - "workspaces=\(workspaceCount) selected=\(selectedWorkspace)" - ) + func scriptableMainWindow(windowId: UUID) -> ScriptableMainWindowState? { + guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) else { + return nil } + return ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) + ) } - private func debugSessionRectDescription(_ rect: SessionRectSnapshot?) -> String { - guard let rect else { return "nil" } - return "x=\(debugSessionNumber(rect.x)) y=\(debugSessionNumber(rect.y)) " + - "w=\(debugSessionNumber(rect.width)) h=\(debugSessionNumber(rect.height))" - } - - private func debugNSRectDescription(_ rect: NSRect?) -> String { - guard let rect else { return "nil" } - return "x=\(debugSessionNumber(Double(rect.origin.x))) " + - "y=\(debugSessionNumber(Double(rect.origin.y))) " + - "w=\(debugSessionNumber(Double(rect.size.width))) " + - "h=\(debugSessionNumber(Double(rect.size.height)))" + func scriptableMainWindowForTab(_ tabId: UUID) -> ScriptableMainWindowState? { + guard let context = contextContainingTabId(tabId) else { return nil } + return ScriptableMainWindowState( + windowId: context.windowId, + tabManager: context.tabManager, + window: context.window ?? windowForMainWindowId(context.windowId) + ) } - private func debugSessionDisplayDescription(_ display: SessionDisplaySnapshot?) -> String { - guard let display else { return "nil" } - let displayIdText = display.displayID.map(String.init) ?? "nil" - return "id=\(displayIdText) " + - "frame={\(debugSessionRectDescription(display.frame))} " + - "visible={\(debugSessionRectDescription(display.visibleFrame))}" + @discardableResult + func focusScriptableMainWindow(windowId: UUID, bringToFront shouldBringToFront: Bool) -> Bool { + guard let state = scriptableMainWindow(windowId: windowId), + let window = state.window else { + return false + } + setActiveMainWindow(window) + if shouldBringToFront { + bringToFront(window) + } + return true } - private func debugSessionNumber(_ value: Double) -> String { - String(format: "%.1f", value) + @discardableResult + func addWorkspace(windowId: UUID, workingDirectory: String? = nil, bringToFront shouldBringToFront: Bool = false) -> UUID? { + guard let state = scriptableMainWindow(windowId: windowId) else { return nil } + if shouldBringToFront, let window = state.window { + setActiveMainWindow(window) + bringToFront(window) + } + let workspace = state.tabManager.addWorkspace( + workingDirectory: workingDirectory, + select: shouldBringToFront + ) + return workspace.id } -#endif - private func notifyMainWindowContextsDidChange() { - NotificationCenter.default.post(name: .mainWindowContextsDidChange, object: self) + private func markCommandPaletteOpenRequested(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + commandPalettePendingOpenByWindowId[windowId] = true + commandPaletteRecentRequestAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime } - /// Register a terminal window with the AppDelegate so menu commands and socket control - /// can target whichever window is currently active. - func registerMainWindow( - _ window: NSWindow, - windowId: UUID, - tabManager: TabManager, - sidebarState: SidebarState, - sidebarSelectionState: SidebarSelectionState + private func postCommandPaletteRequest( + name: Notification.Name, + preferredWindow: NSWindow?, + source: String, + markPending: Bool ) { - tabManager.window = window - - let key = ObjectIdentifier(window) - #if DEBUG - let priorManagerToken = debugManagerToken(self.tabManager) - #endif - if let existing = mainWindowContexts[key] { - existing.window = window - } else if let existing = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { - existing.window = window - reindexMainWindowContextIfNeeded(existing, for: window) - } else { - mainWindowContexts[key] = MainWindowContext( - windowId: windowId, - tabManager: tabManager, - sidebarState: sidebarState, - sidebarSelectionState: sidebarSelectionState, - window: window - ) - NotificationCenter.default.addObserver( - forName: NSWindow.willCloseNotification, - object: window, - queue: .main - ) { [weak self] note in - guard let self, let closing = note.object as? NSWindow else { return } - self.unregisterMainWindow(closing) - } + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + if markPending { + markCommandPaletteOpenRequested(for: targetWindow) } - commandPaletteVisibilityByWindowId[windowId] = false - commandPaletteSelectionByWindowId[windowId] = 0 - commandPaletteSnapshotByWindowId[windowId] = .empty - + NotificationCenter.default.post(name: name, object: targetWindow) #if DEBUG dlog( - "mainWindow.register windowId=\(String(windowId.uuidString.prefix(8))) window={\(debugWindowToken(window))} manager=\(debugManagerToken(tabManager)) priorActiveMgr=\(priorManagerToken) \(debugShortcutRouteSnapshot())" + "shortcut.palette.request source=\(source) " + + "target={\(debugWindowToken(targetWindow))} " + + "pendingMarked=\(markPending ? 1 : 0)" ) #endif - notifyMainWindowContextsDidChange() - if window.isKeyWindow { - setActiveMainWindow(window) - } - - attemptStartupSessionRestoreIfNeeded(primaryWindow: window) - if !isTerminatingApp { - _ = saveSessionSnapshot(includeScrollback: false) - } } - struct MainWindowSummary { - let windowId: UUID - let isKeyWindow: Bool - let isVisible: Bool - let workspaceCount: Int - let selectedWorkspaceId: UUID? + func requestCommandPaletteCommands(preferredWindow: NSWindow? = nil, source: String = "api.commandPalette") { + postCommandPaletteRequest( + name: .commandPaletteRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) } - struct WindowMoveTarget: Identifiable { - let windowId: UUID - let label: String - let tabManager: TabManager - let isCurrentWindow: Bool + func requestCommandPaletteSwitcher(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteSwitcher") { + postCommandPaletteRequest( + name: .commandPaletteSwitcherRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) + } - var id: UUID { windowId } + func requestCommandPaletteRenameTab(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteRenameTab") { + postCommandPaletteRequest( + name: .commandPaletteRenameTabRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) } - struct WorkspaceMoveTarget: Identifiable { - let windowId: UUID - let workspaceId: UUID - let windowLabel: String - let workspaceTitle: String - let tabManager: TabManager - let isCurrentWindow: Bool + func requestCommandPaletteRenameWorkspace( + preferredWindow: NSWindow? = nil, + source: String = "api.commandPaletteRenameWorkspace" + ) { + postCommandPaletteRequest( + name: .commandPaletteRenameWorkspaceRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) + } - var id: String { "\(windowId.uuidString):\(workspaceId.uuidString)" } - var label: String { - isCurrentWindow ? workspaceTitle : "\(workspaceTitle) (\(windowLabel))" - } + func requestCommandPaletteEditWorkspaceDescription( + preferredWindow: NSWindow? = nil, + source: String = "api.commandPaletteEditWorkspaceDescription" + ) { + postCommandPaletteRequest( + name: .commandPaletteEditWorkspaceDescriptionRequested, + preferredWindow: preferredWindow, + source: source, + markPending: true + ) } - func listMainWindowSummaries() -> [MainWindowSummary] { - let contexts = Array(mainWindowContexts.values) - return contexts.map { ctx in - let window = ctx.window ?? windowForMainWindowId(ctx.windowId) - return MainWindowSummary( - windowId: ctx.windowId, - isKeyWindow: window?.isKeyWindow ?? false, - isVisible: window?.isVisible ?? false, - workspaceCount: ctx.tabManager.tabs.count, - selectedWorkspaceId: ctx.tabManager.selectedTabId - ) - } + private func clearCommandPalettePendingOpen(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) } - func windowMoveTargets(referenceWindowId: UUID?) -> [WindowMoveTarget] { - let orderedSummaries = orderedMainWindowSummaries(referenceWindowId: referenceWindowId) - let labels = windowLabelsById(orderedSummaries: orderedSummaries, referenceWindowId: referenceWindowId) - return orderedSummaries.compactMap { summary in - guard let manager = tabManagerFor(windowId: summary.windowId) else { return nil } - let label = labels[summary.windowId] ?? "Window" - return WindowMoveTarget( - windowId: summary.windowId, - label: label, - tabManager: manager, - isCurrentWindow: summary.windowId == referenceWindowId + private func pruneExpiredCommandPalettePendingOpenStates( + now: TimeInterval = ProcessInfo.processInfo.systemUptime + ) { + for windowId in Array(commandPalettePendingOpenByWindowId.keys) { + guard commandPalettePendingOpenByWindowId[windowId] == true else { continue } + guard let requestedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) +#if DEBUG + dlog("shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) reason=missingTimestamp") +#endif + continue + } + let age = now - requestedAt + guard age > Self.commandPalettePendingOpenMaxAge else { continue } + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) +#if DEBUG + dlog( + "shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) " + + "reason=stale ageMs=\(Int(age * 1000))" ) +#endif } } - func workspaceMoveTargets(excludingWorkspaceId: UUID? = nil, referenceWindowId: UUID?) -> [WorkspaceMoveTarget] { - let orderedSummaries = orderedMainWindowSummaries(referenceWindowId: referenceWindowId) - let labels = windowLabelsById(orderedSummaries: orderedSummaries, referenceWindowId: referenceWindowId) - - var targets: [WorkspaceMoveTarget] = [] - targets.reserveCapacity(orderedSummaries.reduce(0) { partial, summary in - partial + summary.workspaceCount - }) + private func isCommandPalettePendingOpen(for window: NSWindow) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + pruneExpiredCommandPalettePendingOpenStates() + return commandPalettePendingOpenByWindowId[windowId] == true + } - for summary in orderedSummaries { - guard let manager = tabManagerFor(windowId: summary.windowId) else { continue } - let windowLabel = labels[summary.windowId] ?? "Window" - let isCurrentWindow = summary.windowId == referenceWindowId - for workspace in manager.tabs { - if workspace.id == excludingWorkspaceId { - continue - } - targets.append( - WorkspaceMoveTarget( - windowId: summary.windowId, - workspaceId: workspace.id, - windowLabel: windowLabel, - workspaceTitle: workspaceDisplayName(workspace), - tabManager: manager, - isCurrentWindow: isCurrentWindow - ) - ) - } - } + private func beginCommandPaletteEscapeSuppression(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + commandPaletteEscapeSuppressionByWindowId.insert(windowId) + commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime + } - return targets + private func endCommandPaletteEscapeSuppression(for window: NSWindow?) { + guard let window, + let windowId = mainWindowId(for: window) else { return } + commandPaletteEscapeSuppressionByWindowId.remove(windowId) + commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: windowId) } - @discardableResult - func moveWorkspaceToWindow(workspaceId: UUID, windowId: UUID, focus: Bool = true) -> Bool { - guard let sourceManager = tabManagerFor(tabId: workspaceId), - let destinationManager = tabManagerFor(windowId: windowId) else { + private func shouldConsumeSuppressedEscape(event: NSEvent, window: NSWindow?) -> Bool { + guard let window, + let windowId = mainWindowId(for: window), + commandPaletteEscapeSuppressionByWindowId.contains(windowId) else { return false } - - if sourceManager === destinationManager { - if focus { - destinationManager.focusTab(workspaceId, suppressFlash: true) - _ = focusMainWindow(windowId: windowId) - TerminalController.shared.setActiveTabManager(destinationManager) - } + let startedAt = commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] ?? 0 + if ProcessInfo.processInfo.systemUptime - startedAt <= 0.35 { return true } - - guard let workspace = sourceManager.detachWorkspace(tabId: workspaceId) else { return false } - destinationManager.attachWorkspace(workspace, select: focus) - - if focus { - _ = focusMainWindow(windowId: windowId) - TerminalController.shared.setActiveTabManager(destinationManager) - } - return true + // Fallback cleanup when keyUp is lost for any reason. + endCommandPaletteEscapeSuppression(for: window) + return false } - @discardableResult - func moveWorkspaceToNewWindow(workspaceId: UUID, focus: Bool = true) -> UUID? { - let windowId = createMainWindow() - guard let destinationManager = tabManagerFor(windowId: windowId) else { return nil } - let bootstrapWorkspaceId = destinationManager.tabs.first?.id - - guard moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: focus) else { - _ = closeMainWindow(windowId: windowId) + private func recentCommandPaletteRequestAge(for window: NSWindow?) -> TimeInterval? { + guard let window, + let windowId = mainWindowId(for: window) else { return nil } - - // Remove the bootstrap workspace from the new window once the moved workspace arrives. - if let bootstrapWorkspaceId, - bootstrapWorkspaceId != workspaceId, - let bootstrapWorkspace = destinationManager.tabs.first(where: { $0.id == bootstrapWorkspaceId }), - destinationManager.tabs.count > 1 { - destinationManager.closeWorkspace(bootstrapWorkspace) + let now = ProcessInfo.processInfo.systemUptime + pruneExpiredCommandPalettePendingOpenStates(now: now) + guard commandPalettePendingOpenByWindowId[windowId] == true else { + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + return nil } - return windowId - } - - func locateBonsplitSurface(tabId: UUID) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { - let bonsplitTabId = TabID(uuid: tabId) - for context in mainWindowContexts.values { - for workspace in context.tabManager.tabs { - if let panelId = workspace.panelIdFromSurfaceId(bonsplitTabId) { - return (context.windowId, workspace.id, panelId, context.tabManager) - } - } + guard let startedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + return nil + } + let age = now - startedAt + if age <= Self.commandPaletteRequestGraceInterval { + return age } return nil } - @discardableResult - func moveSurface( - panelId: UUID, - toWorkspace targetWorkspaceId: UUID, - targetPane: PaneID? = nil, - targetIndex: Int? = nil, - splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? = nil, - focus: Bool = true, - focusWindow: Bool = true - ) -> Bool { -#if DEBUG - let moveStart = ProcessInfo.processInfo.systemUptime - let splitLabel = splitTarget.map { split in - "\(split.orientation.rawValue):\(split.insertFirst ? 1 : 0)" - } ?? "none" - func elapsedMs(since start: TimeInterval) -> String { - let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 - return String(format: "%.2f", ms) - } - dlog( - "surface.move.begin panel=\(panelId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + - "targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil") " + - "split=\(splitLabel) focus=\(focus ? 1 : 0) focusWindow=\(focusWindow ? 1 : 0)" - ) -#endif - guard let source = locateSurface(surfaceId: panelId) else { -#if DEBUG - dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourcePanelNotFound elapsedMs=\(elapsedMs(since: moveStart))") -#endif - return false - } - guard let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }) else { -#if DEBUG - dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourceWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") -#endif - return false - } - guard let destinationManager = tabManagerFor(tabId: targetWorkspaceId) else { -#if DEBUG - dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationManagerMissing elapsedMs=\(elapsedMs(since: moveStart))") -#endif - return false - } - guard let destinationWorkspace = destinationManager.tabs.first(where: { $0.id == targetWorkspaceId }) else { -#if DEBUG - dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))") -#endif - return false - } -#if DEBUG - dlog( - "surface.move.route panel=\(panelId.uuidString.prefix(5)) sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) " + - "sourceWin=\(source.windowId.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " + - "sameWorkspace=\(destinationWorkspace.id == sourceWorkspace.id ? 1 : 0)" - ) -#endif - - let resolvedTargetPane = targetPane.flatMap { pane in - destinationWorkspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) - } ?? destinationWorkspace.bonsplitController.focusedPaneId - ?? destinationWorkspace.bonsplitController.allPaneIds.first + private func escapeSuppressionWindow(for event: NSEvent) -> NSWindow? { + commandPaletteWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + } - guard let resolvedTargetPane else { + @discardableResult + private func clearEscapeSuppressionForKeyUp(event: NSEvent, consumeIfSuppressed: Bool = false) -> Bool { + guard event.type == .keyUp, event.keyCode == 53 else { return false } + let suppressionWindow = escapeSuppressionWindow(for: event) + let didConsume = consumeIfSuppressed && shouldConsumeSuppressedEscape(event: event, window: suppressionWindow) + if let window = suppressionWindow { + endCommandPaletteEscapeSuppression(for: window) #if DEBUG dlog( - "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=targetPaneMissing " + - "destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" + "shortcut.escape suppressionClear target={\(debugWindowToken(window))} " + + "keyUpConsumed=\(didConsume ? 1 : 0)" ) #endif - return false + return didConsume } - - if destinationWorkspace.id == sourceWorkspace.id { - if let splitTarget { - guard let sourceTabId = sourceWorkspace.surfaceIdFromPanelId(panelId), - sourceWorkspace.bonsplitController.splitPane( - resolvedTargetPane, - orientation: splitTarget.orientation, - movingTab: sourceTabId, - insertFirst: splitTarget.insertFirst - ) != nil else { -#if DEBUG - dlog( - "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sameWorkspaceSplitFailed " + - "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) split=\(splitLabel) " + - "elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return false - } - if focus { - source.tabManager.focusTab(sourceWorkspace.id, surfaceId: panelId, suppressFlash: true) - } + commandPaletteEscapeSuppressionByWindowId.removeAll() + commandPaletteEscapeSuppressionStartedAtByWindowId.removeAll() #if DEBUG - dlog( - "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceSplit moved=1 " + - "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" - ) + dlog("shortcut.escape suppressionClear target={nil} clearedAll=1 keyUpConsumed=\(didConsume ? 1 : 0)") #endif - return true - } + return didConsume + } - let moved = sourceWorkspace.moveSurface( - panelId: panelId, - toPane: resolvedTargetPane, - atIndex: targetIndex, - focus: focus - ) + func setCommandPaletteVisible(_ visible: Bool, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + let wasVisible = commandPaletteVisibilityByWindowId[windowId] ?? false + commandPaletteVisibilityByWindowId[windowId] = visible + // Opening (false -> true) always resolves pending-open. + // Closing (true -> false) also clears stale pending state. + // Ignore repeated false updates so a stale sync cannot erase an in-flight open request. + if visible || wasVisible { + commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + } #if DEBUG + if !visible, + !wasVisible, + commandPalettePendingOpenByWindowId[windowId] == true { dlog( - "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceMove moved=\(moved ? 1 : 0) " + - "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " + - "elapsedMs=\(elapsedMs(since: moveStart))" + "palette.visibility.retainPending " + + "window={\(debugWindowToken(window))} visible=0 wasVisible=0 pending=1" ) -#endif - return moved } - - let sourcePane = sourceWorkspace.paneId(forPanelId: panelId) - let sourceIndex = sourceWorkspace.indexInPane(forPanelId: panelId) -#if DEBUG - let detachStart = ProcessInfo.processInfo.systemUptime -#endif - - guard let detached = sourceWorkspace.detachSurface(panelId: panelId) else { -#if DEBUG - dlog( - "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=detachFailed " + - "elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return false - } -#if DEBUG - let detachMs = elapsedMs(since: detachStart) - let attachStart = ProcessInfo.processInfo.systemUptime -#endif - guard destinationWorkspace.attachDetachedSurface( - detached, - inPane: resolvedTargetPane, - atIndex: targetIndex, - focus: focus - ) != nil else { - rollbackDetachedSurface( - detached, - to: sourceWorkspace, - sourcePane: sourcePane, - sourceIndex: sourceIndex, - focus: focus - ) -#if DEBUG - dlog( - "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=attachFailed " + - "detachMs=\(detachMs) elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return false - } -#if DEBUG - let attachMs = elapsedMs(since: attachStart) - var splitMs = "0.00" #endif + } - if let splitTarget { -#if DEBUG - let splitStart = ProcessInfo.processInfo.systemUptime -#endif - guard let movedTabId = destinationWorkspace.surfaceIdFromPanelId(panelId), - destinationWorkspace.bonsplitController.splitPane( - resolvedTargetPane, - orientation: splitTarget.orientation, - movingTab: movedTabId, - insertFirst: splitTarget.insertFirst - ) != nil else { - if let detachedFromDestination = destinationWorkspace.detachSurface(panelId: panelId) { - rollbackDetachedSurface( - detachedFromDestination, - to: sourceWorkspace, - sourcePane: sourcePane, - sourceIndex: sourceIndex, - focus: focus - ) - } -#if DEBUG - dlog( - "surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=postAttachSplitFailed " + - "detachMs=\(detachMs) attachMs=\(attachMs) elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return false - } -#if DEBUG - splitMs = elapsedMs(since: splitStart) -#endif - } + func isCommandPaletteVisible(windowId: UUID) -> Bool { + commandPaletteVisibilityByWindowId[windowId] ?? false + } -#if DEBUG - let cleanupStart = ProcessInfo.processInfo.systemUptime -#endif - cleanupEmptySourceWorkspaceAfterSurfaceMove( - sourceWorkspace: sourceWorkspace, - sourceManager: source.tabManager, - sourceWindowId: source.windowId - ) -#if DEBUG - let cleanupMs = elapsedMs(since: cleanupStart) - let focusStart = ProcessInfo.processInfo.systemUptime -#endif + func setCommandPaletteSelectionIndex(_ index: Int, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + commandPaletteSelectionByWindowId[windowId] = max(0, index) + } - if focus { - let destinationWindowId = focusWindow ? windowId(for: destinationManager) : nil - if let destinationWindowId { - _ = focusMainWindow(windowId: destinationWindowId) - } - destinationManager.focusTab(targetWorkspaceId, surfaceId: panelId, suppressFlash: true) - if let destinationWindowId { - reassertCrossWindowSurfaceMoveFocusIfNeeded( - destinationWindowId: destinationWindowId, - sourceWindowId: source.windowId, - destinationWorkspaceId: targetWorkspaceId, - destinationPanelId: panelId, - destinationManager: destinationManager - ) - } - } -#if DEBUG - let focusMs = elapsedMs(since: focusStart) - dlog( - "surface.move.end panel=\(panelId.uuidString.prefix(5)) path=crossWorkspace moved=1 " + - "sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " + - "targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " + - "split=\(splitLabel) detachMs=\(detachMs) attachMs=\(attachMs) splitMs=\(splitMs) " + - "cleanupMs=\(cleanupMs) focusMs=\(focusMs) elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif + func commandPaletteSelectionIndex(windowId: UUID) -> Int { + commandPaletteSelectionByWindowId[windowId] ?? 0 + } - return true + func setCommandPaletteSnapshot(_ snapshot: CommandPaletteDebugSnapshot, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + commandPaletteSnapshotByWindowId[windowId] = snapshot } - @discardableResult - func moveBonsplitTab( - tabId: UUID, - toWorkspace targetWorkspaceId: UUID, - targetPane: PaneID? = nil, - targetIndex: Int? = nil, - splitTarget: (orientation: SplitOrientation, insertFirst: Bool)? = nil, - focus: Bool = true, - focusWindow: Bool = true - ) -> Bool { -#if DEBUG - let moveStart = ProcessInfo.processInfo.systemUptime - func elapsedMs(since start: TimeInterval) -> String { - let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 - return String(format: "%.2f", ms) - } - dlog( - "surface.moveBonsplit.begin tab=\(tabId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " + - "targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil")" - ) -#endif - guard let located = locateBonsplitSurface(tabId: tabId) else { -#if DEBUG - dlog( - "surface.moveBonsplit.fail tab=\(tabId.uuidString.prefix(5)) reason=tabNotFound " + - "targetWs=\(targetWorkspaceId.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return false - } -#if DEBUG - dlog( - "surface.moveBonsplit.located tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + - "sourceWs=\(located.workspaceId.uuidString.prefix(5)) sourceWin=\(located.windowId.uuidString.prefix(5))" - ) -#endif - let moved = moveSurface( - panelId: located.panelId, - toWorkspace: targetWorkspaceId, - targetPane: targetPane, - targetIndex: targetIndex, - splitTarget: splitTarget, - focus: focus, - focusWindow: focusWindow - ) -#if DEBUG - dlog( - "surface.moveBonsplit.end tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " + - "moved=\(moved ? 1 : 0) elapsedMs=\(elapsedMs(since: moveStart))" - ) -#endif - return moved + func commandPaletteSnapshot(windowId: UUID) -> CommandPaletteDebugSnapshot { + commandPaletteSnapshotByWindowId[windowId] ?? .empty } - func tabManagerFor(windowId: UUID) -> TabManager? { - mainWindowContexts.values.first(where: { $0.windowId == windowId })?.tabManager + func isCommandPaletteVisible(for window: NSWindow) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + return commandPaletteVisibilityByWindowId[windowId] ?? false } - func windowId(for tabManager: TabManager) -> UUID? { - mainWindowContexts.values.first(where: { $0.tabManager === tabManager })?.windowId + func isCommandPaletteEffectivelyVisible(for window: NSWindow) -> Bool { + isCommandPaletteEffectivelyVisible(in: window) } - func mainWindow(for windowId: UUID) -> NSWindow? { - windowForMainWindowId(windowId) + func shouldBlockFirstResponderChangeWhileCommandPaletteVisible( + window: NSWindow, + responder: NSResponder? + ) -> Bool { + guard isCommandPaletteVisible(for: window) else { return false } + guard let responder else { return false } + guard !isCommandPaletteResponder(responder) else { return false } + return isFocusStealingResponderWhileCommandPaletteVisible(responder) } - func mainWindowContainingWorkspace(_ workspaceId: UUID) -> NSWindow? { - for context in mainWindowContexts.values where context.tabManager.tabs.contains(where: { $0.id == workspaceId }) { - if let window = context.window ?? windowForMainWindowId(context.windowId) { - return window + private func isCommandPaletteResponder(_ responder: NSResponder) -> Bool { + if let textView = responder as? NSTextView, textView.isFieldEditor { + if let delegateView = textView.delegate as? NSView { + return isInsideCommandPaletteOverlay(delegateView) } + // SwiftUI can attach a non-view delegate to TextField editors. + // When command palette is visible, its search/rename editor is the + // only expected field editor inside the main window. + return true } - return nil - } - - func scriptableMainWindows() -> [ScriptableMainWindowState] { - var results: [ScriptableMainWindowState] = [] - var seen: Set = [] - - for window in NSApp.orderedWindows { - guard let context = contextForMainTerminalWindow(window, reindex: false) else { continue } - guard seen.insert(context.windowId).inserted else { continue } - results.append( - ScriptableMainWindowState( - windowId: context.windowId, - tabManager: context.tabManager, - window: context.window ?? windowForMainWindowId(context.windowId) - ) - ) + if let view = responder as? NSView { + return isInsideCommandPaletteOverlay(view) } + return false + } - let remaining = mainWindowContexts.values - .sorted { $0.windowId.uuidString < $1.windowId.uuidString } - .filter { seen.insert($0.windowId).inserted } + private func isFocusStealingResponderWhileCommandPaletteVisible(_ responder: NSResponder) -> Bool { + isCommandPaletteFocusStealingTerminalOrBrowserResponder(responder) + } - for context in remaining { - results.append( - ScriptableMainWindowState( - windowId: context.windowId, - tabManager: context.tabManager, - window: context.window ?? windowForMainWindowId(context.windowId) - ) - ) + private func isInsideCommandPaletteOverlay(_ view: NSView) -> Bool { + var current: NSView? = view + while let candidate = current { + if candidate.identifier == commandPaletteOverlayContainerIdentifier { + return true + } + current = candidate.superview } - - return results + return false } - func scriptableMainWindow(windowId: UUID) -> ScriptableMainWindowState? { - guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) else { - return nil + private func keyRoutingOwnerView(for responder: NSResponder?) -> NSView? { + guard let responder else { return nil } + if let editor = responder as? NSTextView, + editor.isFieldEditor { + return programaFieldEditorOwnerView(editor) ?? editor } - return ScriptableMainWindowState( - windowId: context.windowId, - tabManager: context.tabManager, - window: context.window ?? windowForMainWindowId(context.windowId) - ) + return responder as? NSView } - func scriptableMainWindowForTab(_ tabId: UUID) -> ScriptableMainWindowState? { - guard let context = contextContainingTabId(tabId) else { return nil } - return ScriptableMainWindowState( - windowId: context.windowId, - tabManager: context.tabManager, - window: context.window ?? windowForMainWindowId(context.windowId) - ) - } + private func responderHasViableKeyRoutingOwner( + _ responder: NSResponder, + in window: NSWindow + ) -> Bool { + if let ghosttyView = cmuxOwningGhosttyView(for: responder) { + if ghosttyView.window !== window { + return false + } + if ghosttyView.isHiddenOrHasHiddenAncestor { + return false + } + return ghosttyView === window.contentView || ghosttyView.superview != nil + } - @discardableResult - func focusScriptableMainWindow(windowId: UUID, bringToFront shouldBringToFront: Bool) -> Bool { - guard let state = scriptableMainWindow(windowId: windowId), - let window = state.window else { + guard let ownerView = keyRoutingOwnerView(for: responder) else { return false } - setActiveMainWindow(window) - if shouldBringToFront { - bringToFront(window) + + if ownerView.window !== window { + return false } - return true - } - @discardableResult - func addWorkspace(windowId: UUID, workingDirectory: String? = nil, bringToFront shouldBringToFront: Bool = false) -> UUID? { - guard let state = scriptableMainWindow(windowId: windowId) else { return nil } - if shouldBringToFront, let window = state.window { - setActiveMainWindow(window) - bringToFront(window) + if ownerView.isHiddenOrHasHiddenAncestor { + return false } - let workspace = state.tabManager.addWorkspace( - workingDirectory: workingDirectory, - select: shouldBringToFront - ) - return workspace.id + + if ownerView !== window.contentView, ownerView.superview == nil { + return false + } + + return true } - private func markCommandPaletteOpenRequested(for window: NSWindow?) { - guard let window, - let windowId = mainWindowId(for: window) else { return } - commandPalettePendingOpenByWindowId[windowId] = true - commandPaletteRecentRequestAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime + private func responderNeedsFocusedTerminalKeyRepair( + _ responder: NSResponder?, + in window: NSWindow, + hostedView: GhosttySurfaceScrollView + ) -> Bool { + guard let responder else { return true } + if responder is NSWindow { return true } + guard responderHasViableKeyRoutingOwner(responder, in: window) else { + return true + } + if hostedView.responderMatchesPreferredKeyboardFocus(responder) { + return false + } + return true } - private func postCommandPaletteRequest( - name: Notification.Name, - preferredWindow: NSWindow?, - source: String, - markPending: Bool + func repairFocusedTerminalKeyboardRoutingIfNeeded( + window: NSWindow, + event: NSEvent ) { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - if markPending { - markCommandPaletteOpenRequested(for: targetWindow) + guard event.type == .keyDown else { return } + let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + guard !normalizedFlags.contains(.command) else { return } + guard isMainTerminalWindow(window) else { return } + guard window.attachedSheet == nil else { return } + guard !isCommandPaletteEffectivelyVisible(in: window) else { return } + guard let context = contextForMainWindow(window) ?? contextForMainTerminalWindow(window), + let workspace = context.tabManager.selectedWorkspace, + let panelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: panelId) else { + return } - NotificationCenter.default.post(name: name, object: targetWindow) + guard responderNeedsFocusedTerminalKeyRepair( + window.firstResponder, + in: window, + hostedView: terminalPanel.hostedView + ) else { return } + #if DEBUG + let before = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let target = terminalPanel.hostedView.preferredPanelFocusIntentForActivation() dlog( - "shortcut.palette.request source=\(source) " + - "target={\(debugWindowToken(targetWindow))} " + - "pendingMarked=\(markPending ? 1 : 0)" + "focus.keyRepair attempt window=\(ObjectIdentifier(window)) " + + "workspace=\(String(workspace.id.uuidString.prefix(5))) " + + "panel=\(String(panelId.uuidString.prefix(5))) " + + "target=\(target == .findField ? "searchField" : "surface") " + + "fr=\(before) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue)" ) #endif - } - func requestCommandPaletteCommands(preferredWindow: NSWindow? = nil, source: String = "api.commandPalette") { - postCommandPaletteRequest( - name: .commandPaletteRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true - ) - } + terminalPanel.hostedView.ensureFocus(for: workspace.id, surfaceId: panelId) - func requestCommandPaletteSwitcher(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteSwitcher") { - postCommandPaletteRequest( - name: .commandPaletteSwitcherRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true +#if DEBUG + let after = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "focus.keyRepair result window=\(ObjectIdentifier(window)) " + + "panel=\(String(panelId.uuidString.prefix(5))) " + + "isSurfaceResponder=\(terminalPanel.hostedView.isSurfaceViewFirstResponder() ? 1 : 0) " + + "fr=\(after)" ) +#endif } - func requestCommandPaletteRenameTab(preferredWindow: NSWindow? = nil, source: String = "api.commandPaletteRenameTab") { - postCommandPaletteRequest( - name: .commandPaletteRenameTabRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true - ) + func locateSurface(surfaceId: UUID) -> (windowId: UUID, workspaceId: UUID, tabManager: TabManager)? { + for ctx in mainWindowContexts.values { + for ws in ctx.tabManager.tabs { + if ws.panels[surfaceId] != nil { + return (ctx.windowId, ws.id, ctx.tabManager) + } + } + } + return nil } - func requestCommandPaletteRenameWorkspace( - preferredWindow: NSWindow? = nil, - source: String = "api.commandPaletteRenameWorkspace" - ) { - postCommandPaletteRequest( - name: .commandPaletteRenameWorkspaceRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true - ) - } + /// Resolve the workspace that currently owns a panel/surface ID. + /// Prefer the provided workspace when available, then fall back to global lookup. + func workspaceContainingPanel( + panelId: UUID, + preferredWorkspaceId: UUID? = nil + ) -> (workspace: Workspace, tabManager: TabManager)? { + if let preferredWorkspaceId, + let manager = tabManagerFor(tabId: preferredWorkspaceId), + let workspace = manager.tabs.first(where: { $0.id == preferredWorkspaceId }), + workspace.panels[panelId] != nil { + return (workspace, manager) + } - func requestCommandPaletteEditWorkspaceDescription( - preferredWindow: NSWindow? = nil, - source: String = "api.commandPaletteEditWorkspaceDescription" - ) { - postCommandPaletteRequest( - name: .commandPaletteEditWorkspaceDescriptionRequested, - preferredWindow: preferredWindow, - source: source, - markPending: true - ) - } + if let located = locateSurface(surfaceId: panelId), + let workspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }), + workspace.panels[panelId] != nil { + return (workspace, located.tabManager) + } - private func clearCommandPalettePendingOpen(for window: NSWindow?) { - guard let window, - let windowId = mainWindowId(for: window) else { return } - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + if let preferredWorkspaceId, + let manager = tabManagerFor(tabId: preferredWorkspaceId) ?? tabManager, + let workspace = manager.tabs.first(where: { $0.id == preferredWorkspaceId }), + workspace.panels[panelId] != nil { + return (workspace, manager) + } + + if let manager = tabManager, + let workspace = manager.tabs.first(where: { $0.panels[panelId] != nil }) { + return (workspace, manager) + } + + return nil } - private func pruneExpiredCommandPalettePendingOpenStates( - now: TimeInterval = ProcessInfo.processInfo.systemUptime - ) { - for windowId in Array(commandPalettePendingOpenByWindowId.keys) { - guard commandPalettePendingOpenByWindowId[windowId] == true else { continue } - guard let requestedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) -#if DEBUG - dlog("shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) reason=missingTimestamp") -#endif - continue + func locateGhosttySurface(_ surface: ghostty_surface_t?) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { + guard let surface else { return nil } + for ctx in mainWindowContexts.values { + for ws in ctx.tabManager.tabs { + for (panelId, panel) in ws.panels { + guard let terminal = panel as? TerminalPanel else { continue } + if terminal.surface.surface == surface { + return (ctx.windowId, ws.id, panelId, ctx.tabManager) + } + } } - let age = now - requestedAt - guard age > Self.commandPalettePendingOpenMaxAge else { continue } - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) -#if DEBUG - dlog( - "shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) " + - "reason=stale ageMs=\(Int(age * 1000))" - ) -#endif } + return nil } - private func isCommandPalettePendingOpen(for window: NSWindow) -> Bool { - guard let windowId = mainWindowId(for: window) else { return false } - pruneExpiredCommandPalettePendingOpenStates() - return commandPalettePendingOpenByWindowId[windowId] == true - } - - private func beginCommandPaletteEscapeSuppression(for window: NSWindow?) { - guard let window, - let windowId = mainWindowId(for: window) else { return } - commandPaletteEscapeSuppressionByWindowId.insert(windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime + func refreshTerminalSurfacesAfterGhosttyConfigReload(source: String) { + var refreshedCount = 0 + forEachTerminalPanel { terminalPanel in + terminalPanel.hostedView.reconcileGeometryNow() + terminalPanel.hostedView.refreshHostBackgroundAfterGhosttyConfigReload() + terminalPanel.surface.forceRefresh(reason: "appDelegate.refreshAfterGhosttyConfigReload") + refreshedCount += 1 + } +#if DEBUG + dlog("reload.config.surfaceRefresh source=\(source) count=\(refreshedCount)") +#endif } - private func endCommandPaletteEscapeSuppression(for window: NSWindow?) { - guard let window, - let windowId = mainWindowId(for: window) else { return } - commandPaletteEscapeSuppressionByWindowId.remove(windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: windowId) - } + private func forEachTerminalPanel(_ body: (TerminalPanel) -> Void) { + var seenManagers: Set = [] - private func shouldConsumeSuppressedEscape(event: NSEvent, window: NSWindow?) -> Bool { - guard let window, - let windowId = mainWindowId(for: window), - commandPaletteEscapeSuppressionByWindowId.contains(windowId) else { - return false + func visitManager(_ manager: TabManager?) { + guard let manager else { return } + let managerId = ObjectIdentifier(manager) + guard seenManagers.insert(managerId).inserted else { return } + for workspace in manager.tabs { + for panel in workspace.panels.values { + guard let terminalPanel = panel as? TerminalPanel else { continue } + body(terminalPanel) + } + } } - let startedAt = commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] ?? 0 - if ProcessInfo.processInfo.systemUptime - startedAt <= 0.35 { - return true + + visitManager(tabManager) + for context in mainWindowContexts.values { + visitManager(context.tabManager) } - // Fallback cleanup when keyUp is lost for any reason. - endCommandPaletteEscapeSuppression(for: window) - return false } - private func recentCommandPaletteRequestAge(for window: NSWindow?) -> TimeInterval? { - guard let window, - let windowId = mainWindowId(for: window) else { - return nil - } - let now = ProcessInfo.processInfo.systemUptime - pruneExpiredCommandPalettePendingOpenStates(now: now) - guard commandPalettePendingOpenByWindowId[windowId] == true else { - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) - return nil - } - guard let startedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - return nil - } - let age = now - startedAt - if age <= Self.commandPaletteRequestGraceInterval { - return age + func focusMainWindow(windowId: UUID) -> Bool { + guard let window = windowForMainWindowId(windowId) else { return false } + if TerminalController.shouldSuppressSocketCommandActivation(), + !TerminalController.socketCommandAllowsInAppFocusMutations() { + setActiveMainWindow(window) + return true } - return nil + bringToFront(window) + return true } - private func escapeSuppressionWindow(for event: NSEvent) -> NSWindow? { - commandPaletteWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + func closeMainWindow(windowId: UUID) -> Bool { + guard let window = windowForMainWindowId(windowId) else { return false } + window.performClose(nil) + return true } - @discardableResult - private func clearEscapeSuppressionForKeyUp(event: NSEvent, consumeIfSuppressed: Bool = false) -> Bool { - guard event.type == .keyUp, event.keyCode == 53 else { return false } - let suppressionWindow = escapeSuppressionWindow(for: event) - let didConsume = consumeIfSuppressed && shouldConsumeSuppressedEscape(event: event, window: suppressionWindow) - if let window = suppressionWindow { - endCommandPaletteEscapeSuppression(for: window) + private func confirmCloseMainWindow(_ window: NSWindow) -> Bool { #if DEBUG - dlog( - "shortcut.escape suppressionClear target={\(debugWindowToken(window))} " + - "keyUpConsumed=\(didConsume ? 1 : 0)" - ) -#endif - return didConsume + if let debugCloseMainWindowConfirmationHandler { + return debugCloseMainWindowConfirmationHandler(window) } - commandPaletteEscapeSuppressionByWindowId.removeAll() - commandPaletteEscapeSuppressionStartedAtByWindowId.removeAll() -#if DEBUG - dlog("shortcut.escape suppressionClear target={nil} clearedAll=1 keyUpConsumed=\(didConsume ? 1 : 0)") #endif - return didConsume - } - func setCommandPaletteVisible(_ visible: Bool, for window: NSWindow) { - guard let windowId = mainWindowId(for: window) else { return } - let wasVisible = commandPaletteVisibilityByWindowId[windowId] ?? false - commandPaletteVisibilityByWindowId[windowId] = visible - // Opening (false -> true) always resolves pending-open. - // Closing (true -> false) also clears stale pending state. - // Ignore repeated false updates so a stale sync cannot erase an in-flight open request. - if visible || wasVisible { - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) - } -#if DEBUG - if !visible, - !wasVisible, - commandPalettePendingOpenByWindowId[windowId] == true { - dlog( - "palette.visibility.retainPending " + - "window={\(debugWindowToken(window))} visible=0 wasVisible=0 pending=1" - ) - } -#endif - } + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "dialog.closeWindow.title", defaultValue: "Close window?") + alert.informativeText = String( + localized: "dialog.closeWindow.message", + defaultValue: "This will close the current window and all of its workspaces." + ) + alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - func isCommandPaletteVisible(windowId: UUID) -> Bool { - commandPaletteVisibilityByWindowId[windowId] ?? false - } + let alertWindow = alert.window + if let closeButton = alert.buttons.first { + alertWindow.defaultButtonCell = closeButton.cell as? NSButtonCell + alertWindow.initialFirstResponder = closeButton + DispatchQueue.main.async { + _ = alertWindow.makeFirstResponder(closeButton) + } + } - func setCommandPaletteSelectionIndex(_ index: Int, for window: NSWindow) { - guard let windowId = mainWindowId(for: window) else { return } - commandPaletteSelectionByWindowId[windowId] = max(0, index) + return alert.runModal() == .alertFirstButtonReturn } - func commandPaletteSelectionIndex(windowId: UUID) -> Int { - commandPaletteSelectionByWindowId[windowId] ?? 0 + @discardableResult + func closeWindowWithConfirmation(_ window: NSWindow) -> Bool { + guard isMainTerminalWindow(window) else { + window.performClose(nil) + return true + } + guard confirmCloseMainWindow(window) else { return true } + window.performClose(nil) + return true } - func setCommandPaletteSnapshot(_ snapshot: CommandPaletteDebugSnapshot, for window: NSWindow) { - guard let windowId = mainWindowId(for: window) else { return } - commandPaletteSnapshotByWindowId[windowId] = snapshot + private func orderedMainWindowSummaries(referenceWindowId: UUID?) -> [MainWindowSummary] { + let summaries = listMainWindowSummaries() + return summaries.sorted { lhs, rhs in + let lhsIsReference = lhs.windowId == referenceWindowId + let rhsIsReference = rhs.windowId == referenceWindowId + if lhsIsReference != rhsIsReference { return lhsIsReference } + if lhs.isKeyWindow != rhs.isKeyWindow { return lhs.isKeyWindow } + if lhs.isVisible != rhs.isVisible { return lhs.isVisible } + return lhs.windowId.uuidString < rhs.windowId.uuidString + } } - func commandPaletteSnapshot(windowId: UUID) -> CommandPaletteDebugSnapshot { - commandPaletteSnapshotByWindowId[windowId] ?? .empty + private func windowLabelsById(orderedSummaries: [MainWindowSummary], referenceWindowId: UUID?) -> [UUID: String] { + var labels: [UUID: String] = [:] + for (index, summary) in orderedSummaries.enumerated() { + if summary.windowId == referenceWindowId { + labels[summary.windowId] = String(localized: "menu.currentWindow", defaultValue: "Current Window") + } else { + let number = index + 1 + labels[summary.windowId] = String(localized: "menu.windowNumber", defaultValue: "Window \(number)") + } + } + return labels } - func isCommandPaletteVisible(for window: NSWindow) -> Bool { - guard let windowId = mainWindowId(for: window) else { return false } - return commandPaletteVisibilityByWindowId[windowId] ?? false + private func workspaceDisplayName(_ workspace: Workspace) -> String { + let trimmed = workspace.title.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? String(localized: "workspace.displayName.fallback", defaultValue: "Workspace") : trimmed } - func isCommandPaletteEffectivelyVisible(for window: NSWindow) -> Bool { - isCommandPaletteEffectivelyVisible(in: window) + private func rollbackDetachedSurface( + _ detached: Workspace.DetachedSurfaceTransfer, + to workspace: Workspace, + sourcePane: PaneID?, + sourceIndex: Int?, + focus: Bool + ) { + let rollbackPane = sourcePane.flatMap { pane in + workspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) + } ?? workspace.bonsplitController.focusedPaneId + ?? workspace.bonsplitController.allPaneIds.first + guard let rollbackPane else { return } + _ = workspace.attachDetachedSurface( + detached, + inPane: rollbackPane, + atIndex: sourceIndex, + focus: focus + ) } - func shouldBlockFirstResponderChangeWhileCommandPaletteVisible( - window: NSWindow, - responder: NSResponder? - ) -> Bool { - guard isCommandPaletteVisible(for: window) else { return false } - guard let responder else { return false } - guard !isCommandPaletteResponder(responder) else { return false } - return isFocusStealingResponderWhileCommandPaletteVisible(responder) - } + private func cleanupEmptySourceWorkspaceAfterSurfaceMove( + sourceWorkspace: Workspace, + sourceManager: TabManager, + sourceWindowId: UUID + ) { + guard sourceWorkspace.panels.isEmpty else { return } + guard sourceManager.tabs.contains(where: { $0.id == sourceWorkspace.id }) else { return } - private func isCommandPaletteResponder(_ responder: NSResponder) -> Bool { - if let textView = responder as? NSTextView, textView.isFieldEditor { - if let delegateView = textView.delegate as? NSView { - return isInsideCommandPaletteOverlay(delegateView) - } - // SwiftUI can attach a non-view delegate to TextField editors. - // When command palette is visible, its search/rename editor is the - // only expected field editor inside the main window. - return true - } - if let view = responder as? NSView { - return isInsideCommandPaletteOverlay(view) + if sourceManager.tabs.count > 1 { + sourceManager.closeWorkspace(sourceWorkspace) + } else { + _ = closeMainWindow(windowId: sourceWindowId) } - return false } - private func isFocusStealingResponderWhileCommandPaletteVisible(_ responder: NSResponder) -> Bool { - isCommandPaletteFocusStealingTerminalOrBrowserResponder(responder) - } - - private func isInsideCommandPaletteOverlay(_ view: NSView) -> Bool { - var current: NSView? = view - while let candidate = current { - if candidate.identifier == commandPaletteOverlayContainerIdentifier { - return true + private func reassertCrossWindowSurfaceMoveFocusIfNeeded( + destinationWindowId: UUID, + sourceWindowId: UUID, + destinationWorkspaceId: UUID, + destinationPanelId: UUID, + destinationManager: TabManager + ) { + let reassert: () -> Void = { [weak self, weak destinationManager] in + guard let self, let destinationManager else { return } + guard let workspace = destinationManager.tabs.first(where: { $0.id == destinationWorkspaceId }), + workspace.panels[destinationPanelId] != nil else { + return + } + guard let destinationWindow = self.mainWindow(for: destinationWindowId) else { return } + guard let keyWindow = NSApp.keyWindow, + let keyWindowId = self.mainWindowId(for: keyWindow), + keyWindowId == sourceWindowId, + keyWindow !== destinationWindow else { + return } - current = candidate.superview - } - return false - } - private func keyRoutingOwnerView(for responder: NSResponder?) -> NSView? { - guard let responder else { return nil } - if let editor = responder as? NSTextView, - editor.isFieldEditor { - return programaFieldEditorOwnerView(editor) ?? editor + self.bringToFront(destinationWindow) + destinationManager.focusTab( + destinationWorkspaceId, + surfaceId: destinationPanelId, + suppressFlash: true + ) } - return responder as? NSView + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: reassert) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.16, execute: reassert) } - private func responderHasViableKeyRoutingOwner( - _ responder: NSResponder, - in window: NSWindow - ) -> Bool { - if let ghosttyView = cmuxOwningGhosttyView(for: responder) { - if ghosttyView.window !== window { - return false - } - if ghosttyView.isHiddenOrHasHiddenAncestor { - return false - } - return ghosttyView === window.contentView || ghosttyView.superview != nil + private func windowForMainWindowId(_ windowId: UUID) -> NSWindow? { + if let ctx = mainWindowContexts.values.first(where: { $0.windowId == windowId }), + let window = ctx.window { + return window } + let expectedIdentifier = "cmux.main.\(windowId.uuidString)" + return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) + } - guard let ownerView = keyRoutingOwnerView(for: responder) else { - return false + private func resolvedWindow(for context: MainWindowContext) -> NSWindow? { + if let window = context.window { + return window } - - if ownerView.window !== window { - return false + guard let window = windowForMainWindowId(context.windowId) else { + return nil } + reindexMainWindowContextIfNeeded(context, for: window) + return window + } - if ownerView.isHiddenOrHasHiddenAncestor { - return false - } + private func mainWindowId(from window: NSWindow) -> UUID? { + guard let raw = window.identifier?.rawValue else { return nil } + let prefix = "cmux.main." + guard raw.hasPrefix(prefix) else { return nil } + let suffix = String(raw.dropFirst(prefix.count)) + return UUID(uuidString: suffix) + } - if ownerView !== window.contentView, ownerView.superview == nil { - return false + private func reindexMainWindowContextIfNeeded(_ context: MainWindowContext, for window: NSWindow) { + let desiredKey = ObjectIdentifier(window) + if mainWindowContexts[desiredKey] === context { + context.window = window + return } - return true - } - - private func responderNeedsFocusedTerminalKeyRepair( - _ responder: NSResponder?, - in window: NSWindow, - hostedView: GhosttySurfaceScrollView - ) -> Bool { - guard let responder else { return true } - if responder is NSWindow { return true } - guard responderHasViableKeyRoutingOwner(responder, in: window) else { - return true + let contextKeys = mainWindowContexts.compactMap { key, value in + value === context ? key : nil } - if hostedView.responderMatchesPreferredKeyboardFocus(responder) { - return false + for key in contextKeys { + mainWindowContexts.removeValue(forKey: key) } - return true - } - func repairFocusedTerminalKeyboardRoutingIfNeeded( - window: NSWindow, - event: NSEvent - ) { - guard event.type == .keyDown else { return } - let normalizedFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard !normalizedFlags.contains(.command) else { return } - guard isMainTerminalWindow(window) else { return } - guard window.attachedSheet == nil else { return } - guard !isCommandPaletteEffectivelyVisible(in: window) else { return } - guard let context = contextForMainWindow(window) ?? contextForMainTerminalWindow(window), - let workspace = context.tabManager.selectedWorkspace, - let panelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: panelId) else { + if let conflicting = mainWindowContexts[desiredKey], conflicting !== context { + context.window = window return } - guard responderNeedsFocusedTerminalKeyRepair( - window.firstResponder, - in: window, - hostedView: terminalPanel.hostedView - ) else { return } - -#if DEBUG - let before = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let target = terminalPanel.hostedView.preferredPanelFocusIntentForActivation() - dlog( - "focus.keyRepair attempt window=\(ObjectIdentifier(window)) " + - "workspace=\(String(workspace.id.uuidString.prefix(5))) " + - "panel=\(String(panelId.uuidString.prefix(5))) " + - "target=\(target == .findField ? "searchField" : "surface") " + - "fr=\(before) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue)" - ) -#endif - - terminalPanel.hostedView.ensureFocus(for: workspace.id, surfaceId: panelId) - -#if DEBUG - let after = window.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog( - "focus.keyRepair result window=\(ObjectIdentifier(window)) " + - "panel=\(String(panelId.uuidString.prefix(5))) " + - "isSurfaceResponder=\(terminalPanel.hostedView.isSurfaceViewFirstResponder() ? 1 : 0) " + - "fr=\(after)" - ) -#endif - } - func locateSurface(surfaceId: UUID) -> (windowId: UUID, workspaceId: UUID, tabManager: TabManager)? { - for ctx in mainWindowContexts.values { - for ws in ctx.tabManager.tabs { - if ws.panels[surfaceId] != nil { - return (ctx.windowId, ws.id, ctx.tabManager) - } - } - } - return nil + mainWindowContexts[desiredKey] = context + context.window = window + notifyMainWindowContextsDidChange() } - /// Resolve the workspace that currently owns a panel/surface ID. - /// Prefer the provided workspace when available, then fall back to global lookup. - func workspaceContainingPanel( - panelId: UUID, - preferredWorkspaceId: UUID? = nil - ) -> (workspace: Workspace, tabManager: TabManager)? { - if let preferredWorkspaceId, - let manager = tabManagerFor(tabId: preferredWorkspaceId), - let workspace = manager.tabs.first(where: { $0.id == preferredWorkspaceId }), - workspace.panels[panelId] != nil { - return (workspace, manager) - } + private func contextForMainTerminalWindow(_ window: NSWindow, reindex: Bool = true) -> MainWindowContext? { + guard isMainTerminalWindow(window) else { return nil } - if let located = locateSurface(surfaceId: panelId), - let workspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }), - workspace.panels[panelId] != nil { - return (workspace, located.tabManager) + if let context = mainWindowContexts[ObjectIdentifier(window)] { + context.window = window + return context } - if let preferredWorkspaceId, - let manager = tabManagerFor(tabId: preferredWorkspaceId) ?? tabManager, - let workspace = manager.tabs.first(where: { $0.id == preferredWorkspaceId }), - workspace.panels[panelId] != nil { - return (workspace, manager) + if let windowId = mainWindowId(from: window), + let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { + if reindex { + reindexMainWindowContextIfNeeded(context, for: window) + } else { + context.window = window + } + return context } - if let manager = tabManager, - let workspace = manager.tabs.first(where: { $0.panels[panelId] != nil }) { - return (workspace, manager) + let windowNumber = window.windowNumber + if windowNumber >= 0, + let context = mainWindowContexts.values.first(where: { candidate in + let candidateWindow = candidate.window ?? windowForMainWindowId(candidate.windowId) + return candidateWindow?.windowNumber == windowNumber + }) { + if reindex { + reindexMainWindowContextIfNeeded(context, for: window) + } else { + context.window = window + } + return context } return nil } - func locateGhosttySurface(_ surface: ghostty_surface_t?) -> (windowId: UUID, workspaceId: UUID, panelId: UUID, tabManager: TabManager)? { - guard let surface else { return nil } - for ctx in mainWindowContexts.values { - for ws in ctx.tabManager.tabs { - for (panelId, panel) in ws.panels { - guard let terminal = panel as? TerminalPanel else { continue } - if terminal.surface.surface == surface { - return (ctx.windowId, ws.id, panelId, ctx.tabManager) - } - } - } + private func unregisterMainWindowContext(for window: NSWindow) -> MainWindowContext? { + guard let removed = contextForMainTerminalWindow(window, reindex: false) else { return nil } + let removedKeys = mainWindowContexts.compactMap { key, value in + value === removed ? key : nil } - return nil + for key in removedKeys { + mainWindowContexts.removeValue(forKey: key) + } + notifyMainWindowContextsDidChange() + return removed } - func refreshTerminalSurfacesAfterGhosttyConfigReload(source: String) { - var refreshedCount = 0 - forEachTerminalPanel { terminalPanel in - terminalPanel.hostedView.reconcileGeometryNow() - terminalPanel.hostedView.refreshHostBackgroundAfterGhosttyConfigReload() - terminalPanel.surface.forceRefresh(reason: "appDelegate.refreshAfterGhosttyConfigReload") - refreshedCount += 1 + private func discardOrphanedMainWindowContext(_ context: MainWindowContext) { + let contextKeys = mainWindowContexts.compactMap { key, value in + value === context ? key : nil } -#if DEBUG - dlog("reload.config.surfaceRefresh source=\(source) count=\(refreshedCount)") -#endif - } + for key in contextKeys { + mainWindowContexts.removeValue(forKey: key) + } + notifyMainWindowContextsDidChange() - private func forEachTerminalPanel(_ body: (TerminalPanel) -> Void) { - var seenManagers: Set = [] + commandPaletteVisibilityByWindowId.removeValue(forKey: context.windowId) + commandPalettePendingOpenByWindowId.removeValue(forKey: context.windowId) + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: context.windowId) + commandPaletteEscapeSuppressionByWindowId.remove(context.windowId) + commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: context.windowId) + commandPaletteSelectionByWindowId.removeValue(forKey: context.windowId) + commandPaletteSnapshotByWindowId.removeValue(forKey: context.windowId) - func visitManager(_ manager: TabManager?) { - guard let manager else { return } - let managerId = ObjectIdentifier(manager) - guard seenManagers.insert(managerId).inserted else { return } - for workspace in manager.tabs { - for panel in workspace.panels.values { - guard let terminalPanel = panel as? TerminalPanel else { continue } - body(terminalPanel) - } + if tabManager === context.tabManager { + if let nextContext = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) { + tabManager = nextContext.tabManager + sidebarState = nextContext.sidebarState + sidebarSelectionState = nextContext.sidebarSelectionState + TerminalController.shared.setActiveTabManager(nextContext.tabManager) + } else { + tabManager = nil + sidebarState = nil + sidebarSelectionState = nil + TerminalController.shared.setActiveTabManager(nil) } } - visitManager(tabManager) - for context in mainWindowContexts.values { - visitManager(context.tabManager) + if let store = notificationStore { + for tab in context.tabManager.tabs { + store.clearNotifications(forTabId: tab.id) + } } } - func focusMainWindow(windowId: UUID) -> Bool { - guard let window = windowForMainWindowId(windowId) else { return false } - if TerminalController.shouldSuppressSocketCommandActivation(), - !TerminalController.socketCommandAllowsInAppFocusMutations() { - setActiveMainWindow(window) - return true + private func mainWindowId(for window: NSWindow) -> UUID? { + if let context = mainWindowContexts[ObjectIdentifier(window)] { + return context.windowId } - bringToFront(window) - return true - } - - func closeMainWindow(windowId: UUID) -> Bool { - guard let window = windowForMainWindowId(windowId) else { return false } - window.performClose(nil) - return true + guard let rawIdentifier = window.identifier?.rawValue, + rawIdentifier.hasPrefix("cmux.main.") else { return nil } + let idPart = String(rawIdentifier.dropFirst("cmux.main.".count)) + return UUID(uuidString: idPart) } - private func confirmCloseMainWindow(_ window: NSWindow) -> Bool { -#if DEBUG - if let debugCloseMainWindowConfirmationHandler { - return debugCloseMainWindowConfirmationHandler(window) - } -#endif - - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String(localized: "dialog.closeWindow.title", defaultValue: "Close window?") - alert.informativeText = String( - localized: "dialog.closeWindow.message", - defaultValue: "This will close the current window and all of its workspaces." - ) - alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - - let alertWindow = alert.window - if let closeButton = alert.buttons.first { - alertWindow.defaultButtonCell = closeButton.cell as? NSButtonCell - alertWindow.initialFirstResponder = closeButton - DispatchQueue.main.async { - _ = alertWindow.makeFirstResponder(closeButton) + private func commandPaletteOverlayContainer(in window: NSWindow) -> NSView? { + guard let searchRoot = window.contentView?.superview ?? window.contentView else { return nil } + var stack: [NSView] = [searchRoot] + while let candidate = stack.popLast() { + if candidate.identifier == commandPaletteOverlayContainerIdentifier { + return candidate } + stack.append(contentsOf: candidate.subviews) } + return nil + } - return alert.runModal() == .alertFirstButtonReturn + private func isCommandPaletteOverlayPresented(in window: NSWindow) -> Bool { + guard let container = commandPaletteOverlayContainer(in: window) else { return false } + return !container.isHidden && container.alphaValue > 0.001 } - @discardableResult - func closeWindowWithConfirmation(_ window: NSWindow) -> Bool { - guard isMainTerminalWindow(window) else { - window.performClose(nil) - return true + private func isCommandPaletteResponderActive(in window: NSWindow) -> Bool { + guard let responder = window.firstResponder else { return false } + if let textView = responder as? NSTextView, + textView.isFieldEditor, + !(textView.delegate is NSView) { + // Field-editor delegates can be non-view responders. Confirm the overlay is + // mounted and visible to avoid treating unrelated editors as palette input. + return isCommandPaletteOverlayPresented(in: window) } - guard confirmCloseMainWindow(window) else { return true } - window.performClose(nil) - return true + return isCommandPaletteResponder(responder) } - private func orderedMainWindowSummaries(referenceWindowId: UUID?) -> [MainWindowSummary] { - let summaries = listMainWindowSummaries() - return summaries.sorted { lhs, rhs in - let lhsIsReference = lhs.windowId == referenceWindowId - let rhsIsReference = rhs.windowId == referenceWindowId - if lhsIsReference != rhsIsReference { return lhsIsReference } - if lhs.isKeyWindow != rhs.isKeyWindow { return lhs.isKeyWindow } - if lhs.isVisible != rhs.isVisible { return lhs.isVisible } - return lhs.windowId.uuidString < rhs.windowId.uuidString + private func isCommandPaletteMultilineTextResponderActive(in window: NSWindow) -> Bool { + guard let textView = window.firstResponder as? NSTextView, + !textView.isFieldEditor else { + return false } + return isCommandPaletteResponder(textView) } - private func windowLabelsById(orderedSummaries: [MainWindowSummary], referenceWindowId: UUID?) -> [UUID: String] { - var labels: [UUID: String] = [:] - for (index, summary) in orderedSummaries.enumerated() { - if summary.windowId == referenceWindowId { - labels[summary.windowId] = String(localized: "menu.currentWindow", defaultValue: "Current Window") - } else { - let number = index + 1 - labels[summary.windowId] = String(localized: "menu.windowNumber", defaultValue: "Window \(number)") - } + private func commandPaletteMarkedTextInput(in window: NSWindow) -> NSTextView? { + if let textView = window.firstResponder as? NSTextView, + isCommandPaletteResponder(textView), + textView.hasMarkedText() { + return textView } - return labels - } - private func workspaceDisplayName(_ workspace: Workspace) -> String { - let trimmed = workspace.title.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? String(localized: "workspace.displayName.fallback", defaultValue: "Workspace") : trimmed - } + if let textField = window.firstResponder as? NSTextField, + let editor = textField.currentEditor() as? NSTextView, + isCommandPaletteResponder(editor), + editor.hasMarkedText() { + return editor + } - private func rollbackDetachedSurface( - _ detached: Workspace.DetachedSurfaceTransfer, - to workspace: Workspace, - sourcePane: PaneID?, - sourceIndex: Int?, - focus: Bool - ) { - let rollbackPane = sourcePane.flatMap { pane in - workspace.bonsplitController.allPaneIds.first(where: { $0 == pane }) - } ?? workspace.bonsplitController.focusedPaneId - ?? workspace.bonsplitController.allPaneIds.first - guard let rollbackPane else { return } - _ = workspace.attachDetachedSurface( - detached, - inPane: rollbackPane, - atIndex: sourceIndex, - focus: focus - ) + return nil } - private func cleanupEmptySourceWorkspaceAfterSurfaceMove( - sourceWorkspace: Workspace, - sourceManager: TabManager, - sourceWindowId: UUID - ) { - guard sourceWorkspace.panels.isEmpty else { return } - guard sourceManager.tabs.contains(where: { $0.id == sourceWorkspace.id }) else { return } + private func isCommandPaletteEffectivelyVisible(in window: NSWindow) -> Bool { + isCommandPaletteVisible(for: window) + || isCommandPalettePendingOpen(for: window) + || isCommandPaletteOverlayPresented(in: window) + || isCommandPaletteResponderActive(in: window) + } - if sourceManager.tabs.count > 1 { - sourceManager.closeWorkspace(sourceWorkspace) - } else { - _ = closeMainWindow(windowId: sourceWindowId) + private func activeCommandPaletteWindow() -> NSWindow? { + pruneExpiredCommandPalettePendingOpenStates() + if let keyWindow = NSApp.keyWindow, + isMainTerminalWindow(keyWindow), + isCommandPaletteEffectivelyVisible(in: keyWindow) { + return keyWindow + } + if let mainWindow = NSApp.mainWindow, + isMainTerminalWindow(mainWindow), + isCommandPaletteEffectivelyVisible(in: mainWindow) { + return mainWindow + } + if let orderedWindow = NSApp.orderedWindows.first(where: { window in + isMainTerminalWindow(window) && isCommandPaletteEffectivelyVisible(in: window) + }) { + return orderedWindow } + if let visibleWindowId = commandPaletteVisibilityByWindowId.first(where: { $0.value })?.key { + return windowForMainWindowId(visibleWindowId) + } + if let pendingWindowId = commandPalettePendingOpenByWindowId.first(where: { $0.value })?.key { + return windowForMainWindowId(pendingWindowId) + } + return nil } - private func reassertCrossWindowSurfaceMoveFocusIfNeeded( - destinationWindowId: UUID, - sourceWindowId: UUID, - destinationWorkspaceId: UUID, - destinationPanelId: UUID, - destinationManager: TabManager - ) { - let reassert: () -> Void = { [weak self, weak destinationManager] in - guard let self, let destinationManager else { return } - guard let workspace = destinationManager.tabs.first(where: { $0.id == destinationWorkspaceId }), - workspace.panels[destinationPanelId] != nil else { - return - } - guard let destinationWindow = self.mainWindow(for: destinationWindowId) else { return } - guard let keyWindow = NSApp.keyWindow, - let keyWindowId = self.mainWindowId(for: keyWindow), - keyWindowId == sourceWindowId, - keyWindow !== destinationWindow else { - return - } - - self.bringToFront(destinationWindow) - destinationManager.focusTab( - destinationWorkspaceId, - surfaceId: destinationPanelId, - suppressFlash: true - ) + private func commandPaletteWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { + if let scopedWindow = mainWindowForShortcutEvent(event) { + return scopedWindow } + return activeCommandPaletteWindow() + } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: reassert) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.16, execute: reassert) + private func contextForMainWindow(_ window: NSWindow?) -> MainWindowContext? { + guard let window, isMainTerminalWindow(window) else { return nil } + return mainWindowContexts[ObjectIdentifier(window)] } - private func windowForMainWindowId(_ windowId: UUID) -> NSWindow? { - if let ctx = mainWindowContexts.values.first(where: { $0.windowId == windowId }), - let window = ctx.window { - return window - } - let expectedIdentifier = "cmux.main.\(windowId.uuidString)" - return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) - } - - private func resolvedWindow(for context: MainWindowContext) -> NSWindow? { - if let window = context.window { - return window - } - guard let window = windowForMainWindowId(context.windowId) else { - return nil - } - reindexMainWindowContextIfNeeded(context, for: window) - return window - } - - private func mainWindowId(from window: NSWindow) -> UUID? { - guard let raw = window.identifier?.rawValue else { return nil } - let prefix = "cmux.main." - guard raw.hasPrefix(prefix) else { return nil } - let suffix = String(raw.dropFirst(prefix.count)) - return UUID(uuidString: suffix) - } - - private func reindexMainWindowContextIfNeeded(_ context: MainWindowContext, for window: NSWindow) { - let desiredKey = ObjectIdentifier(window) - if mainWindowContexts[desiredKey] === context { - context.window = window - return - } - - let contextKeys = mainWindowContexts.compactMap { key, value in - value === context ? key : nil - } - for key in contextKeys { - mainWindowContexts.removeValue(forKey: key) - } - - if let conflicting = mainWindowContexts[desiredKey], conflicting !== context { - context.window = window - return - } - - mainWindowContexts[desiredKey] = context - context.window = window - notifyMainWindowContextsDidChange() - } - - private func contextForMainTerminalWindow(_ window: NSWindow, reindex: Bool = true) -> MainWindowContext? { - guard isMainTerminalWindow(window) else { return nil } - - if let context = mainWindowContexts[ObjectIdentifier(window)] { - context.window = window - return context - } - - if let windowId = mainWindowId(from: window), - let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) { - if reindex { - reindexMainWindowContextIfNeeded(context, for: window) - } else { - context.window = window - } - return context - } - - let windowNumber = window.windowNumber - if windowNumber >= 0, - let context = mainWindowContexts.values.first(where: { candidate in - let candidateWindow = candidate.window ?? windowForMainWindowId(candidate.windowId) - return candidateWindow?.windowNumber == windowNumber - }) { - if reindex { - reindexMainWindowContextIfNeeded(context, for: window) - } else { - context.window = window - } - return context - } - - return nil - } - - private func unregisterMainWindowContext(for window: NSWindow) -> MainWindowContext? { - guard let removed = contextForMainTerminalWindow(window, reindex: false) else { return nil } - let removedKeys = mainWindowContexts.compactMap { key, value in - value === removed ? key : nil - } - for key in removedKeys { - mainWindowContexts.removeValue(forKey: key) - } - notifyMainWindowContextsDidChange() - return removed - } - - private func discardOrphanedMainWindowContext(_ context: MainWindowContext) { - let contextKeys = mainWindowContexts.compactMap { key, value in - value === context ? key : nil - } - for key in contextKeys { - mainWindowContexts.removeValue(forKey: key) - } - notifyMainWindowContextsDidChange() - - commandPaletteVisibilityByWindowId.removeValue(forKey: context.windowId) - commandPalettePendingOpenByWindowId.removeValue(forKey: context.windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: context.windowId) - commandPaletteEscapeSuppressionByWindowId.remove(context.windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: context.windowId) - commandPaletteSelectionByWindowId.removeValue(forKey: context.windowId) - commandPaletteSnapshotByWindowId.removeValue(forKey: context.windowId) - - if tabManager === context.tabManager { - if let nextContext = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) { - tabManager = nextContext.tabManager - sidebarState = nextContext.sidebarState - sidebarSelectionState = nextContext.sidebarSelectionState - TerminalController.shared.setActiveTabManager(nextContext.tabManager) - } else { - tabManager = nil - sidebarState = nil - sidebarSelectionState = nil - TerminalController.shared.setActiveTabManager(nil) - } - } - - if let store = notificationStore { - for tab in context.tabManager.tabs { - store.clearNotifications(forTabId: tab.id) - } - } - } - - private func mainWindowId(for window: NSWindow) -> UUID? { - if let context = mainWindowContexts[ObjectIdentifier(window)] { - return context.windowId - } - guard let rawIdentifier = window.identifier?.rawValue, - rawIdentifier.hasPrefix("cmux.main.") else { return nil } - let idPart = String(rawIdentifier.dropFirst("cmux.main.".count)) - return UUID(uuidString: idPart) - } - - private func commandPaletteOverlayContainer(in window: NSWindow) -> NSView? { - guard let searchRoot = window.contentView?.superview ?? window.contentView else { return nil } - var stack: [NSView] = [searchRoot] - while let candidate = stack.popLast() { - if candidate.identifier == commandPaletteOverlayContainerIdentifier { - return candidate - } - stack.append(contentsOf: candidate.subviews) - } - return nil - } - - private func isCommandPaletteOverlayPresented(in window: NSWindow) -> Bool { - guard let container = commandPaletteOverlayContainer(in: window) else { return false } - return !container.isHidden && container.alphaValue > 0.001 - } - - private func isCommandPaletteResponderActive(in window: NSWindow) -> Bool { - guard let responder = window.firstResponder else { return false } - if let textView = responder as? NSTextView, - textView.isFieldEditor, - !(textView.delegate is NSView) { - // Field-editor delegates can be non-view responders. Confirm the overlay is - // mounted and visible to avoid treating unrelated editors as palette input. - return isCommandPaletteOverlayPresented(in: window) - } - return isCommandPaletteResponder(responder) - } - - private func isCommandPaletteMultilineTextResponderActive(in window: NSWindow) -> Bool { - guard let textView = window.firstResponder as? NSTextView, - !textView.isFieldEditor else { - return false - } - return isCommandPaletteResponder(textView) - } - - private func commandPaletteMarkedTextInput(in window: NSWindow) -> NSTextView? { - if let textView = window.firstResponder as? NSTextView, - isCommandPaletteResponder(textView), - textView.hasMarkedText() { - return textView - } - - if let textField = window.firstResponder as? NSTextField, - let editor = textField.currentEditor() as? NSTextView, - isCommandPaletteResponder(editor), - editor.hasMarkedText() { - return editor - } - - return nil - } - - private func isCommandPaletteEffectivelyVisible(in window: NSWindow) -> Bool { - isCommandPaletteVisible(for: window) - || isCommandPalettePendingOpen(for: window) - || isCommandPaletteOverlayPresented(in: window) - || isCommandPaletteResponderActive(in: window) - } - - private func activeCommandPaletteWindow() -> NSWindow? { - pruneExpiredCommandPalettePendingOpenStates() - if let keyWindow = NSApp.keyWindow, - isMainTerminalWindow(keyWindow), - isCommandPaletteEffectivelyVisible(in: keyWindow) { - return keyWindow - } - if let mainWindow = NSApp.mainWindow, - isMainTerminalWindow(mainWindow), - isCommandPaletteEffectivelyVisible(in: mainWindow) { - return mainWindow - } - if let orderedWindow = NSApp.orderedWindows.first(where: { window in - isMainTerminalWindow(window) && isCommandPaletteEffectivelyVisible(in: window) - }) { - return orderedWindow - } - if let visibleWindowId = commandPaletteVisibilityByWindowId.first(where: { $0.value })?.key { - return windowForMainWindowId(visibleWindowId) - } - if let pendingWindowId = commandPalettePendingOpenByWindowId.first(where: { $0.value })?.key { - return windowForMainWindowId(pendingWindowId) - } - return nil - } - - private func commandPaletteWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { - if let scopedWindow = mainWindowForShortcutEvent(event) { - return scopedWindow - } - return activeCommandPaletteWindow() - } - - private func contextForMainWindow(_ window: NSWindow?) -> MainWindowContext? { - guard let window, isMainTerminalWindow(window) else { return nil } - return mainWindowContexts[ObjectIdentifier(window)] - } - -#if DEBUG - func debugManagerToken(_ manager: TabManager?) -> String { - guard let manager else { return "nil" } - return String(describing: Unmanaged.passUnretained(manager).toOpaque()) +#if DEBUG + func debugManagerToken(_ manager: TabManager?) -> String { + guard let manager else { return "nil" } + return String(describing: Unmanaged.passUnretained(manager).toOpaque()) } private func debugWindowToken(_ window: NSWindow?) -> String { @@ -6150,5722 +4576,4343 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent setActiveMainWindow(window) if shouldBringToFront { bringToFront(window) - } - - let workspace: Workspace - if let workingDirectory { - workspace = context.tabManager.addWorkspace(workingDirectory: workingDirectory, select: true) - } else { - workspace = context.tabManager.addTab(select: true) - } - #if DEBUG - logWorkspaceCreationRouting( - phase: "created", - source: debugSource, - reason: "workspace_created", - event: event, - chosenContext: context, - workspaceId: workspace.id, - workingDirectory: workingDirectory - ) - #endif - return workspace.id - } - - private func preferredMainWindowContextForWorkspaceCreation( - event: NSEvent? = nil, - debugSource: String = "unspecified" - ) -> MainWindowContext? { - if let context = mainWindowContext(forShortcutEvent: event, debugSource: debugSource) { - return context - } - - // If a keyboard event identifies a specific window but that context - // can't be resolved, do not fall back to another window. - if shortcutEventHasAddressableWindow(event) { -#if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "event_context_required_no_fallback", - event: event, - chosenContext: nil - ) -#endif - return nil - } - - if let keyWindow = NSApp.keyWindow, - let context = contextForMainTerminalWindow(keyWindow) { -#if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "key_window", - event: event, - chosenContext: context - ) - #endif - return context - } - - if let mainWindow = NSApp.mainWindow, - let context = contextForMainTerminalWindow(mainWindow) { - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "main_window", - event: event, - chosenContext: context - ) - #endif - return context - } - - for window in NSApp.orderedWindows where isMainTerminalWindow(window) { - if let context = contextForMainTerminalWindow(window) { - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "ordered_windows", - event: event, - chosenContext: context - ) - #endif - return context - } - } - - let fallback = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "fallback_first_context", - event: event, - chosenContext: fallback - ) -#endif - return fallback - } - - private func shortcutEventHasAddressableWindow(_ event: NSEvent?) -> Bool { - guard let event else { return false } - // NSEvent.windowNumber can be 0 for responder-chain events that are not - // actually bound to an NSWindow (notably some WebKit key paths). - return event.window != nil || event.windowNumber > 0 - } - - private func mainWindowContext( - forShortcutEvent event: NSEvent?, - debugSource: String = "unspecified" - ) -> MainWindowContext? { - guard let event else { return nil } - - if let eventWindow = event.window, - let context = contextForMainTerminalWindow(eventWindow) { - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "event_window", - event: event, - chosenContext: context - ) - #endif - return context - } - - if event.windowNumber > 0, - let numberedWindow = NSApp.window(withWindowNumber: event.windowNumber), - let context = contextForMainTerminalWindow(numberedWindow) { - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "event_window_number", - event: event, - chosenContext: context - ) - #endif - return context - } - - if event.windowNumber > 0, - let context = mainWindowContexts.values.first(where: { candidate in - let window = candidate.window ?? windowForMainWindowId(candidate.windowId) - return window?.windowNumber == event.windowNumber - }) { - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "event_window_number_scan", - event: event, - chosenContext: context - ) - #endif - return context - } - - #if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: debugSource, - reason: "event_context_not_found", - event: event, - chosenContext: nil - ) - #endif - return nil - } - - private func preferredMainWindowContextForShortcutRouting(event: NSEvent) -> MainWindowContext? { - if let context = mainWindowContext(forShortcutEvent: event, debugSource: "shortcut.routing") { - return context - } - - if shortcutEventHasAddressableWindow(event) { - if let eventWindow = resolvedShortcutEventWindow(event), - programaWindowShouldOwnCloseShortcut(eventWindow) { - // Auxiliary cmux windows do not own a terminal tab manager. Let them fall back - // to the active main terminal window so app shortcuts like Cmd+W still route. - } else { -#if DEBUG - logWorkspaceCreationRouting( - phase: "choose", - source: "shortcut.routing", - reason: "event_context_required_no_fallback", - event: event, - chosenContext: nil - ) -#endif - return nil - } - } - - if let keyWindow = NSApp.keyWindow, - let context = contextForMainTerminalWindow(keyWindow) { - return context - } - - if let mainWindow = NSApp.mainWindow, - let context = contextForMainTerminalWindow(mainWindow) { - return context - } - - if let activeManager = tabManager, - let context = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { - return context - } - - return mainWindowContexts.values.first - } - - @discardableResult - private func synchronizeShortcutRoutingContext(event: NSEvent) -> Bool { - guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { -#if DEBUG - FocusLogStore.shared.append( - "shortcut.route reason=no_context_no_fallback eventWin=\(event.windowNumber) keyCode=\(event.keyCode)" - ) -#endif - return false - } - - let alreadyActive = - tabManager === context.tabManager - && sidebarState === context.sidebarState - && sidebarSelectionState === context.sidebarSelectionState - if alreadyActive { return true } - - if let window = context.window ?? windowForMainWindowId(context.windowId) { - setActiveMainWindow(window) - } else { - tabManager = context.tabManager - sidebarState = context.sidebarState - sidebarSelectionState = context.sidebarSelectionState - TerminalController.shared.setActiveTabManager(context.tabManager) - } - -#if DEBUG - FocusLogStore.shared.append( - "shortcut.route reason=sync activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(context))}" - ) -#endif - return true - } - - @discardableResult - func createMainWindow( - initialWorkingDirectory: String? = nil, - sessionWindowSnapshot: SessionWindowSnapshot? = nil - ) -> UUID { - let windowId = UUID() - let tabManager = TabManager(initialWorkingDirectory: initialWorkingDirectory) - if let tabManagerSnapshot = sessionWindowSnapshot?.tabManager { - tabManager.restoreSessionSnapshot(tabManagerSnapshot) - } - - let sidebarWidth = sessionWindowSnapshot?.sidebar.width - .map(SessionPersistencePolicy.sanitizedSidebarWidth) - ?? SessionPersistencePolicy.defaultSidebarWidth - let sidebarState = SidebarState( - isVisible: sessionWindowSnapshot?.sidebar.isVisible ?? true, - persistedWidth: CGFloat(sidebarWidth) - ) - let sidebarSelectionState = SidebarSelectionState( - selection: sessionWindowSnapshot?.sidebar.selection.sidebarSelection ?? .tabs - ) - let notificationStore = TerminalNotificationStore.shared - - let programaConfigStore = ProgramaConfigStore() - programaConfigStore.wireDirectoryTracking(tabManager: tabManager) - programaConfigStore.loadAll() - - let root = ContentView(updateViewModel: updateViewModel, windowId: windowId) - .environmentObject(tabManager) - .environmentObject(notificationStore) - .environmentObject(sidebarState) - .environmentObject(sidebarSelectionState) - .environmentObject(programaConfigStore) - - // Use the current key window's size for new windows so Cmd+Shift+N - // creates a window matching the previous one's dimensions. - let styleMask: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] - let sourceContext = preferredMainWindowContextForWorkspaceCreation( - debugSource: "createMainWindow.initialGeometry" - ) - let sourceWindow = sourceContext.flatMap { resolvedWindow(for: $0) } - let existingFrame = sourceWindow?.frame - let sourceWindowIsNativeFullScreen: Bool = { -#if DEBUG - if let debugCreateMainWindowSourceIsNativeFullScreenOverride { - return debugCreateMainWindowSourceIsNativeFullScreenOverride - } -#endif - return sourceWindow?.styleMask.contains(.fullScreen) == true - }() - let shouldTemporarilyDisallowFullScreenTiling = - sessionWindowSnapshot == nil && sourceWindowIsNativeFullScreen - let initialRect: NSRect - if sessionWindowSnapshot == nil, let existingFrame { - // Convert frame rect to content rect so the new window matches the - // source window's actual size (frame includes titlebar insets). - initialRect = NSWindow.contentRect(forFrameRect: existingFrame, styleMask: styleMask) - } else { - initialRect = NSRect(x: 0, y: 0, width: 460, height: 360) - } - - let window = NSWindow( - contentRect: initialRect, - styleMask: styleMask, - backing: .buffered, - defer: false - ) - // When creating a new window from an existing native fullscreen window, - // temporarily opt out of fullscreen tiling so AppKit doesn't place the - // new window into the active fullscreen Space. - if shouldTemporarilyDisallowFullScreenTiling { - window.collectionBehavior.insert(.fullScreenDisallowsTiling) - } - window.title = "" - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true - window.isMovableByWindowBackground = false - window.isMovable = false - let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) - if let restoredFrame { - window.setFrame(restoredFrame, display: false) - } else { - window.center() - // Cascade using the same algorithm as upstream Ghostty: seed from - // the window's own top-left on the first call, then advance the - // cascade point for each subsequent window. - if mainWindowContexts.count >= 1 { - lastCascadePoint = window.cascadeTopLeft(from: lastCascadePoint) - } else { - lastCascadePoint = window.cascadeTopLeft(from: NSPoint(x: window.frame.minX, y: window.frame.maxY)) - } - } - window.contentView = MainWindowHostingView(rootView: root) - - // Apply shared window styling. - attachUpdateAccessory(to: window) - applyWindowDecorations(to: window) - - // Keep a strong reference so the window isn't deallocated. - let controller = MainWindowController(window: window) - controller.onClose = { [weak self, weak controller] in - guard let self, let controller else { return } - self.mainWindowControllers.removeAll(where: { $0 === controller }) - } - window.delegate = controller - mainWindowControllers.append(controller) - - registerMainWindow( - window, - windowId: windowId, - tabManager: tabManager, - sidebarState: sidebarState, - sidebarSelectionState: sidebarSelectionState - ) - installFileDropOverlay(on: window, tabManager: tabManager) - if TerminalController.shouldSuppressSocketCommandActivation() { - window.orderFront(nil) - if TerminalController.socketCommandAllowsInAppFocusMutations() { - setActiveMainWindow(window) - } - } else { - window.makeKeyAndOrderFront(nil) - setActiveMainWindow(window) - NSApp.activate(ignoringOtherApps: true) - } - if shouldTemporarilyDisallowFullScreenTiling { - DispatchQueue.main.async { [weak window] in - window?.collectionBehavior.remove(.fullScreenDisallowsTiling) - } - } - if let restoredFrame { - window.setFrame(restoredFrame, display: true) -#if DEBUG - dlog( - "session.restore.frameApplied window=\(windowId.uuidString.prefix(8)) " + - "applied={\(debugNSRectDescription(window.frame))}" - ) -#endif - } - return windowId - } - - @objc func checkForUpdates(_ sender: Any?) { - updateViewModel.overrideState = nil - updateController.checkForUpdates() - } - - func checkForUpdatesInCustomUI() { - updateViewModel.overrideState = nil - updateController.checkForUpdatesInCustomUI() - } - - func openWelcomeWorkspace() { - guard let context = preferredMainWindowContextForWorkspaceCreation(event: nil, debugSource: "welcome") else { - return - } - if let window = context.window ?? windowForMainWindowId(context.windowId) { - setActiveMainWindow(window) - bringToFront(window) - } - let workspace = context.tabManager.addWorkspace(select: true, autoWelcomeIfNeeded: false) - sendWelcomeCommandWhenReady(to: workspace) - } - - func sendWelcomeCommandWhenReady(to workspace: Workspace, markShownOnSend: Bool = false) { - sendTextWhenReady("programa welcome\n", to: workspace) { - if markShownOnSend { - UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) - } - } - } - - @objc func applyUpdateIfAvailable(_ sender: Any?) { - updateViewModel.overrideState = nil - updateController.installUpdate() - } - - @objc func attemptUpdate(_ sender: Any?) { - updateViewModel.overrideState = nil - updateController.attemptUpdate() - } - - func isProgramaCLIInstalledInPATH() -> Bool { - ProgramaCLIPathInstaller().isInstalled() - } - - @objc func installProgramaCLIInPath(_ sender: Any?) { - let installer = ProgramaCLIPathInstaller() - do { - let outcome = try installer.install() - var informativeText = String(localized: "cli.install.symlinkCreated", defaultValue: "Created symlink:\n\n\(outcome.destinationURL.path) -> \(outcome.sourceURL.path)") - if outcome.usedAdministratorPrivileges { - informativeText += "\n\n" + String(localized: "cli.install.adminRequired", defaultValue: "Administrator privileges were required to write to /usr/local/bin.") - } - presentCLIPathAlert( - title: String(localized: "cli.installed", defaultValue: "Programa CLI Installed"), - informativeText: informativeText, - style: .informational - ) - } catch { - presentCLIPathAlert( - title: String(localized: "cli.installFailed", defaultValue: "Couldn't Install Programa CLI"), - informativeText: error.localizedDescription, - style: .warning - ) - } - } - - @objc func uninstallProgramaCLIInPath(_ sender: Any?) { - let installer = ProgramaCLIPathInstaller() - do { - let outcome = try installer.uninstall() - let prefix = outcome.removedExistingEntry - ? String(localized: "cli.uninstall.removed", defaultValue: "Removed \(outcome.destinationURL.path).") - : String(localized: "cli.uninstall.notFound", defaultValue: "No Programa CLI symlink was found at \(outcome.destinationURL.path).") - var informativeText = prefix - if outcome.usedAdministratorPrivileges { - informativeText += "\n\n" + String(localized: "cli.uninstall.adminRequired", defaultValue: "Administrator privileges were required to modify /usr/local/bin.") - } - presentCLIPathAlert( - title: String(localized: "cli.uninstalled", defaultValue: "Programa CLI Uninstalled"), - informativeText: informativeText, - style: .informational - ) - } catch { - presentCLIPathAlert( - title: String(localized: "cli.uninstallFailed", defaultValue: "Couldn't Uninstall Programa CLI"), - informativeText: error.localizedDescription, - style: .warning - ) - } - } - - private func presentCLIPathAlert( - title: String, - informativeText: String, - style: NSAlert.Style - ) { - let alert = NSAlert() - alert.alertStyle = style - alert.messageText = title - alert.informativeText = informativeText - alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) - - if let window = NSApp.keyWindow ?? NSApp.mainWindow { - alert.beginSheetModal(for: window, completionHandler: nil) - } else { - _ = alert.runModal() - } - } - - @objc func restartSocketListener(_ sender: Any?) { - guard tabManager != nil else { - NSSound.beep() - return - } - - guard socketListenerConfigurationIfEnabled() != nil else { - TerminalController.shared.stop() - NSSound.beep() - return - } - restartSocketListenerIfEnabled(source: "menu.command") - } - - private func setupMenuBarExtra() { - guard menuBarExtraController == nil else { return } - let store = TerminalNotificationStore.shared - menuBarExtraController = MenuBarExtraController( - notificationStore: store, - onShowNotifications: { [weak self] in - self?.showNotificationsPopoverFromMenuBar() - }, - onOpenNotification: { [weak self] notification in - _ = self?.openNotification( - tabId: notification.tabId, - surfaceId: notification.surfaceId, - notificationId: notification.id - ) - }, - onJumpToLatestUnread: { [weak self] in - self?.jumpToLatestUnread() - }, - onCheckForUpdates: { [weak self] in - self?.checkForUpdates(nil) - }, - onOpenPreferences: { [weak self] in - self?.openPreferencesWindow(debugSource: "menuBarExtra") - }, - onQuitApp: { - NSApp.terminate(nil) - } - ) - } - - private func installMenuBarVisibilityObserver() { - guard menuBarVisibilityObserver == nil else { return } - menuBarVisibilityObserver = NotificationCenter.default.addObserver( - forName: UserDefaults.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.syncMenuBarExtraVisibility() - } - } - } - - private func syncMenuBarExtraVisibility(defaults: UserDefaults = .standard) { - if MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults) { - setupMenuBarExtra() - return - } - - menuBarExtraController?.removeFromMenuBar() - menuBarExtraController = nil - } - - @MainActor - static func presentPreferencesWindow( - navigationTarget: SettingsNavigationTarget? = nil, - showFallbackSettingsWindow: @MainActor (SettingsNavigationTarget?) -> Void = { target in - SettingsWindowController.shared.show(navigationTarget: target) - }, - activateApplication: @MainActor () -> Void = { - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) - } - ) { -#if DEBUG - dlog("settings.open.present path=customWindowDirect") -#endif - showFallbackSettingsWindow(navigationTarget) - activateApplication() - if let window = SettingsWindowController.shared.window { - window.orderFrontRegardless() - window.makeKeyAndOrderFront(nil) - DispatchQueue.main.async { - window.orderFrontRegardless() - window.makeKeyAndOrderFront(nil) - } - } -#if DEBUG - dlog("settings.open.present activate=1") -#endif - } - - @MainActor - func openPreferencesWindow(debugSource: String, navigationTarget: SettingsNavigationTarget? = nil) { -#if DEBUG - dlog("settings.open.request source=\(debugSource)") -#endif - Self.presentPreferencesWindow(navigationTarget: navigationTarget) - } - - @objc func openPreferencesWindow() { - openPreferencesWindow(debugSource: "appDelegate") - } - - func refreshMenuBarExtraForDebug() { - menuBarExtraController?.refreshForDebugControls() - } - - func showNotificationsPopoverFromMenuBar() { - let context: MainWindowContext? = { - if let keyWindow = NSApp.keyWindow, - let keyContext = contextForMainTerminalWindow(keyWindow) { - return keyContext - } - if let first = mainWindowContexts.values.first { - return first - } - let windowId = createMainWindow() - return mainWindowContexts.values.first(where: { $0.windowId == windowId }) - }() - - if let context, - let window = context.window ?? windowForMainWindowId(context.windowId) { - setActiveMainWindow(window) - bringToFront(window) - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.titlebarAccessoryController.showNotificationsPopover(animated: false) - } - } - - #if DEBUG - @objc func showUpdatePill(_ sender: Any?) { - updateViewModel.debugOverrideText = nil - updateViewModel.overrideState = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {})) - } - - @objc func showUpdatePillLongNightly(_ sender: Any?) { - updateViewModel.debugOverrideText = "Update Available: 0.32.0-nightly+20260216.abc1234" - updateViewModel.overrideState = .notFound(.init(acknowledgement: {})) - } - - @objc func showUpdatePillLoading(_ sender: Any?) { - updateViewModel.debugOverrideText = nil - updateViewModel.overrideState = .checking(.init(cancel: {})) - } - - @objc func hideUpdatePill(_ sender: Any?) { - updateViewModel.debugOverrideText = nil - updateViewModel.overrideState = .idle - } - - @objc func clearUpdatePillOverride(_ sender: Any?) { - updateViewModel.debugOverrideText = nil - updateViewModel.overrideState = nil - } -#endif - - @objc func copyUpdateLogs(_ sender: Any?) { - let logText = UpdateLogStore.shared.snapshot() - let payload: String - if logText.isEmpty { - payload = "No update logs captured.\nLog file: \(UpdateLogStore.shared.logPath())" - } else { - payload = logText + "\nLog file: \(UpdateLogStore.shared.logPath())" - } - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(payload, forType: .string) - } - @objc func copyFocusLogs(_ sender: Any?) { - let logText = FocusLogStore.shared.snapshot() - let payload: String - if logText.isEmpty { - payload = "No focus logs captured.\nLog file: \(FocusLogStore.shared.logPath())" - } else { - payload = logText + "\nLog file: \(FocusLogStore.shared.logPath())" - } - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(payload, forType: .string) - } - - @objc private func handleReactGrabDidCopySelection(_ notification: Notification) { - let browserPanelId = notification.userInfo?[ReactGrabPastebackNotificationKey.browserPanelId] as? UUID - guard let workspaceId = notification.userInfo?[ReactGrabPastebackNotificationKey.workspaceId] as? UUID, - let returnPanelId = notification.userInfo?[ReactGrabPastebackNotificationKey.returnPanelId] as? UUID, - let content = notification.userInfo?[ReactGrabPastebackNotificationKey.content] as? String else { -#if DEBUG - dlog( - "reactGrab.pasteback h3.didCopy.drop " + - "reason=missingNotificationFields " + - "workspace=\(Self.debugShortId(notification.userInfo?[ReactGrabPastebackNotificationKey.workspaceId] as? UUID)) " + - "browser=\(Self.debugShortId(browserPanelId)) " + - "return=\(Self.debugShortId(notification.userInfo?[ReactGrabPastebackNotificationKey.returnPanelId] as? UUID)) " + - "hasContent=\((notification.userInfo?[ReactGrabPastebackNotificationKey.content] as? String) != nil ? 1 : 0)" - ) -#endif - return - } - - guard let manager = tabManagerFor(tabId: workspaceId), - let workspace = manager.tabs.first(where: { $0.id == workspaceId }) else { -#if DEBUG - dlog( - "reactGrab.pasteback h3.didCopy.drop " + - "reason=missingWorkspace workspace=\(Self.debugShortId(workspaceId)) " + - "browser=\(Self.debugShortId(browserPanelId)) return=\(Self.debugShortId(returnPanelId))" - ) -#endif - return - } - - guard workspace.terminalPanel(for: returnPanelId) != nil else { -#if DEBUG - dlog( - "reactGrab.pasteback h3.didCopy.drop " + - "reason=missingReturnTerminal workspace=\(Self.debugShortId(workspaceId)) " + - "browser=\(Self.debugShortId(browserPanelId)) return=\(Self.debugShortId(returnPanelId)) " + - "focused=\(Self.debugShortId(workspace.focusedPanelId))" - ) -#endif - return - } - -#if DEBUG - dlog( - "reactGrab.pasteback h3.didCopy " + - "workspace=\(Self.debugShortId(workspaceId)) " + - "browser=\(Self.debugShortId(browserPanelId)) " + - "return=\(Self.debugShortId(returnPanelId)) " + - "focusedBefore=\(Self.debugShortId(workspace.focusedPanelId)) len=\(content.count)" - ) -#endif - manager.focusTab(workspaceId, surfaceId: returnPanelId, suppressFlash: true) -#if DEBUG - dlog( - "reactGrab.pasteback h1.focusRequested " + - "workspace=\(Self.debugShortId(workspaceId)) " + - "return=\(Self.debugShortId(returnPanelId)) " + - "focusedAfterRequest=\(Self.debugShortId(workspace.focusedPanelId))" - ) -#endif - sendTextWhenReady(content, to: workspace, preferredPanelId: returnPanelId) - } - - nonisolated private static func debugShortId(_ id: UUID?) -> String { - id.map { String($0.uuidString.prefix(5)) } ?? "nil" - } - - static func resolveTerminalPanelForTextSend(in tab: Tab, preferredPanelId: UUID? = nil) -> TerminalPanel? { - if let preferredPanelId { - return tab.terminalPanel(for: preferredPanelId) - } - return tab.focusedTerminalPanel - } - - func sendTextWhenReady( - _ text: String, - to tab: Tab, - preferredPanelId: UUID? = nil, - beforeSend: (() -> Void)? = nil - ) { - let isReactGrabPasteback = preferredPanelId != nil -#if DEBUG - let initialTargetPanel = Self.resolveTerminalPanelForTextSend( - in: tab, - preferredPanelId: preferredPanelId - ) - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.send.start " + - "workspace=\(Self.debugShortId(tab.id)) " + - "preferred=\(Self.debugShortId(preferredPanelId)) " + - "focused=\(Self.debugShortId(tab.focusedPanelId)) " + - "focusedTerminal=\(Self.debugShortId(tab.focusedTerminalPanel?.id)) " + - "resolved=\(Self.debugShortId(initialTargetPanel?.id)) " + - "surfaceReady=\(initialTargetPanel?.surface.surface != nil ? 1 : 0) len=\(text.count)" - ) - } -#endif - if let terminalPanel = Self.resolveTerminalPanelForTextSend( - in: tab, - preferredPanelId: preferredPanelId - ), - terminalPanel.surface.surface != nil { -#if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.send.immediate " + - "workspace=\(Self.debugShortId(tab.id)) " + - "target=\(Self.debugShortId(terminalPanel.id)) len=\(text.count)" - ) - } -#endif - beforeSend?() - terminalPanel.sendText(text) -#if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.send.sent " + - "workspace=\(Self.debugShortId(tab.id)) " + - "target=\(Self.debugShortId(terminalPanel.id)) mode=immediate len=\(text.count)" - ) - } -#endif - return - } - - var resolved = false - var readyObserver: NSObjectProtocol? - var focusObserver: NSObjectProtocol? - var firstResponderObserver: NSObjectProtocol? - var panelsCancellable: AnyCancellable? - - func cleanupObservers() { - if let readyObserver { - NotificationCenter.default.removeObserver(readyObserver) - } - if let focusObserver { - NotificationCenter.default.removeObserver(focusObserver) - } - if let firstResponderObserver { - NotificationCenter.default.removeObserver(firstResponderObserver) - } - panelsCancellable?.cancel() - } - - func finishIfReady() { - let terminalPanel = Self.resolveTerminalPanelForTextSend( - in: tab, - preferredPanelId: preferredPanelId - ) -#if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.finishIfReady " + - "workspace=\(Self.debugShortId(tab.id)) " + - "preferred=\(Self.debugShortId(preferredPanelId)) " + - "focused=\(Self.debugShortId(tab.focusedPanelId)) " + - "resolved=\(Self.debugShortId(terminalPanel?.id)) " + - "surfaceReady=\(terminalPanel?.surface.surface != nil ? 1 : 0) alreadyResolved=\(resolved ? 1 : 0)" - ) - } -#endif - guard !resolved, - let terminalPanel, - terminalPanel.surface.surface != nil else { return } - resolved = true - cleanupObservers() - beforeSend?() - terminalPanel.sendText(text) -#if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.send.sent " + - "workspace=\(Self.debugShortId(tab.id)) " + - "target=\(Self.debugShortId(terminalPanel.id)) mode=delayed len=\(text.count)" - ) - } -#endif - } - - panelsCancellable = tab.$panels - .map { _ in () } - .sink { _ in -#if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.panelsChanged " + - "workspace=\(Self.debugShortId(tab.id)) " + - "focused=\(Self.debugShortId(tab.focusedPanelId))" - ) - } -#endif - finishIfReady() - } - if isReactGrabPasteback { - focusObserver = NotificationCenter.default.addObserver( - forName: .ghosttyDidFocusSurface, - object: nil, - queue: .main - ) { note in - guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, - candidateTabId == tab.id, - let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { - return - } -#if DEBUG - dlog( - "reactGrab.pasteback h1.focusEvent " + - "workspace=\(Self.debugShortId(candidateTabId)) " + - "surface=\(Self.debugShortId(candidateSurfaceId)) " + - "target=\(Self.debugShortId(preferredPanelId)) " + - "match=\(candidateSurfaceId == preferredPanelId ? 1 : 0)" - ) -#endif - } - firstResponderObserver = NotificationCenter.default.addObserver( - forName: .ghosttyDidBecomeFirstResponderSurface, - object: nil, - queue: .main - ) { note in - guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, - candidateTabId == tab.id, - let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { - return - } -#if DEBUG - dlog( - "reactGrab.pasteback h1.firstResponderEvent " + - "workspace=\(Self.debugShortId(candidateTabId)) " + - "surface=\(Self.debugShortId(candidateSurfaceId)) " + - "target=\(Self.debugShortId(preferredPanelId)) " + - "match=\(candidateSurfaceId == preferredPanelId ? 1 : 0)" - ) -#endif - } - } - readyObserver = NotificationCenter.default.addObserver( - forName: .terminalSurfaceDidBecomeReady, - object: nil, - queue: .main - ) { note in - guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, - workspaceId == tab.id else { return } - let surfaceId = note.userInfo?["surfaceId"] as? UUID -#if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.surfaceReadyEvent " + - "workspace=\(Self.debugShortId(workspaceId)) " + - "surface=\(Self.debugShortId(surfaceId)) " + - "target=\(Self.debugShortId(preferredPanelId)) " + - "match=\(surfaceId == preferredPanelId ? 1 : 0)" - ) - } -#endif - if let preferredPanelId, - let surfaceId, - surfaceId != preferredPanelId { - return - } - finishIfReady() - } - DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { - if !resolved { -#if DEBUG - if isReactGrabPasteback { - dlog( - "reactGrab.pasteback h2.send.timeout " + - "workspace=\(Self.debugShortId(tab.id)) " + - "preferred=\(Self.debugShortId(preferredPanelId)) " + - "focused=\(Self.debugShortId(tab.focusedPanelId)) " + - "focusedTerminal=\(Self.debugShortId(tab.focusedTerminalPanel?.id))" - ) - } -#endif - cleanupObservers() - NSLog("Command send: surface not ready after 3.0s") - } - } - } - -#if DEBUG - private let debugColorWorkspaceTitlePrefix = "Debug Color - " - let debugPerfWorkspaceTitlePrefix = "Debug Perf - " - var debugStressWorkspaceCreationInProgress = false - var debugStressLagProbeEnabled = false - let debugStressWorkspaceCount = 20 - let debugStressPaneCount = 4 - let debugStressTabsPerPane = 4 - let debugStressYieldInterval = 4 - let debugStressSurfaceLoadTimeoutSeconds: TimeInterval = 10.0 - - @objc func openDebugScrollbackTab(_ sender: Any?) { - guard let tabManager else { return } - let tab = tabManager.addTab() - let config = GhosttyConfig.load() - let lineCount = min(max(config.scrollbackLimit * 2, 2000), 60000) - let command = "for i in {1..\(lineCount)}; do printf \"scrollback %06d\\n\" $i; done\n" - sendTextWhenReady(command, to: tab) - } - - @objc func openDebugLoremTab(_ sender: Any?) { - guard let tabManager else { return } - let tab = tabManager.addTab() - let lineCount = 2000 - let base = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore." - var lines: [String] = [] - lines.reserveCapacity(lineCount) - for index in 1...lineCount { - lines.append(String(format: "%04d %@", index, base)) - } - let payload = lines.joined(separator: "\n") + "\n" - sendTextWhenReady(payload, to: tab) - } - - @objc func openDebugColorComparisonWorkspaces(_ sender: Any?) { - guard let tabManager else { return } - - let palette = WorkspaceTabColorSettings.palette() - guard !palette.isEmpty else { return } - - var existingByTitle: [String: Workspace] = [:] - for tab in tabManager.tabs { - guard let title = tab.customTitle, - title.hasPrefix(debugColorWorkspaceTitlePrefix) else { continue } - existingByTitle[title] = tab - } - - for entry in palette { - let title = "\(debugColorWorkspaceTitlePrefix)\(entry.name)" - let targetTab: Workspace - if let existing = existingByTitle[title] { - targetTab = existing - } else { - targetTab = tabManager.addTab() - } - tabManager.setCustomTitle(tabId: targetTab.id, title: title) - tabManager.setTabColor(tabId: targetTab.id, color: entry.hex) - } - } - - -#endif - - - func attachUpdateAccessory(to window: NSWindow) { - titlebarAccessoryController.start() - titlebarAccessoryController.attach(to: window) - } - - func applyWindowDecorations(to window: NSWindow) { - windowDecorationsController.apply(to: window) - } - - func toggleNotificationsPopover(animated: Bool = true, anchorView: NSView? = nil) { - titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView) - } - - @discardableResult - func dismissNotificationsPopoverIfShown() -> Bool { - titlebarAccessoryController.dismissNotificationsPopoverIfShown() - } - - func isNotificationsPopoverShown() -> Bool { - titlebarAccessoryController.isNotificationsPopoverShown() - } - - func jumpToLatestUnread() { - guard let notificationStore else { return } -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData([ - "jumpUnreadInvoked": "1", - "jumpUnreadNotificationCount": String(notificationStore.notifications.count), - ]) - } -#endif - // Prefer the latest unread that we can actually open. In early startup (especially on the VM), - // the window-context registry can lag behind model initialization, so fall back to whatever - // tab manager currently owns the tab. - for notification in notificationStore.notifications where !notification.isRead { - if openNotification(tabId: notification.tabId, surfaceId: notification.surfaceId, notificationId: notification.id) { - return - } - } - } - - static func installWindowResponderSwizzlesForTesting() { - _ = didInstallWindowKeyEquivalentSwizzle - _ = didInstallWindowFirstResponderSwizzle - _ = didInstallWindowSendEventSwizzle - } - -#if DEBUG - static func setWindowFirstResponderGuardTesting(currentEvent: NSEvent?, hitView: NSView?) { - programaFirstResponderGuardCurrentEventOverride = currentEvent - programaFirstResponderGuardHitViewOverride = hitView - } - - static func clearWindowFirstResponderGuardTesting() { - programaFirstResponderGuardCurrentEventOverride = nil - programaFirstResponderGuardHitViewOverride = nil - } -#endif - - private func installWindowResponderSwizzles() { - _ = Self.didInstallApplicationSendEventSwizzle - _ = Self.didInstallWindowKeyEquivalentSwizzle - _ = Self.didInstallWindowFirstResponderSwizzle - _ = Self.didInstallWindowSendEventSwizzle - } - - private func installShortcutMonitor() { - // Local monitor only receives events when app is active (not global) - shortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp, .flagsChanged]) { [weak self] event in - guard let self else { return event } - if event.type == .keyDown { -#if DEBUG - let phaseTotalStart = ProcessInfo.processInfo.systemUptime - let preludeStart = ProcessInfo.processInfo.systemUptime - var preludeMs: Double = 0 - var shortcutMs: Double = 0 - ProgramaTypingTiming.logEventDelay(path: "appMonitor", event: event) - let shortcutMonitorTraceEnabled = - ProcessInfo.processInfo.environment["PROGRAMA_SHORTCUT_MONITOR_TRACE"] == "1" - || UserDefaults.standard.bool(forKey: "programaShortcutMonitorTrace") - if shortcutMonitorTraceEnabled { - let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog( - "monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil") \(self.debugShortcutRouteSnapshot(event: event))" - ) - } - if let probeKind = self.developerToolsShortcutProbeKind(event: event) { - self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event) - } - preludeMs = (ProcessInfo.processInfo.systemUptime - preludeStart) * 1000.0 - let shortcutTimingStart = ProgramaTypingTiming.start() -#endif - let shortcutStart = ProcessInfo.processInfo.systemUptime - let handledByShortcut = self.handleCustomShortcut(event: event) -#if DEBUG - shortcutMs = (ProcessInfo.processInfo.systemUptime - shortcutStart) * 1000.0 - ProgramaTypingTiming.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 - ProgramaTypingTiming.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 - dlog(" → consumed by handleCustomShortcut") -#endif - return nil // Consume the event - } - return event // Pass through - } - self.handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) - if self.clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true) { - return nil - } - return event - } - } - - private func installShortcutDefaultsObserver() { - guard shortcutDefaultsObserver == nil else { return } - refreshConfiguredShortcutChordActions() - shortcutDefaultsObserver = NotificationCenter.default.addObserver( - forName: KeyboardShortcutSettings.didChangeNotification, - object: nil, - queue: .main - ) { [weak self] _ in - self?.refreshConfiguredShortcutChordActions() - self?.clearConfiguredShortcutChordState() - self?.scheduleSplitButtonTooltipRefreshAcrossWorkspaces() - } - } - - private func refreshConfiguredShortcutChordActions() { - configuredShortcutChordActions = KeyboardShortcutSettings.Action.allCases.filter { - KeyboardShortcutSettings.shortcut(for: $0).hasChord - } - } - - private func clearConfiguredShortcutChordState() { - pendingConfiguredShortcutChord = nil - activeConfiguredShortcutChordPrefixForCurrentEvent = nil - } - - /// Coalesce shortcut-default changes and refresh on the next runloop turn to - /// avoid mutating Bonsplit/SwiftUI-observed state during an active update pass. - private func scheduleSplitButtonTooltipRefreshAcrossWorkspaces() { - guard !splitButtonTooltipRefreshScheduled else { return } - splitButtonTooltipRefreshScheduled = true - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.splitButtonTooltipRefreshScheduled = false - self.refreshSplitButtonTooltipsAcrossWorkspaces() - } - } - - private func refreshSplitButtonTooltipsAcrossWorkspaces() { - var refreshedManagers: Set = [] - if let manager = tabManager { - manager.refreshSplitButtonTooltips() - refreshedManagers.insert(ObjectIdentifier(manager)) - } - for context in mainWindowContexts.values { - let manager = context.tabManager - let identifier = ObjectIdentifier(manager) - guard refreshedManagers.insert(identifier).inserted else { continue } - manager.refreshSplitButtonTooltips() - } - } - - private func installGhosttyConfigObserver() { - guard ghosttyConfigObserver == nil else { return } - ghosttyConfigObserver = NotificationCenter.default.addObserver( - forName: .ghosttyConfigDidReload, - object: nil, - queue: .main - ) { [weak self] _ in - self?.refreshGhosttyGotoSplitShortcuts() - } - } - - private func refreshGhosttyGotoSplitShortcuts() { - guard let config = GhosttyApp.shared.config else { - ghosttyGotoSplitLeftShortcut = nil - ghosttyGotoSplitRightShortcut = nil - ghosttyGotoSplitUpShortcut = nil - ghosttyGotoSplitDownShortcut = nil - return - } - - ghosttyGotoSplitLeftShortcut = storedShortcutFromGhosttyTrigger( - ghostty_config_trigger(config, "goto_split:left", UInt("goto_split:left".utf8.count)) - ) - ghosttyGotoSplitRightShortcut = storedShortcutFromGhosttyTrigger( - ghostty_config_trigger(config, "goto_split:right", UInt("goto_split:right".utf8.count)) - ) - ghosttyGotoSplitUpShortcut = storedShortcutFromGhosttyTrigger( - ghostty_config_trigger(config, "goto_split:up", UInt("goto_split:up".utf8.count)) - ) - ghosttyGotoSplitDownShortcut = storedShortcutFromGhosttyTrigger( - ghostty_config_trigger(config, "goto_split:down", UInt("goto_split:down".utf8.count)) - ) - } - - private func storedShortcutFromGhosttyTrigger(_ trigger: ghostty_input_trigger_s) -> StoredShortcut? { - let key: String - switch trigger.tag { - case GHOSTTY_TRIGGER_PHYSICAL: - switch trigger.key.physical { - case GHOSTTY_KEY_ARROW_LEFT: - key = "←" - case GHOSTTY_KEY_ARROW_RIGHT: - key = "→" - case GHOSTTY_KEY_ARROW_UP: - key = "↑" - case GHOSTTY_KEY_ARROW_DOWN: - key = "↓" - case GHOSTTY_KEY_A: key = "a" - case GHOSTTY_KEY_B: key = "b" - case GHOSTTY_KEY_C: key = "c" - case GHOSTTY_KEY_D: key = "d" - case GHOSTTY_KEY_E: key = "e" - case GHOSTTY_KEY_F: key = "f" - case GHOSTTY_KEY_G: key = "g" - case GHOSTTY_KEY_H: key = "h" - case GHOSTTY_KEY_I: key = "i" - case GHOSTTY_KEY_J: key = "j" - case GHOSTTY_KEY_K: key = "k" - case GHOSTTY_KEY_L: key = "l" - case GHOSTTY_KEY_M: key = "m" - case GHOSTTY_KEY_N: key = "n" - case GHOSTTY_KEY_O: key = "o" - case GHOSTTY_KEY_P: key = "p" - case GHOSTTY_KEY_Q: key = "q" - case GHOSTTY_KEY_R: key = "r" - case GHOSTTY_KEY_S: key = "s" - case GHOSTTY_KEY_T: key = "t" - case GHOSTTY_KEY_U: key = "u" - case GHOSTTY_KEY_V: key = "v" - case GHOSTTY_KEY_W: key = "w" - case GHOSTTY_KEY_X: key = "x" - case GHOSTTY_KEY_Y: key = "y" - case GHOSTTY_KEY_Z: key = "z" - case GHOSTTY_KEY_DIGIT_0: key = "0" - case GHOSTTY_KEY_DIGIT_1: key = "1" - case GHOSTTY_KEY_DIGIT_2: key = "2" - case GHOSTTY_KEY_DIGIT_3: key = "3" - case GHOSTTY_KEY_DIGIT_4: key = "4" - case GHOSTTY_KEY_DIGIT_5: key = "5" - case GHOSTTY_KEY_DIGIT_6: key = "6" - case GHOSTTY_KEY_DIGIT_7: key = "7" - case GHOSTTY_KEY_DIGIT_8: key = "8" - case GHOSTTY_KEY_DIGIT_9: key = "9" - case GHOSTTY_KEY_BRACKET_LEFT: key = "[" - case GHOSTTY_KEY_BRACKET_RIGHT: key = "]" - case GHOSTTY_KEY_MINUS: key = "-" - case GHOSTTY_KEY_EQUAL: key = "=" - case GHOSTTY_KEY_COMMA: key = "," - case GHOSTTY_KEY_PERIOD: key = "." - case GHOSTTY_KEY_SLASH: key = "/" - case GHOSTTY_KEY_SEMICOLON: key = ";" - case GHOSTTY_KEY_QUOTE: key = "'" - case GHOSTTY_KEY_BACKQUOTE: key = "`" - case GHOSTTY_KEY_BACKSLASH: key = "\\" - default: - return nil - } - case GHOSTTY_TRIGGER_UNICODE: - guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil } - key = String(Character(scalar)).lowercased() - case GHOSTTY_TRIGGER_CATCH_ALL: - return nil - default: - return nil - } - - let mods = trigger.mods.rawValue - let command = (mods & GHOSTTY_MODS_SUPER.rawValue) != 0 - let shift = (mods & GHOSTTY_MODS_SHIFT.rawValue) != 0 - let option = (mods & GHOSTTY_MODS_ALT.rawValue) != 0 - let control = (mods & GHOSTTY_MODS_CTRL.rawValue) != 0 - - // Ignore bogus empty triggers. - if key.isEmpty || (!command && !shift && !option && !control) { - return nil - } - - return StoredShortcut(key: key, command: command, shift: shift, option: option, control: control) - } - - private func handleQuitShortcutWarning() -> Bool { - // Tagged DEV builds are ephemeral, skip quit confirmation entirely. - if SocketControlSettings.isTaggedDevBuild() { - NSApp.terminate(nil) - return true - } - - if !QuitWarningSettings.isEnabled() { - NSApp.terminate(nil) - return true - } - - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String(localized: "dialog.quitPrograma.title", defaultValue: "Quit Programa?") - alert.informativeText = String(localized: "dialog.quitPrograma.message", defaultValue: "This will close all windows and workspaces.") - alert.addButton(withTitle: String(localized: "dialog.quitPrograma.quit", defaultValue: "Quit")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - alert.showsSuppressionButton = true - alert.suppressionButton?.title = String(localized: "dialog.dontWarnCmdQ", defaultValue: "Don't warn again for Cmd+Q") - - let response = alert.runModal() - if alert.suppressionButton?.state == .on { - QuitWarningSettings.setEnabled(false) - } - - if response == .alertFirstButtonReturn { - // Mark as confirmed so applicationShouldTerminate does not show a - // second alert when NSApp.terminate re-enters the delegate callback. - isQuitWarningConfirmed = true - NSApp.terminate(nil) - } - return true - } - - func promptRenameSelectedWorkspace() -> Bool { - guard let tabManager, - let tabId = tabManager.selectedTabId, - let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { - NSSound.beep() - return false - } - - let alert = NSAlert() - alert.messageText = String(localized: "dialog.renameWorkspace.title", defaultValue: "Rename Workspace") - alert.informativeText = String(localized: "dialog.renameWorkspace.message", defaultValue: "Enter a custom name for this workspace.") - let input = NSTextField(string: tab.customTitle ?? tab.title) - input.placeholderString = String(localized: "dialog.renameWorkspace.placeholder", defaultValue: "Workspace name") - input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) - alert.accessoryView = input - alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) - let alertWindow = alert.window - alertWindow.initialFirstResponder = input - DispatchQueue.main.async { - alertWindow.makeFirstResponder(input) - input.selectText(nil) - } - - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { return true } - tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) - return true - } - - private func handleCustomShortcut(event: NSEvent) -> Bool { - // `charactersIgnoringModifiers` can be nil for some synthetic NSEvents and certain special keys. - // Treat nil as "" and rely on keyCode/layout-aware fallback logic where needed. - // When a non-Latin input source is active (Korean, Chinese, Japanese, etc.), - // charactersIgnoringModifiers returns non-ASCII characters that never match - // Latin shortcut keys. Normalize via KeyboardLayout so downstream comparisons - // (Cmd+1-9, Ctrl+1-9, omnibar N/P, command palette, etc.) work correctly. - let chars = KeyboardLayout.normalizedCharacters(for: event) - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - let hasControl = flags.contains(.control) - let hasCommand = flags.contains(.command) - let hasOption = flags.contains(.option) - let isControlOnly = hasControl && !hasCommand && !hasOption - let controlDChar = chars == "d" || event.characters == "\u{04}" - let isControlD = isControlOnly && (controlDChar || event.keyCode == 2) - let configuredShortcutEventWindowNumber = configuredShortcutChordWindowNumber(for: event) - if let pendingConfiguredShortcutChord, - pendingConfiguredShortcutChord.windowNumber == configuredShortcutEventWindowNumber { - activeConfiguredShortcutChordPrefixForCurrentEvent = pendingConfiguredShortcutChord.firstStroke - } else { - activeConfiguredShortcutChordPrefixForCurrentEvent = nil - } - pendingConfiguredShortcutChord = nil - defer { activeConfiguredShortcutChordPrefixForCurrentEvent = nil } -#if DEBUG - if isControlD { - writeChildExitKeyboardProbe( - [ - "probeAppShortcutCharsHex": childExitKeyboardProbeHex(event.characters), - "probeAppShortcutCharsIgnoringHex": childExitKeyboardProbeHex(event.charactersIgnoringModifiers), - "probeAppShortcutKeyCode": String(event.keyCode), - "probeAppShortcutModsRaw": String(event.modifierFlags.rawValue), - ], - increments: ["probeAppShortcutCtrlDSeenCount": 1] - ) - } -#endif - - // Don't steal shortcuts from close-confirmation alerts. Keep standard alert key - // equivalents working and avoid surprising actions while the confirmation is up. - let closeConfirmationTitles = [ - String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"), - String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?"), - String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), - String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"), - String(localized: "dialog.closeWindow.title", defaultValue: "Close window?"), - ] - let closeConfirmationPanel = NSApp.windows - .compactMap { $0 as? NSPanel } - .first { panel in - guard panel.isVisible, let root = panel.contentView else { return false } - return closeConfirmationTitles.contains { title in - findStaticText(in: root, equals: title) - } - } - if let closeConfirmationPanel { - // Special-case: Cmd+D should confirm destructive close on alerts. - // XCUITest key events often hit the app-level local monitor first, so forward the key - // equivalent to the alert panel explicitly. - if matchShortcut( - event: event, - shortcut: StoredShortcut(key: "d", command: true, shift: false, option: false, control: false) - ), - let root = closeConfirmationPanel.contentView, - let closeButton = findButton( - in: root, - titled: String(localized: "common.close", defaultValue: "Close") - ) { - closeButton.performClick(nil) - return true - } - return false - } - - if NSApp.modalWindow != nil || NSApp.keyWindow?.attachedSheet != nil { - return false - } - - let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) - let commandPaletteTargetWindow = commandPaletteWindowForShortcutEvent(event) - let commandPaletteShortcutWindow = shouldHandleCommandPaletteShortcutEvent( - event, - paletteWindow: commandPaletteTargetWindow - ) ? commandPaletteTargetWindow : nil - let commandPaletteVisibleInTargetWindow = commandPaletteShortcutWindow.map { - isCommandPaletteVisible(for: $0) - } ?? false - let commandPalettePendingOpenInTargetWindow = commandPaletteTargetWindow.map { - isCommandPalettePendingOpen(for: $0) - } ?? false - let commandPaletteOverlayVisibleInTargetWindow = commandPaletteTargetWindow.map { - isCommandPaletteOverlayPresented(in: $0) - } ?? false - let commandPaletteResponderActiveInTargetWindow = commandPaletteTargetWindow.map { - isCommandPaletteResponderActive(in: $0) - } ?? false - let commandPaletteInteractiveInTargetWindow = - commandPaletteVisibleInTargetWindow - || commandPaletteOverlayVisibleInTargetWindow - || commandPaletteResponderActiveInTargetWindow - let commandPaletteEffectiveInTargetWindow = - commandPaletteInteractiveInTargetWindow - || commandPalettePendingOpenInTargetWindow - -#if DEBUG - if event.keyCode == 36 || event.keyCode == 76 { - dlog( - "shortcut.return.raw " + - "interactive=\(commandPaletteInteractiveInTargetWindow ? 1 : 0) " + - "effective=\(commandPaletteEffectiveInTargetWindow ? 1 : 0) " + - "target={\(debugWindowToken(commandPaletteTargetWindow))} " + - "shortcutWindow={\(debugWindowToken(commandPaletteShortcutWindow))} " + - "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0) " + - "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + - "pendingTarget=\(commandPalettePendingOpenInTargetWindow ? 1 : 0) " + - "\(debugShortcutRouteSnapshot(event: event))" - ) - } -#endif - - if normalizedFlags.isEmpty, event.keyCode == 53 { - let activePaletteWindow = activeCommandPaletteWindow() - let escapePaletteWindow: NSWindow? = { - if let targetWindow = commandPaletteTargetWindow { - guard commandPaletteEffectiveInTargetWindow else { - return nil - } - return targetWindow - } - return activePaletteWindow - }() -#if DEBUG - dlog( - "shortcut.escape route target={\(debugWindowToken(commandPaletteTargetWindow))} " + - "active={\(debugWindowToken(activePaletteWindow))} " + - "visibleTarget=\(commandPaletteVisibleInTargetWindow ? 1 : 0) " + - "pendingTarget=\(commandPalettePendingOpenInTargetWindow ? 1 : 0) " + - "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + - "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0) " + - "effectiveTarget=\(commandPaletteEffectiveInTargetWindow ? 1 : 0) " + - "\(debugShortcutRouteSnapshot(event: event))" - ) - if commandPaletteTargetWindow != nil, - !commandPaletteVisibleInTargetWindow, - !commandPalettePendingOpenInTargetWindow, - (commandPaletteOverlayVisibleInTargetWindow || commandPaletteResponderActiveInTargetWindow) { - dlog( - "shortcut.escape stateMismatch target={\(debugWindowToken(commandPaletteTargetWindow))} " + - "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + - "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0)" - ) - } -#endif - if let paletteWindow = escapePaletteWindow, - isCommandPaletteEffectivelyVisible(in: paletteWindow) { - if commandPaletteMarkedTextInput(in: paletteWindow) != nil { -#if DEBUG - dlog( - "shortcut.escape imeMarkedTextBypass consumed=0 target={\(debugWindowToken(paletteWindow))}" - ) -#endif - return false - } - clearCommandPalettePendingOpen(for: paletteWindow) - beginCommandPaletteEscapeSuppression(for: paletteWindow) - NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: paletteWindow) -#if DEBUG - dlog("shortcut.escape paletteDismiss consumed=1 target={\(debugWindowToken(paletteWindow))}") -#endif - return true - } - let suppressionWindow = commandPaletteTargetWindow - ?? event.window - ?? NSApp.keyWindow - ?? NSApp.mainWindow - if shouldConsumeSuppressedEscape(event: event, window: suppressionWindow) { -#if DEBUG - dlog( - "shortcut.escape suppressionConsume consumed=1 target={\(debugWindowToken(suppressionWindow))} " + - "repeat=\(event.isARepeat ? 1 : 0)" - ) -#endif - return true - } - if let requestAge = recentCommandPaletteRequestAge(for: suppressionWindow) { - beginCommandPaletteEscapeSuppression(for: suppressionWindow) + } + + let workspace: Workspace + if let workingDirectory { + workspace = context.tabManager.addWorkspace(workingDirectory: workingDirectory, select: true) + } else { + workspace = context.tabManager.addTab(select: true) + } + #if DEBUG + logWorkspaceCreationRouting( + phase: "created", + source: debugSource, + reason: "workspace_created", + event: event, + chosenContext: context, + workspaceId: workspace.id, + workingDirectory: workingDirectory + ) + #endif + return workspace.id + } + + private func preferredMainWindowContextForWorkspaceCreation( + event: NSEvent? = nil, + debugSource: String = "unspecified" + ) -> MainWindowContext? { + if let context = mainWindowContext(forShortcutEvent: event, debugSource: debugSource) { + return context + } + + // If a keyboard event identifies a specific window but that context + // can't be resolved, do not fall back to another window. + if shortcutEventHasAddressableWindow(event) { #if DEBUG - dlog( - "shortcut.escape requestGraceConsume consumed=1 target={\(debugWindowToken(suppressionWindow))} " + - "ageMs=\(Int(requestAge * 1000)) repeat=\(event.isARepeat ? 1 : 0)" - ) + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_context_required_no_fallback", + event: event, + chosenContext: nil + ) #endif - return true - } + return nil + } + + if let keyWindow = NSApp.keyWindow, + let context = contextForMainTerminalWindow(keyWindow) { #if DEBUG - dlog( - "shortcut.escape paletteDismiss consumed=0 target={\(debugWindowToken(commandPaletteTargetWindow))} " + - "active={\(debugWindowToken(activePaletteWindow))}" + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "key_window", + event: event, + chosenContext: context ) -#endif + #endif + return context } - let paletteUsesInlineTextHandling = commandPaletteShortcutWindow.map { - isCommandPaletteMultilineTextResponderActive(in: $0) - } ?? false + if let mainWindow = NSApp.mainWindow, + let context = contextForMainTerminalWindow(mainWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "main_window", + event: event, + chosenContext: context + ) + #endif + return context + } - let paletteSelectionDelta = commandPaletteSelectionDeltaForKeyboardNavigation( - flags: event.modifierFlags, - chars: chars, - keyCode: event.keyCode + for window in NSApp.orderedWindows where isMainTerminalWindow(window) { + if let context = contextForMainTerminalWindow(window) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "ordered_windows", + event: event, + chosenContext: context + ) + #endif + return context + } + } + + let fallback = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "fallback_first_context", + event: event, + chosenContext: fallback ) +#endif + return fallback + } - if shouldRouteCommandPaletteSelectionNavigation( - delta: paletteSelectionDelta, - isInteractive: commandPaletteInteractiveInTargetWindow, - usesInlineTextHandling: paletteUsesInlineTextHandling - ), - let delta = paletteSelectionDelta, - let paletteWindow = commandPaletteShortcutWindow { - NotificationCenter.default.post( - name: .commandPaletteMoveSelection, - object: paletteWindow, - userInfo: ["delta": delta] + private func shortcutEventHasAddressableWindow(_ event: NSEvent?) -> Bool { + guard let event else { return false } + // NSEvent.windowNumber can be 0 for responder-chain events that are not + // actually bound to an NSWindow (notably some WebKit key paths). + return event.window != nil || event.windowNumber > 0 + } + + private func mainWindowContext( + forShortcutEvent event: NSEvent?, + debugSource: String = "unspecified" + ) -> MainWindowContext? { + guard let event else { return nil } + + if let eventWindow = event.window, + let context = contextForMainTerminalWindow(eventWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window", + event: event, + chosenContext: context ) - return true + #endif + return context } - if commandPaletteInteractiveInTargetWindow, - let paletteWindow = commandPaletteShortcutWindow { - let paletteFieldEditorHasMarkedText = commandPaletteFieldEditorHasMarkedText(in: paletteWindow) - let paletteSnapshot = mainWindowId(for: paletteWindow).map(commandPaletteSnapshot(windowId:)) ?? .empty - let paletteUsesInlineReturnHandling = paletteUsesInlineTextHandling - if normalizedFlags.isEmpty, event.keyCode == 53 { - if paletteFieldEditorHasMarkedText { - return false - } - NotificationCenter.default.post(name: .commandPaletteDismissRequested, object: paletteWindow) - return true - } + if event.windowNumber > 0, + let numberedWindow = NSApp.window(withWindowNumber: event.windowNumber), + let context = contextForMainTerminalWindow(numberedWindow) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window_number", + event: event, + chosenContext: context + ) + #endif + return context + } - let shouldSubmitPalette = shouldSubmitCommandPaletteWithReturn( - keyCode: event.keyCode, - flags: event.modifierFlags, - mode: paletteSnapshot.mode + if event.windowNumber > 0, + let context = mainWindowContexts.values.first(where: { candidate in + let window = candidate.window ?? windowForMainWindowId(candidate.windowId) + return window?.windowNumber == event.windowNumber + }) { + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_window_number_scan", + event: event, + chosenContext: context ) + #endif + return context + } + + #if DEBUG + logWorkspaceCreationRouting( + phase: "choose", + source: debugSource, + reason: "event_context_not_found", + event: event, + chosenContext: nil + ) + #endif + return nil + } + + private func preferredMainWindowContextForShortcutRouting(event: NSEvent) -> MainWindowContext? { + if let context = mainWindowContext(forShortcutEvent: event, debugSource: "shortcut.routing") { + return context + } + + if shortcutEventHasAddressableWindow(event) { + if let eventWindow = resolvedShortcutEventWindow(event), + programaWindowShouldOwnCloseShortcut(eventWindow) { + // Auxiliary cmux windows do not own a terminal tab manager. Let them fall back + // to the active main terminal window so app shortcuts like Cmd+W still route. + } else { #if DEBUG - if event.keyCode == 36 || event.keyCode == 76 { - dlog( - "shortcut.palette.return target={\(debugWindowToken(paletteWindow))} " + - "mode=\(paletteSnapshot.mode) " + - "inline=\(paletteUsesInlineReturnHandling ? 1 : 0) " + - "submit=\(shouldSubmitPalette ? 1 : 0) " + - "marked=\(paletteFieldEditorHasMarkedText ? 1 : 0) " + - "\(debugShortcutRouteSnapshot(event: event))" + logWorkspaceCreationRouting( + phase: "choose", + source: "shortcut.routing", + reason: "event_context_required_no_fallback", + event: event, + chosenContext: nil ) - } #endif - if paletteUsesInlineReturnHandling, - event.keyCode == 36 || event.keyCode == 76 { - return false - } - if shouldSubmitPalette { - if paletteFieldEditorHasMarkedText { - return false - } - NotificationCenter.default.post(name: .commandPaletteSubmitRequested, object: paletteWindow) - return true + return nil } } - // Guard against stale browserAddressBarFocusedPanelId after focus transitions - // (e.g., split that doesn't properly blur the address bar). If the first responder - // is a terminal surface, the address bar can't be focused. - if browserAddressBarFocusedPanelId != nil, - cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil { + if let keyWindow = NSApp.keyWindow, + let context = contextForMainTerminalWindow(keyWindow) { + return context + } + + if let mainWindow = NSApp.mainWindow, + let context = contextForMainTerminalWindow(mainWindow) { + return context + } + + if let activeManager = tabManager, + let context = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + return context + } + + return mainWindowContexts.values.first + } + + @discardableResult + private func synchronizeShortcutRoutingContext(event: NSEvent) -> Bool { + guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { #if DEBUG - let stalePanelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog( - "browser.focus.addressBar.staleClear panel=\(stalePanelToken) " + - "reason=terminal_first_responder fr=\(firstResponderType)" + FocusLogStore.shared.append( + "shortcut.route reason=no_context_no_fallback eventWin=\(event.windowNumber) keyCode=\(event.keyCode)" ) #endif - browserAddressBarFocusedPanelId = nil - stopBrowserOmnibarSelectionRepeat() + return false } - // Keep Cmd+P/Cmd+N inside the focused browser omnibar for Chrome-like - // suggestion navigation, and avoid opening command palette switcher. - // Scope the omnibar check to the shortcut's routed window context so a - // focused omnibar in another window does not suppress Cmd+P here. - let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil + let alreadyActive = + tabManager === context.tabManager + && sidebarState === context.sidebarState + && sidebarSelectionState === context.sidebarSelectionState + if alreadyActive { return true } - if commandPaletteEffectiveInTargetWindow { - if matchConfiguredShortcut(event: event, action: .commandPalette) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") - return true - } + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + } else { + tabManager = context.tabManager + sidebarState = context.sidebarState + sidebarSelectionState = context.sidebarSelectionState + TerminalController.shared.setActiveTabManager(context.tabManager) + } - if !hasFocusedAddressBarInShortcutContext, - matchConfiguredShortcut(event: event, action: .goToWorkspace) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") - return true - } +#if DEBUG + FocusLogStore.shared.append( + "shortcut.route reason=sync activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(context))}" + ) +#endif + return true + } - if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, - armConfiguredShortcutChordIfNeeded(event: event, actions: [.commandPalette]) { - return true - } + @discardableResult + func createMainWindow( + initialWorkingDirectory: String? = nil, + sessionWindowSnapshot: SessionWindowSnapshot? = nil + ) -> UUID { + let windowId = UUID() + let tabManager = TabManager(initialWorkingDirectory: initialWorkingDirectory) + if let tabManagerSnapshot = sessionWindowSnapshot?.tabManager { + tabManager.restoreSessionSnapshot(tabManagerSnapshot) + } - if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, - !hasFocusedAddressBarInShortcutContext, - armConfiguredShortcutChordIfNeeded(event: event, actions: [.goToWorkspace]) { - return true + let sidebarWidth = sessionWindowSnapshot?.sidebar.width + .map(SessionPersistencePolicy.sanitizedSidebarWidth) + ?? SessionPersistencePolicy.defaultSidebarWidth + let sidebarState = SidebarState( + isVisible: sessionWindowSnapshot?.sidebar.isVisible ?? true, + persistedWidth: CGFloat(sidebarWidth) + ) + let sidebarSelectionState = SidebarSelectionState( + selection: sessionWindowSnapshot?.sidebar.selection.sidebarSelection ?? .tabs + ) + let notificationStore = TerminalNotificationStore.shared + + let programaConfigStore = ProgramaConfigStore() + programaConfigStore.wireDirectoryTracking(tabManager: tabManager) + programaConfigStore.loadAll() + + let root = ContentView(updateViewModel: updateViewModel, windowId: windowId) + .environmentObject(tabManager) + .environmentObject(notificationStore) + .environmentObject(sidebarState) + .environmentObject(sidebarSelectionState) + .environmentObject(programaConfigStore) + + // Use the current key window's size for new windows so Cmd+Shift+N + // creates a window matching the previous one's dimensions. + let styleMask: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] + let sourceContext = preferredMainWindowContextForWorkspaceCreation( + debugSource: "createMainWindow.initialGeometry" + ) + let sourceWindow = sourceContext.flatMap { resolvedWindow(for: $0) } + let existingFrame = sourceWindow?.frame + let sourceWindowIsNativeFullScreen: Bool = { +#if DEBUG + if let debugCreateMainWindowSourceIsNativeFullScreenOverride { + return debugCreateMainWindowSourceIsNativeFullScreenOverride } +#endif + return sourceWindow?.styleMask.contains(.fullScreen) == true + }() + let shouldTemporarilyDisallowFullScreenTiling = + sessionWindowSnapshot == nil && sourceWindowIsNativeFullScreen + let initialRect: NSRect + if sessionWindowSnapshot == nil, let existingFrame { + // Convert frame rect to content rect so the new window matches the + // source window's actual size (frame includes titlebar insets). + initialRect = NSWindow.contentRect(forFrameRect: existingFrame, styleMask: styleMask) + } else { + initialRect = NSRect(x: 0, y: 0, width: 460, height: 360) } - if shouldConsumeShortcutWhileCommandPaletteVisible( - isCommandPaletteVisible: commandPaletteEffectiveInTargetWindow, - normalizedFlags: normalizedFlags, - chars: chars, - keyCode: event.keyCode - ) { - return true + let window = NSWindow( + contentRect: initialRect, + styleMask: styleMask, + backing: .buffered, + defer: false + ) + // When creating a new window from an existing native fullscreen window, + // temporarily opt out of fullscreen tiling so AppKit doesn't place the + // new window into the active fullscreen Space. + if shouldTemporarilyDisallowFullScreenTiling { + window.collectionBehavior.insert(.fullScreenDisallowsTiling) } - - // When the terminal has active IME composition (e.g. Korean, Japanese, Chinese - // input), don't intercept non-Cmd key events — let them flow through to the - // input method. Cmd-based shortcuts (Cmd+T, Cmd+Shift+L, etc.) should still - // work during composition since Cmd is never part of IME input sequences. - if !normalizedFlags.contains(.command), - let ghosttyView = cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder), - ghosttyView.hasMarkedText() { - return false + window.title = "" + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isMovableByWindowBackground = false + window.isMovable = false + let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) + if let restoredFrame { + window.setFrame(restoredFrame, display: false) + } else { + window.center() + // Cascade using the same algorithm as upstream Ghostty: seed from + // the window's own top-left on the first call, then advance the + // cascade point for each subsequent window. + if mainWindowContexts.count >= 1 { + lastCascadePoint = window.cascadeTopLeft(from: lastCascadePoint) + } else { + lastCascadePoint = window.cascadeTopLeft(from: NSPoint(x: window.frame.minX, y: window.frame.maxY)) + } } + window.contentView = MainWindowHostingView(rootView: root) - // When the notifications popover is open, Escape should dismiss it immediately. - if flags.isEmpty, event.keyCode == 53, titlebarAccessoryController.dismissNotificationsPopoverIfShown() { - return true - } + // Apply shared window styling. + attachUpdateAccessory(to: window) + applyWindowDecorations(to: window) - // When the notifications popover is showing an empty state, consume plain typing - // so key presses do not leak through into the focused terminal. - if flags.isDisjoint(with: [.command, .control, .option]), - titlebarAccessoryController.isNotificationsPopoverShown(), - (notificationStore?.notifications.isEmpty ?? false) { - return true + // Keep a strong reference so the window isn't deallocated. + let controller = MainWindowController(window: window) + controller.onClose = { [weak self, weak controller] in + guard let self, let controller else { return } + self.mainWindowControllers.removeAll(where: { $0 === controller }) } + window.delegate = controller + mainWindowControllers.append(controller) - let hasEventWindowContext = shortcutEventHasAddressableWindow(event) - let didSynchronizeShortcutContext = synchronizeShortcutRoutingContext(event: event) - if hasEventWindowContext && !didSynchronizeShortcutContext { + registerMainWindow( + window, + windowId: windowId, + tabManager: tabManager, + sidebarState: sidebarState, + sidebarSelectionState: sidebarSelectionState + ) + installFileDropOverlay(on: window, tabManager: tabManager) + if TerminalController.shouldSuppressSocketCommandActivation() { + window.orderFront(nil) + if TerminalController.socketCommandAllowsInAppFocusMutations() { + setActiveMainWindow(window) + } + } else { + window.makeKeyAndOrderFront(nil) + setActiveMainWindow(window) + NSApp.activate(ignoringOtherApps: true) + } + if shouldTemporarilyDisallowFullScreenTiling { + DispatchQueue.main.async { [weak window] in + window?.collectionBehavior.remove(.fullScreenDisallowsTiling) + } + } + if let restoredFrame { + window.setFrame(restoredFrame, display: true) #if DEBUG - dlog("handleCustomShortcut: unresolved event window context; bypassing app shortcut handling") + dlog( + "session.restore.frameApplied window=\(windowId.uuidString.prefix(8)) " + + "applied={\(debugNSRectDescription(window.frame))}" + ) #endif - return false } + return windowId + } - // Keep keyboard routing deterministic after split close/reparent transitions: - // before processing shortcuts, converge first responder with the focused terminal panel. - if isControlD { -#if DEBUG - let selected = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" - let focused = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" - let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog("shortcut.ctrlD stage=preReconcile selected=\(selected) focused=\(focused) fr=\(frType)") -#endif - tabManager?.reconcileFocusedPanelFromFirstResponderForKeyboard() - #if DEBUG - let frAfterType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog("shortcut.ctrlD stage=postReconcile fr=\(frAfterType)") - writeChildExitKeyboardProbe([:], increments: ["probeAppShortcutCtrlDPassedCount": 1]) - #endif - // Ctrl+D belongs to the focused terminal surface; never treat it as an app shortcut. - return false + @objc func checkForUpdates(_ sender: Any?) { + updateViewModel.overrideState = nil + updateController.checkForUpdates() + } + + func checkForUpdatesInCustomUI() { + updateViewModel.overrideState = nil + updateController.checkForUpdatesInCustomUI() + } + + func openWelcomeWorkspace() { + guard let context = preferredMainWindowContextForWorkspaceCreation(event: nil, debugSource: "welcome") else { + return + } + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + bringToFront(window) } + let workspace = context.tabManager.addWorkspace(select: true, autoWelcomeIfNeeded: false) + sendWelcomeCommandWhenReady(to: workspace) + } - // Chrome-like omnibar navigation while holding Cmd+N / Ctrl+N / Cmd+P / Ctrl+P. - if let delta = commandOmnibarSelectionDelta(flags: flags, chars: chars) { - dispatchBrowserOmnibarSelectionMove(delta: delta) - startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: event.keyCode, delta: delta) - return true + func sendWelcomeCommandWhenReady(to workspace: Workspace, markShownOnSend: Bool = false) { + sendTextWhenReady("programa welcome\n", to: workspace) { + if markShownOnSend { + UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) + } } + } - if let delta = browserOmnibarSelectionDeltaForArrowNavigation( - hasFocusedAddressBar: browserAddressBarFocusedPanelId != nil, - flags: event.modifierFlags, - keyCode: event.keyCode - ) { - dispatchBrowserOmnibarSelectionMove(delta: delta) - return true - } + @objc func applyUpdateIfAvailable(_ sender: Any?) { + updateViewModel.overrideState = nil + updateController.installUpdate() + } - // Fast path for normal typing and terminal navigation keys (for example Up-arrow - // history): after command-palette/notification handling and browser omnibar - // arrow navigation above, plain key events have no app-level shortcut behavior. - if normalizedFlags.isEmpty && activeConfiguredShortcutChordPrefixForCurrentEvent == nil { - return false - } + @objc func attemptUpdate(_ sender: Any?) { + updateViewModel.overrideState = nil + updateController.attemptUpdate() + } - // Let omnibar-local Emacs navigation (Cmd/Ctrl+N/P) win while the browser - // address bar is focused. Without this, app-level Cmd+N can steal focus. - if shouldBypassAppShortcutForFocusedBrowserAddressBar(flags: flags, chars: chars) { - return false - } + func isProgramaCLIInstalledInPATH() -> Bool { + ProgramaCLIPathInstaller().isInstalled() + } - if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, - armConfiguredShortcutChordIfNeeded(event: event) { - return true + @objc func installProgramaCLIInPath(_ sender: Any?) { + let installer = ProgramaCLIPathInstaller() + do { + let outcome = try installer.install() + var informativeText = String(localized: "cli.install.symlinkCreated", defaultValue: "Created symlink:\n\n\(outcome.destinationURL.path) -> \(outcome.sourceURL.path)") + if outcome.usedAdministratorPrivileges { + informativeText += "\n\n" + String(localized: "cli.install.adminRequired", defaultValue: "Administrator privileges were required to write to /usr/local/bin.") + } + presentCLIPathAlert( + title: String(localized: "cli.installed", defaultValue: "Programa CLI Installed"), + informativeText: informativeText, + style: .informational + ) + } catch { + presentCLIPathAlert( + title: String(localized: "cli.installFailed", defaultValue: "Couldn't Install Programa CLI"), + informativeText: error.localizedDescription, + style: .warning + ) } + } - if matchConfiguredShortcut(event: event, action: .commandPalette) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") - return true + @objc func uninstallProgramaCLIInPath(_ sender: Any?) { + let installer = ProgramaCLIPathInstaller() + do { + let outcome = try installer.uninstall() + let prefix = outcome.removedExistingEntry + ? String(localized: "cli.uninstall.removed", defaultValue: "Removed \(outcome.destinationURL.path).") + : String(localized: "cli.uninstall.notFound", defaultValue: "No Programa CLI symlink was found at \(outcome.destinationURL.path).") + var informativeText = prefix + if outcome.usedAdministratorPrivileges { + informativeText += "\n\n" + String(localized: "cli.uninstall.adminRequired", defaultValue: "Administrator privileges were required to modify /usr/local/bin.") + } + presentCLIPathAlert( + title: String(localized: "cli.uninstalled", defaultValue: "Programa CLI Uninstalled"), + informativeText: informativeText, + style: .informational + ) + } catch { + presentCLIPathAlert( + title: String(localized: "cli.uninstallFailed", defaultValue: "Couldn't Uninstall Programa CLI"), + informativeText: error.localizedDescription, + style: .warning + ) } + } - if !hasFocusedAddressBarInShortcutContext, - matchConfiguredShortcut(event: event, action: .goToWorkspace) { - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") - return true - } + private func presentCLIPathAlert( + title: String, + informativeText: String, + style: NSAlert.Style + ) { + let alert = NSAlert() + alert.alertStyle = style + alert.messageText = title + alert.informativeText = informativeText + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) - if matchConfiguredShortcut(event: event, action: .quit) { - return handleQuitShortcutWarning() + if let window = NSApp.keyWindow ?? NSApp.mainWindow { + alert.beginSheetModal(for: window, completionHandler: nil) + } else { + _ = alert.runModal() } - if matchConfiguredShortcut(event: event, action: .openSettings) { - openPreferencesWindow(debugSource: "shortcut.openSettings") - return true + } + + @objc func restartSocketListener(_ sender: Any?) { + guard tabManager != nil else { + NSSound.beep() + return } - if matchConfiguredShortcut(event: event, action: .reloadConfiguration) { - GhosttyApp.shared.reloadConfiguration(source: "shortcut.reloadConfiguration") - return true + + guard socketListenerConfigurationIfEnabled() != nil else { + TerminalController.shared.stop() + NSSound.beep() + return } + restartSocketListenerIfEnabled(source: "menu.command") + } - if matchConfiguredShortcut(event: event, action: .toggleFullScreen) { - guard let targetWindow = mainWindowForShortcutEvent(event) else { - return false + private func setupMenuBarExtra() { + guard menuBarExtraController == nil else { return } + let store = TerminalNotificationStore.shared + menuBarExtraController = MenuBarExtraController( + notificationStore: store, + onShowNotifications: { [weak self] in + self?.showNotificationsPopoverFromMenuBar() + }, + onOpenNotification: { [weak self] notification in + _ = self?.openNotification( + tabId: notification.tabId, + surfaceId: notification.surfaceId, + notificationId: notification.id + ) + }, + onJumpToLatestUnread: { [weak self] in + self?.jumpToLatestUnread() + }, + onCheckForUpdates: { [weak self] in + self?.checkForUpdates(nil) + }, + onOpenPreferences: { [weak self] in + self?.openPreferencesWindow(debugSource: "menuBarExtra") + }, + onQuitApp: { + NSApp.terminate(nil) + } + ) + } + + private func installMenuBarVisibilityObserver() { + guard menuBarVisibilityObserver == nil else { return } + menuBarVisibilityObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.syncMenuBarExtraVisibility() } - targetWindow.toggleFullScreen(nil) - return true } + } - // Primary UI shortcuts - if matchConfiguredShortcut(event: event, action: .toggleSidebar) { - _ = toggleSidebarInActiveMainWindow() - return true + private func syncMenuBarExtraVisibility(defaults: UserDefaults = .standard) { + if MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults) { + setupMenuBarExtra() + return } - if matchConfiguredShortcut(event: event, action: .newTab) { + menuBarExtraController?.removeFromMenuBar() + menuBarExtraController = nil + } + + @MainActor + static func presentPreferencesWindow( + navigationTarget: SettingsNavigationTarget? = nil, + showFallbackSettingsWindow: @MainActor (SettingsNavigationTarget?) -> Void = { target in + SettingsWindowController.shared.show(navigationTarget: target) + }, + activateApplication: @MainActor () -> Void = { + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + } + ) { #if DEBUG - dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))") + dlog("settings.open.present path=customWindowDirect") #endif - // Cmd+N semantics: - // - If there are no main windows, create a new window. - // - Otherwise, create a new workspace in the active window. - if mainWindowContexts.isEmpty { - #if DEBUG - logWorkspaceCreationRouting( - phase: "fallback_new_window", - source: "shortcut.cmdN", - reason: "no_main_windows", - event: event, - chosenContext: nil - ) - #endif - openNewMainWindow(nil) - } else if addWorkspaceInPreferredMainWindow(event: event, debugSource: "shortcut.cmdN") == nil { - #if DEBUG - logWorkspaceCreationRouting( - phase: "fallback_new_window", - source: "shortcut.cmdN", - reason: "workspace_creation_returned_nil", - event: event, - chosenContext: nil - ) - #endif - openNewMainWindow(nil) + showFallbackSettingsWindow(navigationTarget) + activateApplication() + if let window = SettingsWindowController.shared.window { + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) + DispatchQueue.main.async { + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) + } + } +#if DEBUG + dlog("settings.open.present activate=1") +#endif + } + + @MainActor + func openPreferencesWindow(debugSource: String, navigationTarget: SettingsNavigationTarget? = nil) { +#if DEBUG + dlog("settings.open.request source=\(debugSource)") +#endif + Self.presentPreferencesWindow(navigationTarget: navigationTarget) + } + + @objc func openPreferencesWindow() { + openPreferencesWindow(debugSource: "appDelegate") + } + + func refreshMenuBarExtraForDebug() { + menuBarExtraController?.refreshForDebugControls() + } + + func showNotificationsPopoverFromMenuBar() { + let context: MainWindowContext? = { + if let keyWindow = NSApp.keyWindow, + let keyContext = contextForMainTerminalWindow(keyWindow) { + return keyContext } - return true - } + if let first = mainWindowContexts.values.first { + return first + } + let windowId = createMainWindow() + return mainWindowContexts.values.first(where: { $0.windowId == windowId }) + }() - // New Window: Cmd+Shift+N - // Handled here instead of relying on SwiftUI's CommandGroup menu item because - // after a browser panel has been shown, SwiftUI's menu dispatch can silently - // consume the key equivalent without firing the action closure. - if matchConfiguredShortcut(event: event, action: .newWindow) { - openNewMainWindow(nil) - return true + if let context, + let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + bringToFront(window) } - // Open Folder: Cmd+O - // Handled here to prevent AppKit's default NSDocumentController from opening - // the Documents folder when SwiftUI menu dispatch fails due to focus bugs. - if matchConfiguredShortcut(event: event, action: .openFolder) { - showOpenFolderPanel() - return true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.titlebarAccessoryController.showNotificationsPopover(animated: false) } + } - // Check Show Notifications shortcut - if matchConfiguredShortcut(event: event, action: .showNotifications) { - toggleNotificationsPopover(animated: false, anchorView: fullscreenControlsViewModel?.notificationsAnchorView) - return true - } + #if DEBUG + @objc func showUpdatePill(_ sender: Any?) { + updateViewModel.debugOverrideText = nil + updateViewModel.overrideState = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {})) + } - if matchConfiguredShortcut(event: event, action: .sendFeedback) { - guard let targetContext = preferredMainWindowContextForShortcuts(event: event), - let targetWindow = targetContext.window ?? windowForMainWindowId(targetContext.windowId) else { - return false - } - setActiveMainWindow(targetWindow) - bringToFront(targetWindow) - NotificationCenter.default.post(name: .feedbackComposerRequested, object: targetWindow) - return true - } + @objc func showUpdatePillLongNightly(_ sender: Any?) { + updateViewModel.debugOverrideText = "Update Available: 0.32.0-nightly+20260216.abc1234" + updateViewModel.overrideState = .notFound(.init(acknowledgement: {})) + } - // Check Jump to Unread shortcut - if matchConfiguredShortcut(event: event, action: .jumpToUnread) { -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadShortcutHandled": "1"]) - } -#endif - jumpToLatestUnread() - return true - } + @objc func showUpdatePillLoading(_ sender: Any?) { + updateViewModel.debugOverrideText = nil + updateViewModel.overrideState = .checking(.init(cancel: {})) + } - // Flash the currently focused panel so the user can visually confirm focus. - if matchConfiguredShortcut(event: event, action: .triggerFlash) { - tabManager?.triggerFocusFlash() - return true - } + @objc func hideUpdatePill(_ sender: Any?) { + updateViewModel.debugOverrideText = nil + updateViewModel.overrideState = .idle + } - // Surface navigation: Cmd+Shift+] / Cmd+Shift+[ - if matchConfiguredShortcut(event: event, action: .nextSurface) { - tabManager?.selectNextSurface() - return true + @objc func clearUpdatePillOverride(_ sender: Any?) { + updateViewModel.debugOverrideText = nil + updateViewModel.overrideState = nil + } +#endif + + @objc func copyUpdateLogs(_ sender: Any?) { + let logText = UpdateLogStore.shared.snapshot() + let payload: String + if logText.isEmpty { + payload = "No update logs captured.\nLog file: \(UpdateLogStore.shared.logPath())" + } else { + payload = logText + "\nLog file: \(UpdateLogStore.shared.logPath())" } - if matchConfiguredShortcut(event: event, action: .prevSurface) { - tabManager?.selectPreviousSurface() - return true + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + } + @objc func copyFocusLogs(_ sender: Any?) { + let logText = FocusLogStore.shared.snapshot() + let payload: String + if logText.isEmpty { + payload = "No focus logs captured.\nLog file: \(FocusLogStore.shared.logPath())" + } else { + payload = logText + "\nLog file: \(FocusLogStore.shared.logPath())" } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + } - if matchConfiguredShortcut(event: event, action: .toggleTerminalCopyMode) { - let handled = tabManager?.toggleFocusedTerminalCopyMode() ?? false + @objc private func handleReactGrabDidCopySelection(_ notification: Notification) { + let browserPanelId = notification.userInfo?[ReactGrabPastebackNotificationKey.browserPanelId] as? UUID + guard let workspaceId = notification.userInfo?[ReactGrabPastebackNotificationKey.workspaceId] as? UUID, + let returnPanelId = notification.userInfo?[ReactGrabPastebackNotificationKey.returnPanelId] as? UUID, + let content = notification.userInfo?[ReactGrabPastebackNotificationKey.content] as? String else { #if DEBUG dlog( - "shortcut.action name=toggleTerminalCopyMode handled=\(handled ? 1 : 0) " + - "\(debugShortcutRouteSnapshot(event: event))" + "reactGrab.pasteback h3.didCopy.drop " + + "reason=missingNotificationFields " + + "workspace=\(Self.debugShortId(notification.userInfo?[ReactGrabPastebackNotificationKey.workspaceId] as? UUID)) " + + "browser=\(Self.debugShortId(browserPanelId)) " + + "return=\(Self.debugShortId(notification.userInfo?[ReactGrabPastebackNotificationKey.returnPanelId] as? UUID)) " + + "hasContent=\((notification.userInfo?[ReactGrabPastebackNotificationKey.content] as? String) != nil ? 1 : 0)" ) #endif - // Only consume when a focused terminal actually handled the toggle. - // Otherwise allow the event to continue through the responder chain. - return handled + return } - // Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[ - if matchConfiguredShortcut(event: event, action: .nextSidebarTab) { + guard let manager = tabManagerFor(tabId: workspaceId), + let workspace = manager.tabs.first(where: { $0.id == workspaceId }) else { #if DEBUG - let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" dlog( - "ws.shortcut dir=next repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" + "reactGrab.pasteback h3.didCopy.drop " + + "reason=missingWorkspace workspace=\(Self.debugShortId(workspaceId)) " + + "browser=\(Self.debugShortId(browserPanelId)) return=\(Self.debugShortId(returnPanelId))" ) #endif - tabManager?.selectNextTab() - return true + return } - if matchConfiguredShortcut(event: event, action: .prevSidebarTab) { + guard workspace.terminalPanel(for: returnPanelId) != nil else { #if DEBUG - let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" dlog( - "ws.shortcut dir=prev repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" + "reactGrab.pasteback h3.didCopy.drop " + + "reason=missingReturnTerminal workspace=\(Self.debugShortId(workspaceId)) " + + "browser=\(Self.debugShortId(browserPanelId)) return=\(Self.debugShortId(returnPanelId)) " + + "focused=\(Self.debugShortId(workspace.focusedPanelId))" ) #endif - tabManager?.selectPreviousTab() - return true - } - - if matchConfiguredShortcut(event: event, action: .renameWorkspace) { - return requestRenameWorkspaceViaCommandPalette( - preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - ) + return } - if matchConfiguredShortcut(event: event, action: .editWorkspaceDescription) { #if DEBUG - dlog( - "shortcut.editWorkspaceDescription matched target={\(debugWindowToken(commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow))} " + - "\(debugShortcutRouteSnapshot(event: event))" - ) + dlog( + "reactGrab.pasteback h3.didCopy " + + "workspace=\(Self.debugShortId(workspaceId)) " + + "browser=\(Self.debugShortId(browserPanelId)) " + + "return=\(Self.debugShortId(returnPanelId)) " + + "focusedBefore=\(Self.debugShortId(workspace.focusedPanelId)) len=\(content.count)" + ) #endif - return requestEditWorkspaceDescriptionViaCommandPalette( - preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - ) - } + manager.focusTab(workspaceId, surfaceId: returnPanelId, suppressFlash: true) +#if DEBUG + dlog( + "reactGrab.pasteback h1.focusRequested " + + "workspace=\(Self.debugShortId(workspaceId)) " + + "return=\(Self.debugShortId(returnPanelId)) " + + "focusedAfterRequest=\(Self.debugShortId(workspace.focusedPanelId))" + ) +#endif + sendTextWhenReady(content, to: workspace, preferredPanelId: returnPanelId) + } - if matchConfiguredShortcut(event: event, action: .closeOtherTabsInPane) { - if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, - targetWindow.identifier?.rawValue == "cmux.settings" { - targetWindow.performClose(nil) - } else { - let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - if let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) { - terminalContext.tabManager.closeOtherTabsInFocusedPaneWithConfirmation() - } else { - tabManager?.closeOtherTabsInFocusedPaneWithConfirmation() - } - } - return true + nonisolated private static func debugShortId(_ id: UUID?) -> String { + id.map { String($0.uuidString.prefix(5)) } ?? "nil" + } + + static func resolveTerminalPanelForTextSend(in tab: Tab, preferredPanelId: UUID? = nil) -> TerminalPanel? { + if let preferredPanelId { + return tab.terminalPanel(for: preferredPanelId) } + return tab.focusedTerminalPanel + } - // Cmd+W must close the focused panel even if first-responder momentarily lags on a - // browser NSTextView during split focus transitions. - if matchConfiguredShortcut(event: event, action: .closeTab) { - let targetWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow - let routedManager = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager - // Browser popup windows primarily intercept Cmd+W in BrowserPopupPanel. - // This AppDelegate path is a fallback for cases where AppKit routes the - // event through the global shortcut handler first. - if let targetWindow = [targetWindow, NSApp.keyWindow] - .compactMap({ $0 }) - .first(where: { $0.identifier?.rawValue == "programa.browser-popup" }) { -#if DEBUG - dlog("shortcut.cmdW route=browserPopup") -#endif - targetWindow.performClose(nil) - return true - } else if let targetWindow, - programaWindowShouldOwnCloseShortcut(targetWindow) { - targetWindow.performClose(nil) - } else { - if let routedManager { + func sendTextWhenReady( + _ text: String, + to tab: Tab, + preferredPanelId: UUID? = nil, + beforeSend: (() -> Void)? = nil + ) { + let isReactGrabPasteback = preferredPanelId != nil #if DEBUG - let selectedWorkspace = routedManager.selectedWorkspace - dlog( - "shortcut.cmdW route=workspaceModel workspace=\(selectedWorkspace?.id.uuidString.prefix(5) ?? "nil") " + - "panel=\(selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil") " + - "selected=\(routedManager.selectedTabId?.uuidString.prefix(5) ?? "nil")" - ) + let initialTargetPanel = Self.resolveTerminalPanelForTextSend( + in: tab, + preferredPanelId: preferredPanelId + ) + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.send.start " + + "workspace=\(Self.debugShortId(tab.id)) " + + "preferred=\(Self.debugShortId(preferredPanelId)) " + + "focused=\(Self.debugShortId(tab.focusedPanelId)) " + + "focusedTerminal=\(Self.debugShortId(tab.focusedTerminalPanel?.id)) " + + "resolved=\(Self.debugShortId(initialTargetPanel?.id)) " + + "surfaceReady=\(initialTargetPanel?.surface.surface != nil ? 1 : 0) len=\(text.count)" + ) + } #endif - routedManager.closeCurrentPanelWithConfirmation() - } else { + if let terminalPanel = Self.resolveTerminalPanelForTextSend( + in: tab, + preferredPanelId: preferredPanelId + ), + terminalPanel.surface.surface != nil { #if DEBUG - dlog("shortcut.cmdW route=noManager") + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.send.immediate " + + "workspace=\(Self.debugShortId(tab.id)) " + + "target=\(Self.debugShortId(terminalPanel.id)) len=\(text.count)" + ) + } #endif - return false - } + beforeSend?() + terminalPanel.sendText(text) +#if DEBUG + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.send.sent " + + "workspace=\(Self.debugShortId(tab.id)) " + + "target=\(Self.debugShortId(terminalPanel.id)) mode=immediate len=\(text.count)" + ) } - return true +#endif + return } - if matchConfiguredShortcut(event: event, action: .closeWorkspace) { - tabManager?.closeCurrentWorkspaceWithConfirmation() - return true - } + var resolved = false + var readyObserver: NSObjectProtocol? + var focusObserver: NSObjectProtocol? + var firstResponderObserver: NSObjectProtocol? + var panelsCancellable: AnyCancellable? - if matchConfiguredShortcut(event: event, action: .closeWindow) { - guard let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow else { - NSSound.beep() - return true + func cleanupObservers() { + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) } - closeWindowWithConfirmation(targetWindow) - return true - } - - if matchConfiguredShortcut(event: event, action: .renameTab) { - // Keep Cmd+R browser reload behavior when a browser panel is focused. - if tabManager?.focusedBrowserPanel != nil { - return false + if let focusObserver { + NotificationCenter.default.removeObserver(focusObserver) } - let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteRenameTab(preferredWindow: targetWindow, source: "shortcut.renameTab") - return true + if let firstResponderObserver { + NotificationCenter.default.removeObserver(firstResponderObserver) + } + panelsCancellable?.cancel() } - // Numeric shortcuts for specific workspaces (9 = last workspace) - // Always consume the event when the digit matches to prevent Ghostty's - // goto_tab fallback from creating a new window when the index is out of bounds. - if let digit = numberedConfiguredShortcutDigit(event: event, action: .selectWorkspaceByNumber) { - if let manager = tabManager, - let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forDigit: digit, workspaceCount: manager.tabs.count) { + func finishIfReady() { + let terminalPanel = Self.resolveTerminalPanelForTextSend( + in: tab, + preferredPanelId: preferredPanelId + ) #if DEBUG + if isReactGrabPasteback { dlog( - "shortcut.action name=workspaceDigit digit=\(digit) targetIndex=\(targetIndex) manager=\(debugManagerToken(manager)) \(debugShortcutRouteSnapshot(event: event))" + "reactGrab.pasteback h2.finishIfReady " + + "workspace=\(Self.debugShortId(tab.id)) " + + "preferred=\(Self.debugShortId(preferredPanelId)) " + + "focused=\(Self.debugShortId(tab.focusedPanelId)) " + + "resolved=\(Self.debugShortId(terminalPanel?.id)) " + + "surfaceReady=\(terminalPanel?.surface.surface != nil ? 1 : 0) alreadyResolved=\(resolved ? 1 : 0)" ) -#endif - manager.selectTab(at: targetIndex) - } - return true - } - - // Numeric shortcuts for surfaces within the focused pane (9 = last) - if let digit = numberedConfiguredShortcutDigit(event: event, action: .selectSurfaceByNumber) { - if digit == 9 { - tabManager?.selectLastSurface() - } else { - tabManager?.selectSurface(at: digit - 1) } - return true - } - - // Pane focus navigation (defaults to Cmd+Option+Arrow, but can be customized to letter/number keys). - if matchConfiguredDirectionalShortcut( - event: event, - action: .focusLeft, - arrowGlyph: "←", - arrowKeyCode: 123 - ) || (ghosttyGotoSplitLeftShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "←", arrowKeyCode: 123) } ?? false) { - tabManager?.movePaneFocus(direction: .left) -#if DEBUG - recordGotoSplitMoveIfNeeded(direction: .left) -#endif - return true - } - if matchConfiguredDirectionalShortcut( - event: event, - action: .focusRight, - arrowGlyph: "→", - arrowKeyCode: 124 - ) || (ghosttyGotoSplitRightShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "→", arrowKeyCode: 124) } ?? false) { - tabManager?.movePaneFocus(direction: .right) -#if DEBUG - recordGotoSplitMoveIfNeeded(direction: .right) #endif - return true - } - if matchConfiguredDirectionalShortcut( - event: event, - action: .focusUp, - arrowGlyph: "↑", - arrowKeyCode: 126 - ) || (ghosttyGotoSplitUpShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↑", arrowKeyCode: 126) } ?? false) { - tabManager?.movePaneFocus(direction: .up) + guard !resolved, + let terminalPanel, + terminalPanel.surface.surface != nil else { return } + resolved = true + cleanupObservers() + beforeSend?() + terminalPanel.sendText(text) #if DEBUG - recordGotoSplitMoveIfNeeded(direction: .up) + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.send.sent " + + "workspace=\(Self.debugShortId(tab.id)) " + + "target=\(Self.debugShortId(terminalPanel.id)) mode=delayed len=\(text.count)" + ) + } #endif - return true } - if matchConfiguredDirectionalShortcut( - event: event, - action: .focusDown, - arrowGlyph: "↓", - arrowKeyCode: 125 - ) || (ghosttyGotoSplitDownShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↓", arrowKeyCode: 125) } ?? false) { - tabManager?.movePaneFocus(direction: .down) + + panelsCancellable = tab.$panels + .map { _ in () } + .sink { _ in #if DEBUG - recordGotoSplitMoveIfNeeded(direction: .down) + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.panelsChanged " + + "workspace=\(Self.debugShortId(tab.id)) " + + "focused=\(Self.debugShortId(tab.focusedPanelId))" + ) + } #endif - return true - } - - if matchConfiguredShortcut(event: event, action: .toggleSplitZoom) { - _ = tabManager?.toggleFocusedSplitZoom() + finishIfReady() + } + if isReactGrabPasteback { + focusObserver = NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { note in + guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, + candidateTabId == tab.id, + let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { + return + } #if DEBUG - recordGotoSplitZoomIfNeeded() + dlog( + "reactGrab.pasteback h1.focusEvent " + + "workspace=\(Self.debugShortId(candidateTabId)) " + + "surface=\(Self.debugShortId(candidateSurfaceId)) " + + "target=\(Self.debugShortId(preferredPanelId)) " + + "match=\(candidateSurfaceId == preferredPanelId ? 1 : 0)" + ) #endif - return true - } - - // Split actions: Cmd+D / Cmd+Shift+D - if matchConfiguredShortcut(event: event, action: .splitRight) { + } + firstResponderObserver = NotificationCenter.default.addObserver( + forName: .ghosttyDidBecomeFirstResponderSurface, + object: nil, + queue: .main + ) { note in + guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, + candidateTabId == tab.id, + let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { + return + } #if DEBUG - dlog("shortcut.action name=splitRight \(debugShortcutRouteSnapshot(event: event))") + dlog( + "reactGrab.pasteback h1.firstResponderEvent " + + "workspace=\(Self.debugShortId(candidateTabId)) " + + "surface=\(Self.debugShortId(candidateSurfaceId)) " + + "target=\(Self.debugShortId(preferredPanelId)) " + + "match=\(candidateSurfaceId == preferredPanelId ? 1 : 0)" + ) #endif - if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) { - return true } - _ = performSplitShortcut( - direction: .right, - preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - ) - return true } - - if matchConfiguredShortcut(event: event, action: .splitDown) { + readyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { note in + guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, + workspaceId == tab.id else { return } + let surfaceId = note.userInfo?["surfaceId"] as? UUID #if DEBUG - dlog("shortcut.action name=splitDown \(debugShortcutRouteSnapshot(event: event))") + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.surfaceReadyEvent " + + "workspace=\(Self.debugShortId(workspaceId)) " + + "surface=\(Self.debugShortId(surfaceId)) " + + "target=\(Self.debugShortId(preferredPanelId)) " + + "match=\(surfaceId == preferredPanelId ? 1 : 0)" + ) + } #endif - if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) { - return true + if let preferredPanelId, + let surfaceId, + surfaceId != preferredPanelId { + return } - _ = performSplitShortcut( - direction: .down, - preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - ) - return true + finishIfReady() } - - if matchConfiguredShortcut(event: event, action: .splitBrowserRight) { + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + if !resolved { #if DEBUG - dlog("shortcut.action name=splitBrowserRight \(debugShortcutRouteSnapshot(event: event))") + if isReactGrabPasteback { + dlog( + "reactGrab.pasteback h2.send.timeout " + + "workspace=\(Self.debugShortId(tab.id)) " + + "preferred=\(Self.debugShortId(preferredPanelId)) " + + "focused=\(Self.debugShortId(tab.focusedPanelId)) " + + "focusedTerminal=\(Self.debugShortId(tab.focusedTerminalPanel?.id))" + ) + } #endif - _ = performBrowserSplitShortcut(direction: .right) - return true + cleanupObservers() + NSLog("Command send: surface not ready after 3.0s") + } } + } - if matchConfiguredShortcut(event: event, action: .splitBrowserDown) { #if DEBUG - dlog("shortcut.action name=splitBrowserDown \(debugShortcutRouteSnapshot(event: event))") -#endif - _ = performBrowserSplitShortcut(direction: .down) - return true - } - - // Surface navigation (legacy Ctrl+Tab support) - if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: false, option: false, control: true)) { - tabManager?.selectNextSurface() - return true - } - if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: true, option: false, control: true)) { - tabManager?.selectPreviousSurface() - return true - } + private let debugColorWorkspaceTitlePrefix = "Debug Color - " + let debugPerfWorkspaceTitlePrefix = "Debug Perf - " + var debugStressWorkspaceCreationInProgress = false + var debugStressLagProbeEnabled = false + let debugStressWorkspaceCount = 20 + let debugStressPaneCount = 4 + let debugStressTabsPerPane = 4 + let debugStressYieldInterval = 4 + let debugStressSurfaceLoadTimeoutSeconds: TimeInterval = 10.0 - // New surface: Cmd+T - if matchConfiguredShortcut(event: event, action: .newSurface) { - tabManager?.newSurface() - return true - } + @objc func openDebugScrollbackTab(_ sender: Any?) { + guard let tabManager else { return } + let tab = tabManager.addTab() + let config = GhosttyConfig.load() + let lineCount = min(max(config.scrollbackLimit * 2, 2000), 60000) + let command = "for i in {1..\(lineCount)}; do printf \"scrollback %06d\\n\" $i; done\n" + sendTextWhenReady(command, to: tab) + } - // Open browser: Cmd+Shift+L - if matchConfiguredShortcut(event: event, action: .openBrowser) { - _ = openBrowserAndFocusAddressBar(insertAtEnd: true) - return true + @objc func openDebugLoremTab(_ sender: Any?) { + guard let tabManager else { return } + let tab = tabManager.addTab() + let lineCount = 2000 + let base = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore." + var lines: [String] = [] + lines.reserveCapacity(lineCount) + for index in 1...lineCount { + lines.append(String(format: "%04d %@", index, base)) } + let payload = lines.joined(separator: "\n") + "\n" + sendTextWhenReady(payload, to: tab) + } - if matchConfiguredShortcut(event: event, action: .focusBrowserAddressBar) { - if let focusedPanel = tabManager?.focusedBrowserPanel { - focusBrowserAddressBar(in: focusedPanel) - return true - } + @objc func openDebugColorComparisonWorkspaces(_ sender: Any?) { + guard let tabManager else { return } - if let browserAddressBarFocusedPanelId, - focusBrowserAddressBar(panelId: browserAddressBarFocusedPanelId) { - return true - } + let palette = WorkspaceTabColorSettings.palette() + guard !palette.isEmpty else { return } - if openBrowserAndFocusAddressBar(insertAtEnd: true) != nil { - return true - } + var existingByTitle: [String: Workspace] = [:] + for tab in tabManager.tabs { + guard let title = tab.customTitle, + title.hasPrefix(debugColorWorkspaceTitlePrefix) else { continue } + existingByTitle[title] = tab } - if matchConfiguredShortcut(event: event, action: .browserBack) { - guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { - return false + for entry in palette { + let title = "\(debugColorWorkspaceTitlePrefix)\(entry.name)" + let targetTab: Workspace + if let existing = existingByTitle[title] { + targetTab = existing + } else { + targetTab = tabManager.addTab() } - focusedBrowserPanel.goBack() - return true + tabManager.setCustomTitle(tabId: targetTab.id, title: title) + tabManager.setTabColor(tabId: targetTab.id, color: entry.hex) } + } - if matchConfiguredShortcut(event: event, action: .browserForward) { - guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { - return false - } - focusedBrowserPanel.goForward() - return true - } - if matchConfiguredShortcut(event: event, action: .browserReload) { - guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { - return false +#endif + + + func attachUpdateAccessory(to window: NSWindow) { + titlebarAccessoryController.start() + titlebarAccessoryController.attach(to: window) + } + + func applyWindowDecorations(to window: NSWindow) { + windowDecorationsController.apply(to: window) + } + + func toggleNotificationsPopover(animated: Bool = true, anchorView: NSView? = nil) { + titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView) + } + + @discardableResult + func dismissNotificationsPopoverIfShown() -> Bool { + titlebarAccessoryController.dismissNotificationsPopoverIfShown() + } + + func isNotificationsPopoverShown() -> Bool { + titlebarAccessoryController.isNotificationsPopoverShown() + } + + func jumpToLatestUnread() { + guard let notificationStore else { return } +#if DEBUG + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData([ + "jumpUnreadInvoked": "1", + "jumpUnreadNotificationCount": String(notificationStore.notifications.count), + ]) + } +#endif + // Prefer the latest unread that we can actually open. In early startup (especially on the VM), + // the window-context registry can lag behind model initialization, so fall back to whatever + // tab manager currently owns the tab. + for notification in notificationStore.notifications where !notification.isRead { + if openNotification(tabId: notification.tabId, surfaceId: notification.surfaceId, notificationId: notification.id) { + return } - focusedBrowserPanel.reload() - return true } + } + + static func installWindowResponderSwizzlesForTesting() { + _ = didInstallWindowKeyEquivalentSwizzle + _ = didInstallWindowFirstResponderSwizzle + _ = didInstallWindowSendEventSwizzle + } - // Safari defaults: - // - Option+Command+I => Show/Toggle Web Inspector - // - Option+Command+C => Show JavaScript Console - if matchConfiguredShortcut(event: event, action: .toggleBrowserDeveloperTools) { #if DEBUG - logDeveloperToolsShortcutSnapshot(phase: "toggle.pre", event: event) + static func setWindowFirstResponderGuardTesting(currentEvent: NSEvent?, hitView: NSView?) { + programaFirstResponderGuardCurrentEventOverride = currentEvent + programaFirstResponderGuardHitViewOverride = hitView + } + + static func clearWindowFirstResponderGuardTesting() { + programaFirstResponderGuardCurrentEventOverride = nil + programaFirstResponderGuardHitViewOverride = nil + } #endif - let didHandle = tabManager?.toggleDeveloperToolsFocusedBrowser() ?? false + + private func installWindowResponderSwizzles() { + _ = Self.didInstallApplicationSendEventSwizzle + _ = Self.didInstallWindowKeyEquivalentSwizzle + _ = Self.didInstallWindowFirstResponderSwizzle + _ = Self.didInstallWindowSendEventSwizzle + } + + private func installShortcutMonitor() { + // Local monitor only receives events when app is active (not global) + shortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp, .flagsChanged]) { [weak self] event in + guard let self else { return event } + if event.type == .keyDown { #if DEBUG - logDeveloperToolsShortcutSnapshot(phase: "toggle.post", event: event, didHandle: didHandle) - DispatchQueue.main.async { [weak self] in - self?.logDeveloperToolsShortcutSnapshot(phase: "toggle.tick", didHandle: didHandle) - } + let phaseTotalStart = ProcessInfo.processInfo.systemUptime + let preludeStart = ProcessInfo.processInfo.systemUptime + var preludeMs: Double = 0 + var shortcutMs: Double = 0 + ProgramaTypingTiming.logEventDelay(path: "appMonitor", event: event) + let shortcutMonitorTraceEnabled = + ProcessInfo.processInfo.environment["PROGRAMA_SHORTCUT_MONITOR_TRACE"] == "1" + || UserDefaults.standard.bool(forKey: "programaShortcutMonitorTrace") + if shortcutMonitorTraceEnabled { + let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil") \(self.debugShortcutRouteSnapshot(event: event))" + ) + } + if let probeKind = self.developerToolsShortcutProbeKind(event: event) { + self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event) + } + preludeMs = (ProcessInfo.processInfo.systemUptime - preludeStart) * 1000.0 + let shortcutTimingStart = ProgramaTypingTiming.start() #endif - if !didHandle { NSSound.beep() } - return true - } - - if matchConfiguredShortcut(event: event, action: .showBrowserJavaScriptConsole) { + let shortcutStart = ProcessInfo.processInfo.systemUptime + let handledByShortcut = self.handleCustomShortcut(event: event) #if DEBUG - logDeveloperToolsShortcutSnapshot(phase: "console.pre", event: event) + shortcutMs = (ProcessInfo.processInfo.systemUptime - shortcutStart) * 1000.0 + ProgramaTypingTiming.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 + ProgramaTypingTiming.logBreakdown( + path: "appMonitor.phase", + totalMs: totalMs, + event: event, + thresholdMs: 0.75, + parts: [ + ("preludeMs", preludeMs), + ("shortcutMs", shortcutMs), + ], + extra: "handled=\(handledByShortcut ? 1 : 0)" + ) #endif - let didHandle = tabManager?.showJavaScriptConsoleFocusedBrowser() ?? false + if handledByShortcut { #if DEBUG - logDeveloperToolsShortcutSnapshot(phase: "console.post", event: event, didHandle: didHandle) - DispatchQueue.main.async { [weak self] in - self?.logDeveloperToolsShortcutSnapshot(phase: "console.tick", didHandle: didHandle) - } + dlog(" → consumed by handleCustomShortcut") #endif - if !didHandle { NSSound.beep() } - return true + return nil // Consume the event + } + return event // Pass through + } + self.handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) + if self.clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true) { + return nil + } + return event } + } - if matchConfiguredShortcut(event: event, action: .toggleReactGrab) { - let didHandle = tabManager?.toggleReactGrabFromCurrentFocus() ?? false - if !didHandle { NSSound.beep() } - return true + private func installShortcutDefaultsObserver() { + guard shortcutDefaultsObserver == nil else { return } + refreshConfiguredShortcutChordActions() + shortcutDefaultsObserver = NotificationCenter.default.addObserver( + forName: KeyboardShortcutSettings.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshConfiguredShortcutChordActions() + self?.clearConfiguredShortcutChordState() + self?.scheduleSplitButtonTooltipRefreshAcrossWorkspaces() } + } - if matchConfiguredShortcut(event: event, action: .browserZoomIn) { - return tabManager?.zoomInFocusedBrowser() ?? false + private func refreshConfiguredShortcutChordActions() { + configuredShortcutChordActions = KeyboardShortcutSettings.Action.allCases.filter { + KeyboardShortcutSettings.shortcut(for: $0).hasChord } + } - if matchConfiguredShortcut(event: event, action: .browserZoomOut) { - return tabManager?.zoomOutFocusedBrowser() ?? false - } + private func clearConfiguredShortcutChordState() { + pendingConfiguredShortcutChord = nil + activeConfiguredShortcutChordPrefixForCurrentEvent = nil + } - if matchConfiguredShortcut(event: event, action: .browserZoomReset) { - return tabManager?.resetZoomFocusedBrowser() ?? false + /// Coalesce shortcut-default changes and refresh on the next runloop turn to + /// avoid mutating Bonsplit/SwiftUI-observed state during an active update pass. + private func scheduleSplitButtonTooltipRefreshAcrossWorkspaces() { + guard !splitButtonTooltipRefreshScheduled else { return } + splitButtonTooltipRefreshScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.splitButtonTooltipRefreshScheduled = false + self.refreshSplitButtonTooltipsAcrossWorkspaces() } + } - if matchConfiguredShortcut(event: event, action: .find) { - guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { - return false - } - tabManager?.startSearch() - return true + private func refreshSplitButtonTooltipsAcrossWorkspaces() { + var refreshedManagers: Set = [] + if let manager = tabManager { + manager.refreshSplitButtonTooltips() + refreshedManagers.insert(ObjectIdentifier(manager)) + } + for context in mainWindowContexts.values { + let manager = context.tabManager + let identifier = ObjectIdentifier(manager) + guard refreshedManagers.insert(identifier).inserted else { continue } + manager.refreshSplitButtonTooltips() } + } - if matchConfiguredShortcut(event: event, action: .findNext) { - guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { - return false - } - tabManager?.findNext() - return true + private func installGhosttyConfigObserver() { + guard ghosttyConfigObserver == nil else { return } + ghosttyConfigObserver = NotificationCenter.default.addObserver( + forName: .ghosttyConfigDidReload, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshGhosttyGotoSplitShortcuts() } + } - if matchConfiguredShortcut(event: event, action: .findPrevious) { - guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { - return false - } - tabManager?.findPrevious() - return true + private func refreshGhosttyGotoSplitShortcuts() { + guard let config = GhosttyApp.shared.config else { + ghosttyGotoSplitLeftShortcut = nil + ghosttyGotoSplitRightShortcut = nil + ghosttyGotoSplitUpShortcut = nil + ghosttyGotoSplitDownShortcut = nil + return } - if matchConfiguredShortcut(event: event, action: .hideFind) { - guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { - return false + ghosttyGotoSplitLeftShortcut = storedShortcutFromGhosttyTrigger( + ghostty_config_trigger(config, "goto_split:left", UInt("goto_split:left".utf8.count)) + ) + ghosttyGotoSplitRightShortcut = storedShortcutFromGhosttyTrigger( + ghostty_config_trigger(config, "goto_split:right", UInt("goto_split:right".utf8.count)) + ) + ghosttyGotoSplitUpShortcut = storedShortcutFromGhosttyTrigger( + ghostty_config_trigger(config, "goto_split:up", UInt("goto_split:up".utf8.count)) + ) + ghosttyGotoSplitDownShortcut = storedShortcutFromGhosttyTrigger( + ghostty_config_trigger(config, "goto_split:down", UInt("goto_split:down".utf8.count)) + ) + } + + private func storedShortcutFromGhosttyTrigger(_ trigger: ghostty_input_trigger_s) -> StoredShortcut? { + let key: String + switch trigger.tag { + case GHOSTTY_TRIGGER_PHYSICAL: + switch trigger.key.physical { + case GHOSTTY_KEY_ARROW_LEFT: + key = "←" + case GHOSTTY_KEY_ARROW_RIGHT: + key = "→" + case GHOSTTY_KEY_ARROW_UP: + key = "↑" + case GHOSTTY_KEY_ARROW_DOWN: + key = "↓" + case GHOSTTY_KEY_A: key = "a" + case GHOSTTY_KEY_B: key = "b" + case GHOSTTY_KEY_C: key = "c" + case GHOSTTY_KEY_D: key = "d" + case GHOSTTY_KEY_E: key = "e" + case GHOSTTY_KEY_F: key = "f" + case GHOSTTY_KEY_G: key = "g" + case GHOSTTY_KEY_H: key = "h" + case GHOSTTY_KEY_I: key = "i" + case GHOSTTY_KEY_J: key = "j" + case GHOSTTY_KEY_K: key = "k" + case GHOSTTY_KEY_L: key = "l" + case GHOSTTY_KEY_M: key = "m" + case GHOSTTY_KEY_N: key = "n" + case GHOSTTY_KEY_O: key = "o" + case GHOSTTY_KEY_P: key = "p" + case GHOSTTY_KEY_Q: key = "q" + case GHOSTTY_KEY_R: key = "r" + case GHOSTTY_KEY_S: key = "s" + case GHOSTTY_KEY_T: key = "t" + case GHOSTTY_KEY_U: key = "u" + case GHOSTTY_KEY_V: key = "v" + case GHOSTTY_KEY_W: key = "w" + case GHOSTTY_KEY_X: key = "x" + case GHOSTTY_KEY_Y: key = "y" + case GHOSTTY_KEY_Z: key = "z" + case GHOSTTY_KEY_DIGIT_0: key = "0" + case GHOSTTY_KEY_DIGIT_1: key = "1" + case GHOSTTY_KEY_DIGIT_2: key = "2" + case GHOSTTY_KEY_DIGIT_3: key = "3" + case GHOSTTY_KEY_DIGIT_4: key = "4" + case GHOSTTY_KEY_DIGIT_5: key = "5" + case GHOSTTY_KEY_DIGIT_6: key = "6" + case GHOSTTY_KEY_DIGIT_7: key = "7" + case GHOSTTY_KEY_DIGIT_8: key = "8" + case GHOSTTY_KEY_DIGIT_9: key = "9" + case GHOSTTY_KEY_BRACKET_LEFT: key = "[" + case GHOSTTY_KEY_BRACKET_RIGHT: key = "]" + case GHOSTTY_KEY_MINUS: key = "-" + case GHOSTTY_KEY_EQUAL: key = "=" + case GHOSTTY_KEY_COMMA: key = "," + case GHOSTTY_KEY_PERIOD: key = "." + case GHOSTTY_KEY_SLASH: key = "/" + case GHOSTTY_KEY_SEMICOLON: key = ";" + case GHOSTTY_KEY_QUOTE: key = "'" + case GHOSTTY_KEY_BACKQUOTE: key = "`" + case GHOSTTY_KEY_BACKSLASH: key = "\\" + default: + return nil } - tabManager?.hideFind() - return true + case GHOSTTY_TRIGGER_UNICODE: + guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil } + key = String(Character(scalar)).lowercased() + case GHOSTTY_TRIGGER_CATCH_ALL: + return nil + default: + return nil } - if matchConfiguredShortcut(event: event, action: .useSelectionForFind) { - tabManager?.searchSelection() - return true - } + let mods = trigger.mods.rawValue + let command = (mods & GHOSTTY_MODS_SUPER.rawValue) != 0 + let shift = (mods & GHOSTTY_MODS_SHIFT.rawValue) != 0 + let option = (mods & GHOSTTY_MODS_ALT.rawValue) != 0 + let control = (mods & GHOSTTY_MODS_CTRL.rawValue) != 0 - if matchConfiguredShortcut(event: event, action: .reopenClosedBrowserPanel) { - _ = tabManager?.reopenMostRecentlyClosedBrowserPanel() - return true + // Ignore bogus empty triggers. + if key.isEmpty || (!command && !shift && !option && !control) { + return nil } - return false + return StoredShortcut(key: key, command: command, shift: shift, option: option, control: control) } - private func shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: SplitDirection) -> Bool { - guard let tabManager, - let workspace = tabManager.selectedWorkspace, - let focusedPanelId = workspace.focusedPanelId, - let terminalPanel = workspace.terminalPanel(for: focusedPanelId) else { - return false + private func handleQuitShortcutWarning() -> Bool { + // Tagged DEV builds are ephemeral, skip quit confirmation entirely. + if SocketControlSettings.isTaggedDevBuild() { + NSApp.terminate(nil) + return true } - let hostedView = terminalPanel.hostedView - let hostedSize = hostedView.bounds.size - let hostedHiddenInHierarchy = hostedView.isHiddenOrHasHiddenAncestor - let hostedAttachedToWindow = hostedView.window != nil - let firstResponderIsWindow = NSApp.keyWindow?.firstResponder is NSWindow + if !QuitWarningSettings.isEnabled() { + NSApp.terminate(nil) + return true + } - let shouldSuppress = shouldSuppressSplitShortcutForTransientTerminalFocusInputs( - firstResponderIsWindow: firstResponderIsWindow, - hostedSize: hostedSize, - hostedHiddenInHierarchy: hostedHiddenInHierarchy, - hostedAttachedToWindow: hostedAttachedToWindow - ) - guard shouldSuppress else { return false } + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "dialog.quitPrograma.title", defaultValue: "Quit Programa?") + alert.informativeText = String(localized: "dialog.quitPrograma.message", defaultValue: "This will close all windows and workspaces.") + alert.addButton(withTitle: String(localized: "dialog.quitPrograma.quit", defaultValue: "Quit")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.showsSuppressionButton = true + alert.suppressionButton?.title = String(localized: "dialog.dontWarnCmdQ", defaultValue: "Don't warn again for Cmd+Q") - tabManager.reconcileFocusedPanelFromFirstResponderForKeyboard() + let response = alert.runModal() + if alert.suppressionButton?.state == .on { + QuitWarningSettings.setEnabled(false) + } -#if DEBUG - let directionLabel: String - switch direction { - case .left: directionLabel = "left" - case .right: directionLabel = "right" - case .up: directionLabel = "up" - case .down: directionLabel = "down" + if response == .alertFirstButtonReturn { + // Mark as confirmed so applicationShouldTerminate does not show a + // second alert when NSApp.terminate re-enters the delegate callback. + isQuitWarningConfirmed = true + NSApp.terminate(nil) } - let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - dlog( - "split.shortcut suppressed dir=\(directionLabel) reason=transient_focus_state " + - "fr=\(firstResponderType) hidden=\(hostedHiddenInHierarchy ? 1 : 0) " + - "attached=\(hostedAttachedToWindow ? 1 : 0) " + - "frame=\(String(format: "%.1fx%.1f", hostedSize.width, hostedSize.height))" - ) -#endif return true } -#if DEBUG - private func logBrowserZoomShortcutTrace( - stage: String, - event: NSEvent, - flags: NSEvent.ModifierFlags, - chars: String, - action: BrowserZoomShortcutAction? = nil, - handled: Bool? = nil - ) { - guard browserZoomShortcutTraceCandidate( - flags: flags, - chars: chars, - keyCode: event.keyCode, - literalChars: event.characters - ) else { - return + func promptRenameSelectedWorkspace() -> Bool { + guard let tabManager, + let tabId = tabManager.selectedTabId, + let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { + NSSound.beep() + return false } - let keyWindow = NSApp.keyWindow - let firstResponderType = keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let panel = tabManager?.focusedBrowserPanel - let panelToken = panel.map { String($0.id.uuidString.prefix(8)) } ?? "nil" - let panelZoom = panel?.webView.pageZoom ?? -1 - var line = - "zoom.shortcut stage=\(stage) event=\(NSWindow.keyDescription(event)) " + - "chars='\(chars)' flags=\(browserZoomShortcutTraceFlagsString(flags)) " + - "action=\(browserZoomShortcutTraceActionString(action)) keyWin=\(keyWindow?.windowNumber ?? -1) " + - "fr=\(firstResponderType) panel=\(panelToken) zoom=\(String(format: "%.3f", panelZoom)) " + - "addrBarId=\(browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")" - if let handled { - line += " handled=\(handled ? 1 : 0)" + let alert = NSAlert() + alert.messageText = String(localized: "dialog.renameWorkspace.title", defaultValue: "Rename Workspace") + alert.informativeText = String(localized: "dialog.renameWorkspace.message", defaultValue: "Enter a custom name for this workspace.") + let input = NSTextField(string: tab.customTitle ?? tab.title) + input.placeholderString = String(localized: "dialog.renameWorkspace.placeholder", defaultValue: "Workspace name") + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) } - dlog(line) - } - private func browserFocusStateSnapshot() -> String { - let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" - let focused = tabManager?.selectedWorkspace?.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - let addressBar = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - let keyWindow = NSApp.keyWindow?.windowNumber ?? -1 - let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - return "selected=\(selected) focused=\(focused) addr=\(addressBar) keyWin=\(keyWindow) fr=\(firstResponderType)" + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return true } + tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) + return true } - private func redactedDebugURL(_ url: URL?) -> String { - guard let url else { return "nil" } - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return "" + private func handleCustomShortcut(event: NSEvent) -> Bool { + // `charactersIgnoringModifiers` can be nil for some synthetic NSEvents and certain special keys. + // Treat nil as "" and rely on keyCode/layout-aware fallback logic where needed. + // When a non-Latin input source is active (Korean, Chinese, Japanese, etc.), + // charactersIgnoringModifiers returns non-ASCII characters that never match + // Latin shortcut keys. Normalize via KeyboardLayout so downstream comparisons + // (Cmd+1-9, Ctrl+1-9, omnibar N/P, command palette, etc.) work correctly. + let chars = KeyboardLayout.normalizedCharacters(for: event) + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let hasControl = flags.contains(.control) + let hasCommand = flags.contains(.command) + let hasOption = flags.contains(.option) + let isControlOnly = hasControl && !hasCommand && !hasOption + let controlDChar = chars == "d" || event.characters == "\u{04}" + let isControlD = isControlOnly && (controlDChar || event.keyCode == 2) + let configuredShortcutEventWindowNumber = configuredShortcutChordWindowNumber(for: event) + if let pendingConfiguredShortcutChord, + pendingConfiguredShortcutChord.windowNumber == configuredShortcutEventWindowNumber { + activeConfiguredShortcutChordPrefixForCurrentEvent = pendingConfiguredShortcutChord.firstStroke + } else { + activeConfiguredShortcutChordPrefixForCurrentEvent = nil } - components.user = nil - components.password = nil - components.query = nil - components.fragment = nil - return components.string ?? "" - } -#endif - - @discardableResult - private func focusBrowserAddressBar(panelId: UUID) -> Bool { - guard let tabManager, - let workspace = tabManager.selectedWorkspace, - let panel = workspace.browserPanel(for: panelId) else { + pendingConfiguredShortcutChord = nil + defer { activeConfiguredShortcutChordPrefixForCurrentEvent = nil } #if DEBUG - dlog( - "browser.focus.addressBar.route panel=\(panelId.uuidString.prefix(5)) " + - "result=miss \(browserFocusStateSnapshot())" + if isControlD { + writeChildExitKeyboardProbe( + [ + "probeAppShortcutCharsHex": childExitKeyboardProbeHex(event.characters), + "probeAppShortcutCharsIgnoringHex": childExitKeyboardProbeHex(event.charactersIgnoringModifiers), + "probeAppShortcutKeyCode": String(event.keyCode), + "probeAppShortcutModsRaw": String(event.modifierFlags.rawValue), + ], + increments: ["probeAppShortcutCtrlDSeenCount": 1] ) + } #endif + + // Don't steal shortcuts from close-confirmation alerts. Keep standard alert key + // equivalents working and avoid surprising actions while the confirmation is up. + let closeConfirmationTitles = [ + String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"), + String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?"), + String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), + String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"), + String(localized: "dialog.closeWindow.title", defaultValue: "Close window?"), + ] + let closeConfirmationPanel = NSApp.windows + .compactMap { $0 as? NSPanel } + .first { panel in + guard panel.isVisible, let root = panel.contentView else { return false } + return closeConfirmationTitles.contains { title in + findStaticText(in: root, equals: title) + } + } + if let closeConfirmationPanel { + // Special-case: Cmd+D should confirm destructive close on alerts. + // XCUITest key events often hit the app-level local monitor first, so forward the key + // equivalent to the alert panel explicitly. + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: "d", command: true, shift: false, option: false, control: false) + ), + let root = closeConfirmationPanel.contentView, + let closeButton = findButton( + in: root, + titled: String(localized: "common.close", defaultValue: "Close") + ) { + closeButton.performClick(nil) + return true + } return false } + + if NSApp.modalWindow != nil || NSApp.keyWindow?.attachedSheet != nil { + return false + } + + let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) + let commandPaletteTargetWindow = commandPaletteWindowForShortcutEvent(event) + let commandPaletteShortcutWindow = shouldHandleCommandPaletteShortcutEvent( + event, + paletteWindow: commandPaletteTargetWindow + ) ? commandPaletteTargetWindow : nil + let commandPaletteVisibleInTargetWindow = commandPaletteShortcutWindow.map { + isCommandPaletteVisible(for: $0) + } ?? false + let commandPalettePendingOpenInTargetWindow = commandPaletteTargetWindow.map { + isCommandPalettePendingOpen(for: $0) + } ?? false + let commandPaletteOverlayVisibleInTargetWindow = commandPaletteTargetWindow.map { + isCommandPaletteOverlayPresented(in: $0) + } ?? false + let commandPaletteResponderActiveInTargetWindow = commandPaletteTargetWindow.map { + isCommandPaletteResponderActive(in: $0) + } ?? false + let commandPaletteInteractiveInTargetWindow = + commandPaletteVisibleInTargetWindow + || commandPaletteOverlayVisibleInTargetWindow + || commandPaletteResponderActiveInTargetWindow + let commandPaletteEffectiveInTargetWindow = + commandPaletteInteractiveInTargetWindow + || commandPalettePendingOpenInTargetWindow + #if DEBUG - dlog( - "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + - "workspace=\(workspace.id.uuidString.prefix(5)) result=hit \(browserFocusStateSnapshot())" - ) -#endif - workspace.focusPanel(panel.id) -#if DEBUG - let focusedAfter = workspace.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - dlog( - "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + - "workspace=\(workspace.id.uuidString.prefix(5)) focusedAfter=\(focusedAfter)" - ) + if event.keyCode == 36 || event.keyCode == 76 { + dlog( + "shortcut.return.raw " + + "interactive=\(commandPaletteInteractiveInTargetWindow ? 1 : 0) " + + "effective=\(commandPaletteEffectiveInTargetWindow ? 1 : 0) " + + "target={\(debugWindowToken(commandPaletteTargetWindow))} " + + "shortcutWindow={\(debugWindowToken(commandPaletteShortcutWindow))} " + + "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0) " + + "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + + "pendingTarget=\(commandPalettePendingOpenInTargetWindow ? 1 : 0) " + + "\(debugShortcutRouteSnapshot(event: event))" + ) + } #endif - focusBrowserAddressBar(in: panel) - return true - } - @discardableResult - func openBrowserAndFocusAddressBar(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? { - let preferredProfileID = - tabManager?.focusedBrowserPanel?.profileID - ?? tabManager?.selectedWorkspace?.preferredBrowserProfileID - guard let panelId = tabManager?.openBrowser( - url: url, - preferredProfileID: preferredProfileID, - insertAtEnd: insertAtEnd - ) else { + if normalizedFlags.isEmpty, event.keyCode == 53 { + let activePaletteWindow = activeCommandPaletteWindow() + let escapePaletteWindow: NSWindow? = { + if let targetWindow = commandPaletteTargetWindow { + guard commandPaletteEffectiveInTargetWindow else { + return nil + } + return targetWindow + } + return activePaletteWindow + }() #if DEBUG dlog( - "browser.focus.openAndFocus result=open_failed insertAtEnd=\(insertAtEnd ? 1 : 0) " + - "url=\(redactedDebugURL(url)) \(browserFocusStateSnapshot())" + "shortcut.escape route target={\(debugWindowToken(commandPaletteTargetWindow))} " + + "active={\(debugWindowToken(activePaletteWindow))} " + + "visibleTarget=\(commandPaletteVisibleInTargetWindow ? 1 : 0) " + + "pendingTarget=\(commandPalettePendingOpenInTargetWindow ? 1 : 0) " + + "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + + "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0) " + + "effectiveTarget=\(commandPaletteEffectiveInTargetWindow ? 1 : 0) " + + "\(debugShortcutRouteSnapshot(event: event))" ) + if commandPaletteTargetWindow != nil, + !commandPaletteVisibleInTargetWindow, + !commandPalettePendingOpenInTargetWindow, + (commandPaletteOverlayVisibleInTargetWindow || commandPaletteResponderActiveInTargetWindow) { + dlog( + "shortcut.escape stateMismatch target={\(debugWindowToken(commandPaletteTargetWindow))} " + + "overlayTarget=\(commandPaletteOverlayVisibleInTargetWindow ? 1 : 0) " + + "responderTarget=\(commandPaletteResponderActiveInTargetWindow ? 1 : 0)" + ) + } #endif - return nil - } + if let paletteWindow = escapePaletteWindow, + isCommandPaletteEffectivelyVisible(in: paletteWindow) { + if commandPaletteMarkedTextInput(in: paletteWindow) != nil { #if DEBUG - dlog( - "browser.focus.openAndFocus result=open_ok panel=\(panelId.uuidString.prefix(5)) " + - "insertAtEnd=\(insertAtEnd ? 1 : 0) url=\(redactedDebugURL(url))" - ) + dlog( + "shortcut.escape imeMarkedTextBypass consumed=0 target={\(debugWindowToken(paletteWindow))}" + ) #endif + return false + } + clearCommandPalettePendingOpen(for: paletteWindow) + beginCommandPaletteEscapeSuppression(for: paletteWindow) + NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: paletteWindow) #if DEBUG - let didFocus = focusBrowserAddressBar(panelId: panelId) - dlog( - "browser.focus.openAndFocus result=focus_request panel=\(panelId.uuidString.prefix(5)) " + - "focused=\(didFocus ? 1 : 0) \(browserFocusStateSnapshot())" - ) -#else - _ = focusBrowserAddressBar(panelId: panelId) + dlog("shortcut.escape paletteDismiss consumed=1 target={\(debugWindowToken(paletteWindow))}") #endif - return panelId - } - - private func focusBrowserAddressBar(in panel: BrowserPanel) { + return true + } + let suppressionWindow = commandPaletteTargetWindow + ?? event.window + ?? NSApp.keyWindow + ?? NSApp.mainWindow + if shouldConsumeSuppressedEscape(event: event, window: suppressionWindow) { #if DEBUG - let requestId = panel.requestAddressBarFocus() - dlog( - "browser.focus.addressBar.request panel=\(panel.id.uuidString.prefix(5)) " + - "request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())" - ) -#else - _ = panel.requestAddressBarFocus() + dlog( + "shortcut.escape suppressionConsume consumed=1 target={\(debugWindowToken(suppressionWindow))} " + + "repeat=\(event.isARepeat ? 1 : 0)" + ) #endif - browserAddressBarFocusedPanelId = panel.id + return true + } + if let requestAge = recentCommandPaletteRequestAge(for: suppressionWindow) { + beginCommandPaletteEscapeSuppression(for: suppressionWindow) #if DEBUG - dlog( - "browser.focus.addressBar.sticky panel=\(panel.id.uuidString.prefix(5)) " + - "request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())" - ) + dlog( + "shortcut.escape requestGraceConsume consumed=1 target={\(debugWindowToken(suppressionWindow))} " + + "ageMs=\(Int(requestAge * 1000)) repeat=\(event.isARepeat ? 1 : 0)" + ) #endif - NotificationCenter.default.post(name: .browserFocusAddressBar, object: panel.id) + return true + } #if DEBUG - dlog( - "browser.focus.addressBar.notify panel=\(panel.id.uuidString.prefix(5)) " + - "request=\(requestId.uuidString.prefix(8))" - ) + dlog( + "shortcut.escape paletteDismiss consumed=0 target={\(debugWindowToken(commandPaletteTargetWindow))} " + + "active={\(debugWindowToken(activePaletteWindow))}" + ) #endif - } + } - func focusedBrowserAddressBarPanelId() -> UUID? { - browserAddressBarFocusedPanelId - } + let paletteUsesInlineTextHandling = commandPaletteShortcutWindow.map { + isCommandPaletteMultilineTextResponderActive(in: $0) + } ?? false - private func focusedBrowserAddressBarPanelIdForShortcutEvent(_ event: NSEvent) -> UUID? { - guard let panelId = browserAddressBarFocusedPanelId else { return nil } + let paletteSelectionDelta = commandPaletteSelectionDeltaForKeyboardNavigation( + flags: event.modifierFlags, + chars: chars, + keyCode: event.keyCode + ) - guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { -#if DEBUG - dlog( - "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=0 reason=no_context event=\(NSWindow.keyDescription(event))" + if shouldRouteCommandPaletteSelectionNavigation( + delta: paletteSelectionDelta, + isInteractive: commandPaletteInteractiveInTargetWindow, + usesInlineTextHandling: paletteUsesInlineTextHandling + ), + let delta = paletteSelectionDelta, + let paletteWindow = commandPaletteShortcutWindow { + NotificationCenter.default.post( + name: .commandPaletteMoveSelection, + object: paletteWindow, + userInfo: ["delta": delta] ) -#endif - return nil + return true } - guard let workspace = context.tabManager.selectedWorkspace else { -#if DEBUG - dlog( - "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=0 reason=no_workspace event=\(NSWindow.keyDescription(event))" + if commandPaletteInteractiveInTargetWindow, + let paletteWindow = commandPaletteShortcutWindow { + let paletteFieldEditorHasMarkedText = commandPaletteFieldEditorHasMarkedText(in: paletteWindow) + let paletteSnapshot = mainWindowId(for: paletteWindow).map(commandPaletteSnapshot(windowId:)) ?? .empty + let paletteUsesInlineReturnHandling = paletteUsesInlineTextHandling + if normalizedFlags.isEmpty, event.keyCode == 53 { + if paletteFieldEditorHasMarkedText { + return false + } + NotificationCenter.default.post(name: .commandPaletteDismissRequested, object: paletteWindow) + return true + } + + let shouldSubmitPalette = shouldSubmitCommandPaletteWithReturn( + keyCode: event.keyCode, + flags: event.modifierFlags, + mode: paletteSnapshot.mode ) +#if DEBUG + if event.keyCode == 36 || event.keyCode == 76 { + dlog( + "shortcut.palette.return target={\(debugWindowToken(paletteWindow))} " + + "mode=\(paletteSnapshot.mode) " + + "inline=\(paletteUsesInlineReturnHandling ? 1 : 0) " + + "submit=\(shouldSubmitPalette ? 1 : 0) " + + "marked=\(paletteFieldEditorHasMarkedText ? 1 : 0) " + + "\(debugShortcutRouteSnapshot(event: event))" + ) + } #endif - return nil + if paletteUsesInlineReturnHandling, + event.keyCode == 36 || event.keyCode == 76 { + return false + } + if shouldSubmitPalette { + if paletteFieldEditorHasMarkedText { + return false + } + NotificationCenter.default.post(name: .commandPaletteSubmitRequested, object: paletteWindow) + return true + } } - guard workspace.browserPanel(for: panelId) != nil else { + // Guard against stale browserAddressBarFocusedPanelId after focus transitions + // (e.g., split that doesn't properly blur the address bar). If the first responder + // is a terminal surface, the address bar can't be focused. + if browserAddressBarFocusedPanelId != nil, + cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil { #if DEBUG + let stalePanelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" dlog( - "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=0 reason=panel_not_in_workspace workspace=\(workspace.id.uuidString.prefix(5)) " + - "event=\(NSWindow.keyDescription(event))" + "browser.focus.addressBar.staleClear panel=\(stalePanelToken) " + + "reason=terminal_first_responder fr=\(firstResponderType)" ) #endif - return nil + browserAddressBarFocusedPanelId = nil + stopBrowserOmnibarSelectionRepeat() } -#if DEBUG - dlog( - "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + - "accepted=1 workspace=\(workspace.id.uuidString.prefix(5)) event=\(NSWindow.keyDescription(event))" - ) -#endif - return panelId - } + // Keep Cmd+P/Cmd+N inside the focused browser omnibar for Chrome-like + // suggestion navigation, and avoid opening command palette switcher. + // Scope the omnibar check to the shortcut's routed window context so a + // focused omnibar in another window does not suppress Cmd+P here. + let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil - @discardableResult - func requestBrowserAddressBarFocus(panelId: UUID) -> Bool { - focusBrowserAddressBar(panelId: panelId) - } + if commandPaletteEffectiveInTargetWindow { + if matchConfiguredShortcut(event: event, action: .commandPalette) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") + return true + } - private func shouldBypassAppShortcutForFocusedBrowserAddressBar( - flags: NSEvent.ModifierFlags, - chars: String - ) -> Bool { - guard browserAddressBarFocusedPanelId != nil else { return false } - let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) - let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] - guard isCommandOrControlOnly else { return false } - let shouldBypass = chars == "n" || chars == "p" -#if DEBUG - if shouldBypass { - let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - dlog( - "browser.focus.addressBar.shortcutBypass panel=\(panelToken) " + - "chars=\(chars) flags=\(normalizedFlags.rawValue)" - ) + if !hasFocusedAddressBarInShortcutContext, + matchConfiguredShortcut(event: event, action: .goToWorkspace) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") + return true + } + + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + armConfiguredShortcutChordIfNeeded(event: event, actions: [.commandPalette]) { + return true + } + + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + !hasFocusedAddressBarInShortcutContext, + armConfiguredShortcutChordIfNeeded(event: event, actions: [.goToWorkspace]) { + return true + } + } + + if shouldConsumeShortcutWhileCommandPaletteVisible( + isCommandPaletteVisible: commandPaletteEffectiveInTargetWindow, + normalizedFlags: normalizedFlags, + chars: chars, + keyCode: event.keyCode + ) { + return true + } + + // When the terminal has active IME composition (e.g. Korean, Japanese, Chinese + // input), don't intercept non-Cmd key events — let them flow through to the + // input method. Cmd-based shortcuts (Cmd+T, Cmd+Shift+L, etc.) should still + // work during composition since Cmd is never part of IME input sequences. + if !normalizedFlags.contains(.command), + let ghosttyView = cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder), + ghosttyView.hasMarkedText() { + return false + } + + // When the notifications popover is open, Escape should dismiss it immediately. + if flags.isEmpty, event.keyCode == 53, titlebarAccessoryController.dismissNotificationsPopoverIfShown() { + return true } -#endif - return shouldBypass - } - private func commandOmnibarSelectionDelta( - flags: NSEvent.ModifierFlags, - chars: String - ) -> Int? { - browserOmnibarSelectionDeltaForCommandNavigation( - hasFocusedAddressBar: browserAddressBarFocusedPanelId != nil, - flags: flags, - chars: chars - ) - } + // When the notifications popover is showing an empty state, consume plain typing + // so key presses do not leak through into the focused terminal. + if flags.isDisjoint(with: [.command, .control, .option]), + titlebarAccessoryController.isNotificationsPopoverShown(), + (notificationStore?.notifications.isEmpty ?? false) { + return true + } - private func dispatchBrowserOmnibarSelectionMove(delta: Int) { - guard delta != 0 else { return } - guard let panelId = browserAddressBarFocusedPanelId else { return } + let hasEventWindowContext = shortcutEventHasAddressableWindow(event) + let didSynchronizeShortcutContext = synchronizeShortcutRoutingContext(event: event) + if hasEventWindowContext && !didSynchronizeShortcutContext { #if DEBUG - dlog( - "browser.focus.omnibar.selectionMove panel=\(panelId.uuidString.prefix(5)) " + - "delta=\(delta) repeatKey=\(browserOmnibarRepeatKeyCode.map(String.init) ?? "nil")" - ) + dlog("handleCustomShortcut: unresolved event window context; bypassing app shortcut handling") #endif - NotificationCenter.default.post( - name: .browserMoveOmnibarSelection, - object: panelId, - userInfo: ["delta": delta] - ) - } + return false + } - private func startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: UInt16, delta: Int) { - guard delta != 0 else { return } - guard browserAddressBarFocusedPanelId != nil else { + // Keep keyboard routing deterministic after split close/reparent transitions: + // before processing shortcuts, converge first responder with the focused terminal panel. + if isControlD { #if DEBUG - dlog( - "browser.focus.omnibar.repeat.start key=\(keyCode) delta=\(delta) " + - "result=skip_no_focused_address_bar" - ) + let selected = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" + let focused = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" + let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog("shortcut.ctrlD stage=preReconcile selected=\(selected) focused=\(focused) fr=\(frType)") #endif - return + tabManager?.reconcileFocusedPanelFromFirstResponderForKeyboard() + #if DEBUG + let frAfterType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog("shortcut.ctrlD stage=postReconcile fr=\(frAfterType)") + writeChildExitKeyboardProbe([:], increments: ["probeAppShortcutCtrlDPassedCount": 1]) + #endif + // Ctrl+D belongs to the focused terminal surface; never treat it as an app shortcut. + return false } - if browserOmnibarRepeatKeyCode == keyCode, browserOmnibarRepeatDelta == delta { -#if DEBUG - let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - dlog( - "browser.focus.omnibar.repeat.start panel=\(panelToken) " + - "key=\(keyCode) delta=\(delta) result=reuse" - ) -#endif - return + // Chrome-like omnibar navigation while holding Cmd+N / Ctrl+N / Cmd+P / Ctrl+P. + if let delta = commandOmnibarSelectionDelta(flags: flags, chars: chars) { + dispatchBrowserOmnibarSelectionMove(delta: delta) + startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: event.keyCode, delta: delta) + return true } - stopBrowserOmnibarSelectionRepeat() - browserOmnibarRepeatKeyCode = keyCode - browserOmnibarRepeatDelta = delta -#if DEBUG - let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - dlog( - "browser.focus.omnibar.repeat.start panel=\(panelToken) " + - "key=\(keyCode) delta=\(delta) result=armed" - ) -#endif + if let delta = browserOmnibarSelectionDeltaForArrowNavigation( + hasFocusedAddressBar: browserAddressBarFocusedPanelId != nil, + flags: event.modifierFlags, + keyCode: event.keyCode + ) { + dispatchBrowserOmnibarSelectionMove(delta: delta) + return true + } - let start = DispatchWorkItem { [weak self] in - self?.scheduleBrowserOmnibarSelectionRepeatTick() + // Fast path for normal typing and terminal navigation keys (for example Up-arrow + // history): after command-palette/notification handling and browser omnibar + // arrow navigation above, plain key events have no app-level shortcut behavior. + if normalizedFlags.isEmpty && activeConfiguredShortcutChordPrefixForCurrentEvent == nil { + return false } - browserOmnibarRepeatStartWorkItem = start - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: start) - } - private func scheduleBrowserOmnibarSelectionRepeatTick() { - browserOmnibarRepeatStartWorkItem = nil - guard browserAddressBarFocusedPanelId != nil else { -#if DEBUG - dlog("browser.focus.omnibar.repeat.tick result=stop_no_focused_address_bar") -#endif - stopBrowserOmnibarSelectionRepeat() - return + // Let omnibar-local Emacs navigation (Cmd/Ctrl+N/P) win while the browser + // address bar is focused. Without this, app-level Cmd+N can steal focus. + if shouldBypassAppShortcutForFocusedBrowserAddressBar(flags: flags, chars: chars) { + return false } - guard browserOmnibarRepeatKeyCode != nil else { return } -#if DEBUG - let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - dlog( - "browser.focus.omnibar.repeat.tick panel=\(panelToken) " + - "delta=\(browserOmnibarRepeatDelta)" - ) -#endif - dispatchBrowserOmnibarSelectionMove(delta: browserOmnibarRepeatDelta) + if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, + armConfiguredShortcutChordIfNeeded(event: event) { + return true + } - let tick = DispatchWorkItem { [weak self] in - self?.scheduleBrowserOmnibarSelectionRepeatTick() + if matchConfiguredShortcut(event: event, action: .commandPalette) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") + return true } - browserOmnibarRepeatTickWorkItem = tick - DispatchQueue.main.asyncAfter(deadline: .now() + 0.055, execute: tick) - } - private func stopBrowserOmnibarSelectionRepeat() { -#if DEBUG - let previousKeyCode = browserOmnibarRepeatKeyCode - let previousDelta = browserOmnibarRepeatDelta -#endif - browserOmnibarRepeatStartWorkItem?.cancel() - browserOmnibarRepeatTickWorkItem?.cancel() - browserOmnibarRepeatStartWorkItem = nil - browserOmnibarRepeatTickWorkItem = nil - browserOmnibarRepeatKeyCode = nil - browserOmnibarRepeatDelta = 0 -#if DEBUG - if previousKeyCode != nil || previousDelta != 0 { - dlog( - "browser.focus.omnibar.repeat.stop key=\(previousKeyCode.map(String.init) ?? "nil") " + - "delta=\(previousDelta)" - ) + if !hasFocusedAddressBarInShortcutContext, + matchConfiguredShortcut(event: event, action: .goToWorkspace) { + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.goToWorkspace") + return true } -#endif - } - private func handleBrowserOmnibarSelectionRepeatLifecycleEvent(_ event: NSEvent) { - guard browserOmnibarRepeatKeyCode != nil else { return } + if matchConfiguredShortcut(event: event, action: .quit) { + return handleQuitShortcutWarning() + } + if matchConfiguredShortcut(event: event, action: .openSettings) { + openPreferencesWindow(debugSource: "shortcut.openSettings") + return true + } + if matchConfiguredShortcut(event: event, action: .reloadConfiguration) { + GhosttyApp.shared.reloadConfiguration(source: "shortcut.reloadConfiguration") + return true + } - switch event.type { - case .keyUp: - if event.keyCode == browserOmnibarRepeatKeyCode { -#if DEBUG - dlog( - "browser.focus.omnibar.repeat.lifecycle event=keyUp key=\(event.keyCode) " + - "action=stop" - ) -#endif - stopBrowserOmnibarSelectionRepeat() + if matchConfiguredShortcut(event: event, action: .toggleFullScreen) { + guard let targetWindow = mainWindowForShortcutEvent(event) else { + return false } - case .flagsChanged: - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if !flags.contains(.command) { + targetWindow.toggleFullScreen(nil) + return true + } + + // Primary UI shortcuts + if matchConfiguredShortcut(event: event, action: .toggleSidebar) { + _ = toggleSidebarInActiveMainWindow() + return true + } + + if matchConfiguredShortcut(event: event, action: .newTab) { #if DEBUG - dlog( - "browser.focus.omnibar.repeat.lifecycle event=flagsChanged " + - "flags=\(flags.rawValue) action=stop" - ) + dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))") #endif - stopBrowserOmnibarSelectionRepeat() + // Cmd+N semantics: + // - If there are no main windows, create a new window. + // - Otherwise, create a new workspace in the active window. + if mainWindowContexts.isEmpty { + #if DEBUG + logWorkspaceCreationRouting( + phase: "fallback_new_window", + source: "shortcut.cmdN", + reason: "no_main_windows", + event: event, + chosenContext: nil + ) + #endif + openNewMainWindow(nil) + } else if addWorkspaceInPreferredMainWindow(event: event, debugSource: "shortcut.cmdN") == nil { + #if DEBUG + logWorkspaceCreationRouting( + phase: "fallback_new_window", + source: "shortcut.cmdN", + reason: "workspace_creation_returned_nil", + event: event, + chosenContext: nil + ) + #endif + openNewMainWindow(nil) } - default: - break + return true + } + + // New Window: Cmd+Shift+N + // Handled here instead of relying on SwiftUI's CommandGroup menu item because + // after a browser panel has been shown, SwiftUI's menu dispatch can silently + // consume the key equivalent without firing the action closure. + if matchConfiguredShortcut(event: event, action: .newWindow) { + openNewMainWindow(nil) + return true + } + + // Open Folder: Cmd+O + // Handled here to prevent AppKit's default NSDocumentController from opening + // the Documents folder when SwiftUI menu dispatch fails due to focus bugs. + if matchConfiguredShortcut(event: event, action: .openFolder) { + showOpenFolderPanel() + return true } - } - - private func isLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool { - programaIsLikelyWebInspectorResponder(responder) - } -#if DEBUG - private func developerToolsShortcutProbeKind(event: NSEvent) -> String? { - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) { - return "toggle.configured" - } - if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) { - return "console.configured" + // Check Show Notifications shortcut + if matchConfiguredShortcut(event: event, action: .showNotifications) { + toggleNotificationsPopover(animated: false, anchorView: fullscreenControlsViewModel?.notificationsAnchorView) + return true } - let chars = (event.charactersIgnoringModifiers ?? "").lowercased() - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if flags == [.command, .option] { - if chars == "i" || event.keyCode == 34 { - return "toggle.literal" - } - if chars == "c" || event.keyCode == 8 { - return "console.literal" + if matchConfiguredShortcut(event: event, action: .sendFeedback) { + guard let targetContext = preferredMainWindowContextForShortcuts(event: event), + let targetWindow = targetContext.window ?? windowForMainWindowId(targetContext.windowId) else { + return false } + setActiveMainWindow(targetWindow) + bringToFront(targetWindow) + NotificationCenter.default.post(name: .feedbackComposerRequested, object: targetWindow) + return true } - return nil - } - private func logDeveloperToolsShortcutSnapshot( - phase: String, - event: NSEvent? = nil, - didHandle: Bool? = nil - ) { - let keyWindow = NSApp.keyWindow - let firstResponder = keyWindow?.firstResponder - let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - let eventDescription = event.map(NSWindow.keyDescription) ?? "none" - if let browser = tabManager?.focusedBrowserPanel { - var line = - "browser.devtools shortcut=\(phase) panel=\(browser.id.uuidString.prefix(5)) " + - "\(browser.debugDeveloperToolsStateSummary()) \(browser.debugDeveloperToolsGeometrySummary()) " + - "keyWin=\(keyWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)" - if let didHandle { - line += " handled=\(didHandle ? 1 : 0)" + // Check Jump to Unread shortcut + if matchConfiguredShortcut(event: event, action: .jumpToUnread) { +#if DEBUG + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadShortcutHandled": "1"]) } - dlog(line) - return +#endif + jumpToLatestUnread() + return true } - var line = - "browser.devtools shortcut=\(phase) panel=nil keyWin=\(keyWindow?.windowNumber ?? -1) " + - "fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)" - if let didHandle { - line += " handled=\(didHandle ? 1 : 0)" + + // Flash the currently focused panel so the user can visually confirm focus. + if matchConfiguredShortcut(event: event, action: .triggerFlash) { + tabManager?.triggerFocusFlash() + return true } - dlog(line) - } -#endif - private func prepareFocusedBrowserDevToolsForSplit(directionLabel: String) { - guard let browser = tabManager?.focusedBrowserPanel else { return } - guard browser.shouldPreserveWebViewAttachmentDuringTransientHide() else { return } - guard let keyWindow = NSApp.keyWindow else { return } - guard isLikelyWebInspectorResponder(keyWindow.firstResponder) else { return } + // Surface navigation: Cmd+Shift+] / Cmd+Shift+[ + if matchConfiguredShortcut(event: event, action: .nextSurface) { + tabManager?.selectNextSurface() + return true + } + if matchConfiguredShortcut(event: event, action: .prevSurface) { + tabManager?.selectPreviousSurface() + return true + } - let beforeResponder = keyWindow.firstResponder - let movedToWebView = keyWindow.makeFirstResponder(browser.webView) - let movedToNil = movedToWebView ? false : keyWindow.makeFirstResponder(nil) + if matchConfiguredShortcut(event: event, action: .toggleTerminalCopyMode) { + let handled = tabManager?.toggleFocusedTerminalCopyMode() ?? false +#if DEBUG + dlog( + "shortcut.action name=toggleTerminalCopyMode handled=\(handled ? 1 : 0) " + + "\(debugShortcutRouteSnapshot(event: event))" + ) +#endif + // Only consume when a focused terminal actually handled the toggle. + // Otherwise allow the event to continue through the responder chain. + return handled + } - #if DEBUG - let beforeType = beforeResponder.map { String(describing: type(of: $0)) } ?? "nil" - let beforePtr = beforeResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - let afterResponder = keyWindow.firstResponder - let afterType = afterResponder.map { String(describing: type(of: $0)) } ?? "nil" - let afterPtr = afterResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - dlog( - "split.shortcut inspector.preflight dir=\(directionLabel) panel=\(browser.id.uuidString.prefix(5)) " + - "before=\(beforeType)@\(beforePtr) after=\(afterType)@\(afterPtr) " + - "moveWeb=\(movedToWebView ? 1 : 0) moveNil=\(movedToNil ? 1 : 0) \(browser.debugDeveloperToolsStateSummary())" - ) - #endif - } + // Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[ + if matchConfiguredShortcut(event: event, action: .nextSidebarTab) { +#if DEBUG + let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "ws.shortcut dir=next repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" + ) +#endif + tabManager?.selectNextTab() + return true + } - @discardableResult - func performSplitShortcut(direction: SplitDirection, preferredWindow: NSWindow? = nil) -> Bool { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) - _ = synchronizeActiveMainWindowContext(preferredWindow: targetWindow) + if matchConfiguredShortcut(event: event, action: .prevSidebarTab) { +#if DEBUG + let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "ws.shortcut dir=prev repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)" + ) +#endif + tabManager?.selectPreviousTab() + return true + } - let directionLabel: String - switch direction { - case .left: directionLabel = "left" - case .right: directionLabel = "right" - case .up: directionLabel = "up" - case .down: directionLabel = "down" + if matchConfiguredShortcut(event: event, action: .renameWorkspace) { + return requestRenameWorkspaceViaCommandPalette( + preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) } - #if DEBUG - let keyWindow = NSApp.keyWindow - let firstResponder = keyWindow?.firstResponder - let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - let firstResponderWindow: Int = { - if let v = firstResponder as? NSView { - return v.window?.windowNumber ?? -1 - } - if let w = firstResponder as? NSWindow { - return w.windowNumber - } - return -1 - }() - let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)" - if let browser = tabManager?.focusedBrowserPanel { - let webWindow = browser.webView.window?.windowNumber ?? -1 - let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") - } else { - dlog("split.shortcut dir=\(directionLabel) pre panel=nil \(splitContext)") + if matchConfiguredShortcut(event: event, action: .editWorkspaceDescription) { +#if DEBUG + dlog( + "shortcut.editWorkspaceDescription matched target={\(debugWindowToken(commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow))} " + + "\(debugShortcutRouteSnapshot(event: event))" + ) +#endif + return requestEditWorkspaceDescriptionViaCommandPalette( + preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) } - #endif - prepareFocusedBrowserDevToolsForSplit(directionLabel: directionLabel) - let didCreateSplit: Bool = { - if let terminalContext { - return terminalContext.tabManager.createSplit( - tabId: terminalContext.workspaceId, - surfaceId: terminalContext.panelId, - direction: direction - ) != nil + if matchConfiguredShortcut(event: event, action: .closeOtherTabsInPane) { + if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, + targetWindow.identifier?.rawValue == "cmux.settings" { + targetWindow.performClose(nil) + } else { + let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + if let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) { + terminalContext.tabManager.closeOtherTabsInFocusedPaneWithConfirmation() + } else { + tabManager?.closeOtherTabsInFocusedPaneWithConfirmation() + } } - return tabManager?.createSplit(direction: direction) != nil - }() + return true + } + + // Cmd+W must close the focused panel even if first-responder momentarily lags on a + // browser NSTextView during split focus transitions. + if matchConfiguredShortcut(event: event, action: .closeTab) { + let targetWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow + let routedManager = preferredMainWindowContextForShortcutRouting(event: event)?.tabManager ?? tabManager + // Browser popup windows primarily intercept Cmd+W in BrowserPopupPanel. + // This AppDelegate path is a fallback for cases where AppKit routes the + // event through the global shortcut handler first. + if let targetWindow = [targetWindow, NSApp.keyWindow] + .compactMap({ $0 }) + .first(where: { $0.identifier?.rawValue == "programa.browser-popup" }) { #if DEBUG - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in - let keyWindow = NSApp.keyWindow - let firstResponder = keyWindow?.firstResponder - let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - let firstResponderWindow: Int = { - if let v = firstResponder as? NSView { - return v.window?.windowNumber ?? -1 - } - if let w = firstResponder as? NSWindow { - return w.windowNumber - } - return -1 - }() - let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)" - if let browser = self?.tabManager?.focusedBrowserPanel { - let webWindow = browser.webView.window?.windowNumber ?? -1 - let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" - dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") + dlog("shortcut.cmdW route=browserPopup") +#endif + targetWindow.performClose(nil) + return true + } else if let targetWindow, + programaWindowShouldOwnCloseShortcut(targetWindow) { + targetWindow.performClose(nil) } else { - dlog("split.shortcut dir=\(directionLabel) post panel=nil \(splitContext)") + if let routedManager { +#if DEBUG + let selectedWorkspace = routedManager.selectedWorkspace + dlog( + "shortcut.cmdW route=workspaceModel workspace=\(selectedWorkspace?.id.uuidString.prefix(5) ?? "nil") " + + "panel=\(selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil") " + + "selected=\(routedManager.selectedTabId?.uuidString.prefix(5) ?? "nil")" + ) +#endif + routedManager.closeCurrentPanelWithConfirmation() + } else { +#if DEBUG + dlog("shortcut.cmdW route=noManager") +#endif + return false + } } + return true } - recordGotoSplitSplitIfNeeded(direction: direction) -#endif - return didCreateSplit - } - - @discardableResult - func performBrowserSplitShortcut(direction: SplitDirection) -> Bool { - _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) - #if DEBUG - let directionLabel: String - switch direction { - case .left: directionLabel = "left" - case .right: directionLabel = "right" - case .up: directionLabel = "up" - case .down: directionLabel = "down" + if matchConfiguredShortcut(event: event, action: .closeWorkspace) { + tabManager?.closeCurrentWorkspaceWithConfirmation() + return true } - let selectedTabBefore = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" - let focusedPanelBefore = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" - dlog( - "split.browser.shortcut pre dir=\(directionLabel) " + - "tab=\(selectedTabBefore) focusedPanel=\(focusedPanelBefore)" - ) - #endif - guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { - #if DEBUG - dlog("split.browser.shortcut failed dir=\(directionLabel)") - #endif - return false + if matchConfiguredShortcut(event: event, action: .closeWindow) { + guard let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow else { + NSSound.beep() + return true + } + closeWindowWithConfirmation(targetWindow) + return true } - #if DEBUG - let selectedTabAfter = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" - let focusedPanelAfter = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" - dlog( - "split.browser.shortcut post dir=\(directionLabel) " + - "created=\(panelId.uuidString.prefix(5)) tab=\(selectedTabAfter) focusedPanel=\(focusedPanelAfter)" - ) - #endif - - _ = focusBrowserAddressBar(panelId: panelId) - return true - } + if matchConfiguredShortcut(event: event, action: .renameTab) { + // Keep Cmd+R browser reload behavior when a browser panel is focused. + if tabManager?.focusedBrowserPanel != nil { + return false + } + let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteRenameTab(preferredWindow: targetWindow, source: "shortcut.renameTab") + return true + } - /// Allow AppKit-backed browser surfaces (WKWebView) to route non-menu shortcuts - /// through the same app-level shortcut handler used by the local key monitor. - @discardableResult - func handleBrowserSurfaceKeyEquivalent(_ event: NSEvent) -> Bool { - handleCustomShortcut(event: event) - } + // Numeric shortcuts for specific workspaces (9 = last workspace) + // Always consume the event when the digit matches to prevent Ghostty's + // goto_tab fallback from creating a new window when the index is out of bounds. + if let digit = numberedConfiguredShortcutDigit(event: event, action: .selectWorkspaceByNumber) { + if let manager = tabManager, + let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forDigit: digit, workspaceCount: manager.tabs.count) { +#if DEBUG + dlog( + "shortcut.action name=workspaceDigit digit=\(digit) targetIndex=\(targetIndex) manager=\(debugManagerToken(manager)) \(debugShortcutRouteSnapshot(event: event))" + ) +#endif + manager.selectTab(at: targetIndex) + } + return true + } - @discardableResult - func requestRenameWorkspaceViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow - requestCommandPaletteRenameWorkspace( - preferredWindow: targetWindow, - source: "shortcut.renameWorkspace" - ) - return true - } + // Numeric shortcuts for surfaces within the focused pane (9 = last) + if let digit = numberedConfiguredShortcutDigit(event: event, action: .selectSurfaceByNumber) { + if digit == 9 { + tabManager?.selectLastSurface() + } else { + tabManager?.selectSurface(at: digit - 1) + } + return true + } - @discardableResult - func requestEditWorkspaceDescriptionViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { - let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + // Pane focus navigation (defaults to Cmd+Option+Arrow, but can be customized to letter/number keys). + if matchConfiguredDirectionalShortcut( + event: event, + action: .focusLeft, + arrowGlyph: "←", + arrowKeyCode: 123 + ) || (ghosttyGotoSplitLeftShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "←", arrowKeyCode: 123) } ?? false) { + tabManager?.movePaneFocus(direction: .left) #if DEBUG - dlog( - "shortcut.editWorkspaceDescription request target={\(debugWindowToken(targetWindow))} " + - "fr=\(targetWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" - ) + recordGotoSplitMoveIfNeeded(direction: .left) #endif - requestCommandPaletteEditWorkspaceDescription( - preferredWindow: targetWindow, - source: "shortcut.editWorkspaceDescription" - ) - return true - } - + return true + } + if matchConfiguredDirectionalShortcut( + event: event, + action: .focusRight, + arrowGlyph: "→", + arrowKeyCode: 124 + ) || (ghosttyGotoSplitRightShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "→", arrowKeyCode: 124) } ?? false) { + tabManager?.movePaneFocus(direction: .right) #if DEBUG - // Debug/test hook: allow socket-driven shortcut simulation to reuse the same shortcut routing - // logic as the local NSEvent monitor, without relying on AppKit event monitor behavior for - // synthetic NSEvents. - func debugHandleCustomShortcut(event: NSEvent) -> Bool { - handleCustomShortcut(event: event) - } + recordGotoSplitMoveIfNeeded(direction: .right) +#endif + return true + } + if matchConfiguredDirectionalShortcut( + event: event, + action: .focusUp, + arrowGlyph: "↑", + arrowKeyCode: 126 + ) || (ghosttyGotoSplitUpShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↑", arrowKeyCode: 126) } ?? false) { + tabManager?.movePaneFocus(direction: .up) +#if DEBUG + recordGotoSplitMoveIfNeeded(direction: .up) +#endif + return true + } + if matchConfiguredDirectionalShortcut( + event: event, + action: .focusDown, + arrowGlyph: "↓", + arrowKeyCode: 125 + ) || (ghosttyGotoSplitDownShortcut.map { matchDirectionalShortcut(event: event, shortcut: $0, arrowGlyph: "↓", arrowKeyCode: 125) } ?? false) { + tabManager?.movePaneFocus(direction: .down) +#if DEBUG + recordGotoSplitMoveIfNeeded(direction: .down) +#endif + return true + } - // Debug/test hook: mirrors local monitor routing (keyDown + keyUp lifecycle). - func debugHandleShortcutMonitorEvent(event: NSEvent) -> Bool { - if event.type == .keyDown { - return handleCustomShortcut(event: event) + if matchConfiguredShortcut(event: event, action: .toggleSplitZoom) { + _ = tabManager?.toggleFocusedSplitZoom() +#if DEBUG + recordGotoSplitZoomIfNeeded() +#endif + return true } - handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) - return clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true) - } - func debugMarkCommandPaletteOpenPending(window: NSWindow) { - markCommandPaletteOpenRequested(for: window) - } + // Split actions: Cmd+D / Cmd+Shift+D + if matchConfiguredShortcut(event: event, action: .splitRight) { +#if DEBUG + dlog("shortcut.action name=splitRight \(debugShortcutRouteSnapshot(event: event))") +#endif + if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) { + return true + } + _ = performSplitShortcut( + direction: .right, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) + return true + } - @discardableResult - func debugSetCommandPalettePendingOpenAge(window: NSWindow, age: TimeInterval) -> Bool { - guard let windowId = mainWindowId(for: window) else { return false } - commandPalettePendingOpenByWindowId[windowId] = true - commandPaletteRecentRequestAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime - max(age, 0) - return true - } + if matchConfiguredShortcut(event: event, action: .splitDown) { +#if DEBUG + dlog("shortcut.action name=splitDown \(debugShortcutRouteSnapshot(event: event))") +#endif + if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) { + return true + } + _ = performSplitShortcut( + direction: .down, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) + return true + } - // Test hook: remap a window context under a detached window key so direct - // ObjectIdentifier(window) lookups fail and fallback logic is exercised. - @discardableResult - func debugInjectWindowContextKeyMismatch(windowId: UUID) -> Bool { - guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }), - let window = context.window ?? windowForMainWindowId(windowId) else { - return false + if matchConfiguredShortcut(event: event, action: .splitBrowserRight) { +#if DEBUG + dlog("shortcut.action name=splitBrowserRight \(debugShortcutRouteSnapshot(event: event))") +#endif + _ = performBrowserSplitShortcut(direction: .right) + return true } - let detachedWindow = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 16, height: 16), - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - debugDetachedContextWindows.append(detachedWindow) + if matchConfiguredShortcut(event: event, action: .splitBrowserDown) { +#if DEBUG + dlog("shortcut.action name=splitBrowserDown \(debugShortcutRouteSnapshot(event: event))") +#endif + _ = performBrowserSplitShortcut(direction: .down) + return true + } - let contextKeys = mainWindowContexts.compactMap { key, value in - value === context ? key : nil + // Surface navigation (legacy Ctrl+Tab support) + if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: false, option: false, control: true)) { + tabManager?.selectNextSurface() + return true } - for key in contextKeys { - mainWindowContexts.removeValue(forKey: key) + if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: true, option: false, control: true)) { + tabManager?.selectPreviousSurface() + return true } - mainWindowContexts[ObjectIdentifier(detachedWindow)] = context - context.window = window - return true - } -#endif - private func findButton(in view: NSView, titled title: String) -> NSButton? { - if let button = view as? NSButton, button.title == title { - return button - } - for subview in view.subviews { - if let found = findButton(in: subview, titled: title) { - return found - } + // New surface: Cmd+T + if matchConfiguredShortcut(event: event, action: .newSurface) { + tabManager?.newSurface() + return true } - return nil - } - private func findStaticText(in view: NSView, equals text: String) -> Bool { - if let field = view as? NSTextField, field.stringValue == text { + // Open browser: Cmd+Shift+L + if matchConfiguredShortcut(event: event, action: .openBrowser) { + _ = openBrowserAndFocusAddressBar(insertAtEnd: true) return true } - for subview in view.subviews { - if findStaticText(in: subview, equals: text) { + + if matchConfiguredShortcut(event: event, action: .focusBrowserAddressBar) { + if let focusedPanel = tabManager?.focusedBrowserPanel { + focusBrowserAddressBar(in: focusedPanel) return true } - } - return false - } - private func matchConfiguredShortcut(event: NSEvent, action: KeyboardShortcutSettings.Action) -> Bool { - let shortcut = KeyboardShortcutSettings.shortcut(for: action) - if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { - guard let secondStroke = shortcut.secondStroke, - shortcut.firstStroke == prefix else { - return false + if let browserAddressBarFocusedPanelId, + focusBrowserAddressBar(panelId: browserAddressBarFocusedPanelId) { + return true } - return matchShortcutStroke(event: event, stroke: secondStroke) - } - guard !shortcut.hasChord else { return false } - return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) - } - private func numberedConfiguredShortcutDigit( - event: NSEvent, - action: KeyboardShortcutSettings.Action - ) -> Int? { - let shortcut = KeyboardShortcutSettings.shortcut(for: action) - if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { - guard let secondStroke = shortcut.secondStroke, - shortcut.firstStroke == prefix else { - return nil + if openBrowserAndFocusAddressBar(insertAtEnd: true) != nil { + return true } - return numberedShortcutDigit(event: event, stroke: secondStroke) } - guard !shortcut.hasChord else { return nil } - return numberedShortcutDigit(event: event, stroke: shortcut.firstStroke) - } - private func matchConfiguredDirectionalShortcut( - event: NSEvent, - action: KeyboardShortcutSettings.Action, - arrowGlyph: String, - arrowKeyCode: UInt16 - ) -> Bool { - let shortcut = KeyboardShortcutSettings.shortcut(for: action) - if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { - guard let secondStroke = shortcut.secondStroke, - shortcut.firstStroke == prefix else { + if matchConfiguredShortcut(event: event, action: .browserBack) { + guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { return false } - return matchDirectionalShortcut( - event: event, - stroke: secondStroke, - arrowGlyph: arrowGlyph, - arrowKeyCode: arrowKeyCode - ) - } - guard !shortcut.hasChord else { return false } - return matchDirectionalShortcut( - event: event, - stroke: shortcut.firstStroke, - arrowGlyph: arrowGlyph, - arrowKeyCode: arrowKeyCode - ) - } - - private func configuredShortcutChordWindowNumber(for event: NSEvent) -> Int? { - if let window = mainWindowForShortcutEvent(event) { - return window.windowNumber - } - if let window = event.window { - return window.windowNumber + focusedBrowserPanel.goBack() + return true } - return event.windowNumber > 0 ? event.windowNumber : nil - } - private func armConfiguredShortcutChordIfNeeded( - event: NSEvent, - actions: [KeyboardShortcutSettings.Action]? = nil - ) -> Bool { - for action in actions ?? configuredShortcutChordActions { - let shortcut = KeyboardShortcutSettings.shortcut(for: action) - guard shortcut.hasChord else { continue } - if matchShortcutStroke(event: event, stroke: shortcut.firstStroke) { - pendingConfiguredShortcutChord = PendingConfiguredShortcutChord( - firstStroke: shortcut.firstStroke, - windowNumber: configuredShortcutChordWindowNumber(for: event) - ) - return true + if matchConfiguredShortcut(event: event, action: .browserForward) { + guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { + return false } + focusedBrowserPanel.goForward() + return true } - return false - } - - /// Match a shortcut stroke against an event, handling normal keys. - private func matchShortcutStroke(event: NSEvent, stroke: ShortcutStroke) -> Bool { - // Some keys can include extra flags (e.g. .function) depending on the responder chain. - // Strip those for consistent matching across first responders (terminal, WebKit, etc). - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - guard flags == stroke.modifierFlags else { return false } - let shortcutKey = stroke.key.lowercased() - if shortcutKey == "\r" { - return event.keyCode == 36 || event.keyCode == 76 + if matchConfiguredShortcut(event: event, action: .browserReload) { + guard let focusedBrowserPanel = tabManager?.focusedBrowserPanel else { + return false + } + focusedBrowserPanel.reload() + return true } - let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers - if shortcutCharacterMatches( - eventCharacter: eventCharsIgnoringModifiers, - shortcutKey: shortcutKey, - applyShiftSymbolNormalization: flags.contains(.shift), - eventKeyCode: event.keyCode - ) { + // Safari defaults: + // - Option+Command+I => Show/Toggle Web Inspector + // - Option+Command+C => Show JavaScript Console + if matchConfiguredShortcut(event: event, action: .toggleBrowserDeveloperTools) { +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "toggle.pre", event: event) +#endif + let didHandle = tabManager?.toggleDeveloperToolsFocusedBrowser() ?? false +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "toggle.post", event: event, didHandle: didHandle) + DispatchQueue.main.async { [weak self] in + self?.logDeveloperToolsShortcutSnapshot(phase: "toggle.tick", didHandle: didHandle) + } +#endif + if !didHandle { NSSound.beep() } return true } - // For command-based shortcuts, trust AppKit's layout-aware characters when present. - // Keep this strict for letter shortcuts to avoid physical-key collisions across layouts, - // while still allowing keyCode fallback for digit/punctuation shortcuts on non-US layouts. - // When a non-Latin input source is active (Russian, Korean, Chinese, Japanese, etc.), - // charactersIgnoringModifiers returns non-ASCII characters that can never match - // a Latin shortcut key — skip this guard and fall through to layout-based matching. - let hasEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) - let eventCharsAreASCII = eventCharsIgnoringModifiers?.allSatisfy(\.isASCII) ?? true - let shortcutKeyIsDigit = shortcutKey.count == 1 && shortcutKey.first?.isNumber == true - if shortcutKeyIsDigit, - hasEventChars, - eventCharsAreASCII, - digitForNumberKeyCode(event.keyCode) == nil { - return false - } - if hasEventChars, - eventCharsAreASCII, - flags.contains(.command), - !flags.contains(.control), - shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) { - return false + if matchConfiguredShortcut(event: event, action: .showBrowserJavaScriptConsole) { +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "console.pre", event: event) +#endif + let didHandle = tabManager?.showJavaScriptConsoleFocusedBrowser() ?? false +#if DEBUG + logDeveloperToolsShortcutSnapshot(phase: "console.post", event: event, didHandle: didHandle) + DispatchQueue.main.async { [weak self] in + self?.logDeveloperToolsShortcutSnapshot(phase: "console.tick", didHandle: didHandle) + } +#endif + if !didHandle { NSSound.beep() } + return true } - // Match using the current keyboard layout so Command shortcuts stay character-based - // across layouts (QWERTY, Dvorak, etc.) instead of being tied to ANSI physical keys. - let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) - if shortcutCharacterMatches( - eventCharacter: layoutCharacter, - shortcutKey: shortcutKey, - applyShiftSymbolNormalization: false, - eventKeyCode: event.keyCode - ) { + if matchConfiguredShortcut(event: event, action: .toggleReactGrab) { + let didHandle = tabManager?.toggleReactGrabFromCurrentFocus() ?? false + if !didHandle { NSSound.beep() } return true } - // Control-key combos can surface as ASCII control characters (e.g. Ctrl+H => backspace), - // so keep ANSI keyCode fallback for control-modified shortcuts. Also allow fallback for - // command punctuation shortcuts, since some non-US layouts report different characters - // for the same physical key even when menu-equivalent semantics should still apply. - // When a non-Latin input source is active (Russian, Korean, Chinese, Japanese, etc.), - // event chars carry no usable Latin key identity. Always allow keyCode fallback as a - // safety net — even when the layout-based translation resolved a character, the - // physical key code is the definitive identifier for the intended shortcut. - // For empty-character events (synthetic/browser key equivalents), preserve the original - // behavior: only fall back when the layout translation also failed. - let hasUsableEventChars = hasEventChars && eventCharsAreASCII - let allowANSIKeyCodeFallback = flags.contains(.control) - || (flags.contains(.command) - && !flags.contains(.control) - && ( - !shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) - || (hasEventChars && !eventCharsAreASCII) - || (!hasEventChars && (layoutCharacter?.isEmpty ?? true)) - )) - if allowANSIKeyCodeFallback, let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) { - return event.keyCode == expectedKeyCode + if matchConfiguredShortcut(event: event, action: .browserZoomIn) { + return tabManager?.zoomInFocusedBrowser() ?? false } - return false - } - private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { - guard !shortcut.hasChord else { return false } - return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) - } + if matchConfiguredShortcut(event: event, action: .browserZoomOut) { + return tabManager?.zoomOutFocusedBrowser() ?? false + } - private func numberedShortcutDigit(event: NSEvent, stroke: ShortcutStroke) -> Int? { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - guard flags == stroke.modifierFlags else { return nil } - let numberKeyDigit = digitForNumberKeyCode(event.keyCode) + if matchConfiguredShortcut(event: event, action: .browserZoomReset) { + return tabManager?.resetZoomFocusedBrowser() ?? false + } - if let digit = numberedShortcutDigit( - eventCharacter: event.charactersIgnoringModifiers, - applyShiftSymbolNormalization: flags.contains(.shift), - eventKeyCode: event.keyCode - ) { - return digit + if matchConfiguredShortcut(event: event, action: .find) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } + tabManager?.startSearch() + return true } - let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers - let hasUsableASCIIEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) - && (eventCharsIgnoringModifiers?.allSatisfy(\.isASCII) ?? true) - if !hasUsableASCIIEventChars || numberKeyDigit != nil { - let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) - if let digit = numberedShortcutDigit( - eventCharacter: layoutCharacter, - applyShiftSymbolNormalization: false, - eventKeyCode: event.keyCode - ) { - return digit + if matchConfiguredShortcut(event: event, action: .findNext) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false } + tabManager?.findNext() + return true } - return numberKeyDigit - } - - private func numberedShortcutDigit(event: NSEvent, shortcut: StoredShortcut) -> Int? { - guard !shortcut.hasChord else { return nil } - return numberedShortcutDigit(event: event, stroke: shortcut.firstStroke) - } + if matchConfiguredShortcut(event: event, action: .findPrevious) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } + tabManager?.findPrevious() + return true + } - private func numberedShortcutDigit( - eventCharacter: String?, - applyShiftSymbolNormalization: Bool, - eventKeyCode: UInt16 - ) -> Int? { - guard let eventCharacter, !eventCharacter.isEmpty else { return nil } - let normalized = normalizedShortcutEventCharacter( - eventCharacter, - applyShiftSymbolNormalization: applyShiftSymbolNormalization, - eventKeyCode: eventKeyCode - ) - guard let digit = Int(normalized), (1...9).contains(digit) else { return nil } - return digit - } + if matchConfiguredShortcut(event: event, action: .hideFind) { + guard !shouldLetFocusedBrowserOwnFindShortcut(event) else { + return false + } + tabManager?.hideFind() + return true + } - private func shouldRequireCharacterMatchForCommandShortcut(shortcutKey: String) -> Bool { - guard shortcutKey.count == 1, let scalar = shortcutKey.unicodeScalars.first else { - return false + if matchConfiguredShortcut(event: event, action: .useSelectionForFind) { + tabManager?.searchSelection() + return true } - return CharacterSet.letters.contains(scalar) - } - private func shortcutCharacterMatches( - eventCharacter: String?, - shortcutKey: String, - applyShiftSymbolNormalization: Bool, - eventKeyCode: UInt16 - ) -> Bool { - guard let eventCharacter, !eventCharacter.isEmpty else { return false } - if normalizedShortcutEventCharacter( - eventCharacter, - applyShiftSymbolNormalization: applyShiftSymbolNormalization, - eventKeyCode: eventKeyCode - ) == shortcutKey { + if matchConfiguredShortcut(event: event, action: .reopenClosedBrowserPanel) { + _ = tabManager?.reopenMostRecentlyClosedBrowserPanel() return true } + return false } - private func normalizedShortcutEventCharacter( - _ eventCharacter: String, - applyShiftSymbolNormalization: Bool, - eventKeyCode: UInt16 - ) -> String { - let lowered = eventCharacter.lowercased() - guard applyShiftSymbolNormalization else { return lowered } - - switch lowered { - case "{": return "[" - case "}": return "]" - case "<": return eventKeyCode == 43 ? "," : lowered // kVK_ANSI_Comma - case ">": return eventKeyCode == 47 ? "." : lowered // kVK_ANSI_Period - case "?": return "/" - case ":": return ";" - case "\"": return "'" - case "|": return "\\" - case "~": return "`" - case "+": return "=" - case "_": return "-" - case "!": return eventKeyCode == 18 ? "1" : lowered // kVK_ANSI_1 - case "@": return eventKeyCode == 19 ? "2" : lowered // kVK_ANSI_2 - case "#": return eventKeyCode == 20 ? "3" : lowered // kVK_ANSI_3 - case "$": return eventKeyCode == 21 ? "4" : lowered // kVK_ANSI_4 - case "%": return eventKeyCode == 23 ? "5" : lowered // kVK_ANSI_5 - case "^": return eventKeyCode == 22 ? "6" : lowered // kVK_ANSI_6 - case "&": return eventKeyCode == 26 ? "7" : lowered // kVK_ANSI_7 - case "*": return eventKeyCode == 28 ? "8" : lowered // kVK_ANSI_8 - case "(": return eventKeyCode == 25 ? "9" : lowered // kVK_ANSI_9 - case ")": return eventKeyCode == 29 ? "0" : lowered // kVK_ANSI_0 - default: return lowered + private func shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: SplitDirection) -> Bool { + guard let tabManager, + let workspace = tabManager.selectedWorkspace, + let focusedPanelId = workspace.focusedPanelId, + let terminalPanel = workspace.terminalPanel(for: focusedPanelId) else { + return false } - } - private func keyCodeForShortcutKey(_ key: String) -> UInt16? { - // Matches macOS ANSI key codes. This is intentionally limited to keys we - // support in StoredShortcut/ghostty trigger translation. - switch key { - case "a": return 0 // kVK_ANSI_A - case "s": return 1 // kVK_ANSI_S - case "d": return 2 // kVK_ANSI_D - case "f": return 3 // kVK_ANSI_F - case "h": return 4 // kVK_ANSI_H - case "g": return 5 // kVK_ANSI_G - case "z": return 6 // kVK_ANSI_Z - case "x": return 7 // kVK_ANSI_X - case "c": return 8 // kVK_ANSI_C - case "v": return 9 // kVK_ANSI_V - case "b": return 11 // kVK_ANSI_B - case "q": return 12 // kVK_ANSI_Q - case "w": return 13 // kVK_ANSI_W - case "e": return 14 // kVK_ANSI_E - case "r": return 15 // kVK_ANSI_R - case "y": return 16 // kVK_ANSI_Y - case "t": return 17 // kVK_ANSI_T - case "1": return 18 // kVK_ANSI_1 - case "2": return 19 // kVK_ANSI_2 - case "3": return 20 // kVK_ANSI_3 - case "4": return 21 // kVK_ANSI_4 - case "6": return 22 // kVK_ANSI_6 - case "5": return 23 // kVK_ANSI_5 - case "=": return 24 // kVK_ANSI_Equal - case "9": return 25 // kVK_ANSI_9 - case "7": return 26 // kVK_ANSI_7 - case "-": return 27 // kVK_ANSI_Minus - case "8": return 28 // kVK_ANSI_8 - case "0": return 29 // kVK_ANSI_0 - case "]": return 30 // kVK_ANSI_RightBracket - case "o": return 31 // kVK_ANSI_O - case "u": return 32 // kVK_ANSI_U - case "[": return 33 // kVK_ANSI_LeftBracket - case "i": return 34 // kVK_ANSI_I - case "p": return 35 // kVK_ANSI_P - case "l": return 37 // kVK_ANSI_L - case "j": return 38 // kVK_ANSI_J - case "'": return 39 // kVK_ANSI_Quote - case "k": return 40 // kVK_ANSI_K - case ";": return 41 // kVK_ANSI_Semicolon - case "\\": return 42 // kVK_ANSI_Backslash - case ",": return 43 // kVK_ANSI_Comma - case "/": return 44 // kVK_ANSI_Slash - case "n": return 45 // kVK_ANSI_N - case "m": return 46 // kVK_ANSI_M - case ".": return 47 // kVK_ANSI_Period - case "`": return 50 // kVK_ANSI_Grave - case "\r": return 36 // kVK_Return - case "←": return 123 // kVK_LeftArrow - case "→": return 124 // kVK_RightArrow - case "↓": return 125 // kVK_DownArrow - case "↑": return 126 // kVK_UpArrow - default: - return nil - } - } + let hostedView = terminalPanel.hostedView + let hostedSize = hostedView.bounds.size + let hostedHiddenInHierarchy = hostedView.isHiddenOrHasHiddenAncestor + let hostedAttachedToWindow = hostedView.window != nil + let firstResponderIsWindow = NSApp.keyWindow?.firstResponder is NSWindow - private func digitForNumberKeyCode(_ keyCode: UInt16) -> Int? { - switch keyCode { - case 18: return 1 // kVK_ANSI_1 - case 19: return 2 // kVK_ANSI_2 - case 20: return 3 // kVK_ANSI_3 - case 21: return 4 // kVK_ANSI_4 - case 23: return 5 // kVK_ANSI_5 - case 22: return 6 // kVK_ANSI_6 - case 26: return 7 // kVK_ANSI_7 - case 28: return 8 // kVK_ANSI_8 - case 25: return 9 // kVK_ANSI_9 - default: - return nil + let shouldSuppress = shouldSuppressSplitShortcutForTransientTerminalFocusInputs( + firstResponderIsWindow: firstResponderIsWindow, + hostedSize: hostedSize, + hostedHiddenInHierarchy: hostedHiddenInHierarchy, + hostedAttachedToWindow: hostedAttachedToWindow + ) + guard shouldSuppress else { return false } + + tabManager.reconcileFocusedPanelFromFirstResponderForKeyboard() + +#if DEBUG + let directionLabel: String + switch direction { + case .left: directionLabel = "left" + case .right: directionLabel = "right" + case .up: directionLabel = "up" + case .down: directionLabel = "down" } + let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + dlog( + "split.shortcut suppressed dir=\(directionLabel) reason=transient_focus_state " + + "fr=\(firstResponderType) hidden=\(hostedHiddenInHierarchy ? 1 : 0) " + + "attached=\(hostedAttachedToWindow ? 1 : 0) " + + "frame=\(String(format: "%.1fx%.1f", hostedSize.width, hostedSize.height))" + ) +#endif + return true } - /// Match arrow key shortcuts using keyCode - /// Arrow keys include .numericPad and .function in their modifierFlags, so strip those before comparing. - private func matchArrowShortcut(event: NSEvent, stroke: ShortcutStroke, keyCode: UInt16) -> Bool { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - return event.keyCode == keyCode && flags == stroke.modifierFlags - } +#if DEBUG + private func logBrowserZoomShortcutTrace( + stage: String, + event: NSEvent, + flags: NSEvent.ModifierFlags, + chars: String, + action: BrowserZoomShortcutAction? = nil, + handled: Bool? = nil + ) { + guard browserZoomShortcutTraceCandidate( + flags: flags, + chars: chars, + keyCode: event.keyCode, + literalChars: event.characters + ) else { + return + } - /// Match tab key shortcuts using keyCode 48 - private func matchTabShortcut(event: NSEvent, stroke: ShortcutStroke) -> Bool { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - return event.keyCode == 48 && flags == stroke.modifierFlags + let keyWindow = NSApp.keyWindow + let firstResponderType = keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let panel = tabManager?.focusedBrowserPanel + let panelToken = panel.map { String($0.id.uuidString.prefix(8)) } ?? "nil" + let panelZoom = panel?.webView.pageZoom ?? -1 + var line = + "zoom.shortcut stage=\(stage) event=\(NSWindow.keyDescription(event)) " + + "chars='\(chars)' flags=\(browserZoomShortcutTraceFlagsString(flags)) " + + "action=\(browserZoomShortcutTraceActionString(action)) keyWin=\(keyWindow?.windowNumber ?? -1) " + + "fr=\(firstResponderType) panel=\(panelToken) zoom=\(String(format: "%.3f", panelZoom)) " + + "addrBarId=\(browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")" + if let handled { + line += " handled=\(handled ? 1 : 0)" + } + dlog(line) } - private func matchTabShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { - guard !shortcut.hasChord else { return false } - return matchTabShortcut(event: event, stroke: shortcut.firstStroke) + private func browserFocusStateSnapshot() -> String { + let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let focused = tabManager?.selectedWorkspace?.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let addressBar = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let keyWindow = NSApp.keyWindow?.windowNumber ?? -1 + let firstResponderType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + return "selected=\(selected) focused=\(focused) addr=\(addressBar) keyWin=\(keyWindow) fr=\(firstResponderType)" } - /// Directional shortcuts default to arrow keys, but the shortcut recorder only supports letter/number keys. - /// Support both so users can customize pane navigation (e.g. Cmd+Ctrl+H/J/K/L). - private func matchDirectionalShortcut( - event: NSEvent, - stroke: ShortcutStroke, - arrowGlyph: String, - arrowKeyCode: UInt16 - ) -> Bool { - if stroke.key == arrowGlyph { - return matchArrowShortcut(event: event, stroke: stroke, keyCode: arrowKeyCode) + private func redactedDebugURL(_ url: URL?) -> String { + guard let url else { return "nil" } + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return "" } - return matchShortcutStroke(event: event, stroke: stroke) + components.user = nil + components.password = nil + components.query = nil + components.fragment = nil + return components.string ?? "" } +#endif - private func matchDirectionalShortcut( - event: NSEvent, - shortcut: StoredShortcut, - arrowGlyph: String, - arrowKeyCode: UInt16 - ) -> Bool { - guard !shortcut.hasChord else { return false } - return matchDirectionalShortcut( - event: event, - stroke: shortcut.firstStroke, - arrowGlyph: arrowGlyph, - arrowKeyCode: arrowKeyCode + @discardableResult + private func focusBrowserAddressBar(panelId: UUID) -> Bool { + guard let tabManager, + let workspace = tabManager.selectedWorkspace, + let panel = workspace.browserPanel(for: panelId) else { +#if DEBUG + dlog( + "browser.focus.addressBar.route panel=\(panelId.uuidString.prefix(5)) " + + "result=miss \(browserFocusStateSnapshot())" + ) +#endif + return false + } +#if DEBUG + dlog( + "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + + "workspace=\(workspace.id.uuidString.prefix(5)) result=hit \(browserFocusStateSnapshot())" ) +#endif + workspace.focusPanel(panel.id) +#if DEBUG + let focusedAfter = workspace.focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.addressBar.route panel=\(panel.id.uuidString.prefix(5)) " + + "workspace=\(workspace.id.uuidString.prefix(5)) focusedAfter=\(focusedAfter)" + ) +#endif + focusBrowserAddressBar(in: panel) + return true } - func validateMenuItem(_ item: NSMenuItem) -> Bool { - updateController.validateMenuItem(item) - } - - - private func configureUserNotifications() { - let actions = [ - UNNotificationAction( - identifier: TerminalNotificationStore.actionShowIdentifier, - title: "Show" + @discardableResult + func openBrowserAndFocusAddressBar(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? { + let preferredProfileID = + tabManager?.focusedBrowserPanel?.profileID + ?? tabManager?.selectedWorkspace?.preferredBrowserProfileID + guard let panelId = tabManager?.openBrowser( + url: url, + preferredProfileID: preferredProfileID, + insertAtEnd: insertAtEnd + ) else { +#if DEBUG + dlog( + "browser.focus.openAndFocus result=open_failed insertAtEnd=\(insertAtEnd ? 1 : 0) " + + "url=\(redactedDebugURL(url)) \(browserFocusStateSnapshot())" ) - ] - - let category = UNNotificationCategory( - identifier: TerminalNotificationStore.categoryIdentifier, - actions: actions, - intentIdentifiers: [], - options: [.customDismissAction] +#endif + return nil + } +#if DEBUG + dlog( + "browser.focus.openAndFocus result=open_ok panel=\(panelId.uuidString.prefix(5)) " + + "insertAtEnd=\(insertAtEnd ? 1 : 0) url=\(redactedDebugURL(url))" + ) +#endif +#if DEBUG + let didFocus = focusBrowserAddressBar(panelId: panelId) + dlog( + "browser.focus.openAndFocus result=focus_request panel=\(panelId.uuidString.prefix(5)) " + + "focused=\(didFocus ? 1 : 0) \(browserFocusStateSnapshot())" ) +#else + _ = focusBrowserAddressBar(panelId: panelId) +#endif + return panelId + } - let center = UNUserNotificationCenter.current() - center.setNotificationCategories([category]) - center.delegate = self + private func focusBrowserAddressBar(in panel: BrowserPanel) { +#if DEBUG + let requestId = panel.requestAddressBarFocus() + dlog( + "browser.focus.addressBar.request panel=\(panel.id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())" + ) +#else + _ = panel.requestAddressBarFocus() +#endif + browserAddressBarFocusedPanelId = panel.id +#if DEBUG + dlog( + "browser.focus.addressBar.sticky panel=\(panel.id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8)) \(browserFocusStateSnapshot())" + ) +#endif + NotificationCenter.default.post(name: .browserFocusAddressBar, object: panel.id) +#if DEBUG + dlog( + "browser.focus.addressBar.notify panel=\(panel.id.uuidString.prefix(5)) " + + "request=\(requestId.uuidString.prefix(8))" + ) +#endif } - private func disableNativeTabbingShortcut() { - guard let menu = NSApp.mainMenu else { return } - disableMenuItemShortcut(in: menu, action: #selector(NSWindow.toggleTabBar(_:))) + func focusedBrowserAddressBarPanelId() -> UUID? { + browserAddressBarFocusedPanelId } - private func disableMenuItemShortcut(in menu: NSMenu, action: Selector) { - for item in menu.items { - if item.action == action { - item.keyEquivalent = "" - item.keyEquivalentModifierMask = [] - item.isEnabled = false - } - if let submenu = item.submenu { - disableMenuItemShortcut(in: submenu, action: action) - } + private func focusedBrowserAddressBarPanelIdForShortcutEvent(_ event: NSEvent) -> UUID? { + guard let panelId = browserAddressBarFocusedPanelId else { return nil } + + guard let context = preferredMainWindowContextForShortcutRouting(event: event) else { +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=0 reason=no_context event=\(NSWindow.keyDescription(event))" + ) +#endif + return nil } - } - private func ensureApplicationIcon() { - // Manual light/dark override removed; the icon always tracks system - // appearance. Clear any stale pre-removal override so the dock-tile - // plugin (which reads the raw key cross-process) falls back too. - UserDefaults.standard.removeObject(forKey: "appIconMode") - AppIconAppearanceObserver.shared.startObserving() - } + guard let workspace = context.tabManager.selectedWorkspace else { +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=0 reason=no_workspace event=\(NSWindow.keyDescription(event))" + ) +#endif + return nil + } - private func scheduleLaunchServicesBundleRegistration( - bundleURL: URL = Bundle.main.bundleURL.standardizedFileURL, - scheduler: @escaping (@escaping @Sendable () -> Void) -> Void = AppDelegate.enqueueLaunchServicesRegistrationWork, - register: @escaping (CFURL) -> OSStatus = { url in - LSRegisterURL(url, true) - }, - breadcrumb: @escaping (_ message: String, _ data: [String: Any]) -> Void = { _, _ in } - ) { - let normalizedURL = bundleURL.standardizedFileURL - breadcrumb("launchservices.register.schedule", [ - "bundlePath": normalizedURL.path - ]) + guard workspace.browserPanel(for: panelId) != nil else { +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=0 reason=panel_not_in_workspace workspace=\(workspace.id.uuidString.prefix(5)) " + + "event=\(NSWindow.keyDescription(event))" + ) +#endif + return nil + } - scheduler { - let startedAt = CFAbsoluteTimeGetCurrent() - let registerStatus = register(normalizedURL as CFURL) - let durationMs = Int(((CFAbsoluteTimeGetCurrent() - startedAt) * 1000).rounded()) +#if DEBUG + dlog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=1 workspace=\(workspace.id.uuidString.prefix(5)) event=\(NSWindow.keyDescription(event))" + ) +#endif + return panelId + } - breadcrumb("launchservices.register.complete", [ - "bundlePath": normalizedURL.path, - "status": Int(registerStatus), - "durationMs": durationMs - ]) + @discardableResult + func requestBrowserAddressBarFocus(panelId: UUID) -> Bool { + focusBrowserAddressBar(panelId: panelId) + } - if registerStatus != noErr { - NSLog("LaunchServices registration failed (status: \(registerStatus)) for \(normalizedURL.path)") - } + private func shouldBypassAppShortcutForFocusedBrowserAddressBar( + flags: NSEvent.ModifierFlags, + chars: String + ) -> Bool { + guard browserAddressBarFocusedPanelId != nil else { return false } + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] + guard isCommandOrControlOnly else { return false } + let shouldBypass = chars == "n" || chars == "p" +#if DEBUG + if shouldBypass { + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.addressBar.shortcutBypass panel=\(panelToken) " + + "chars=\(chars) flags=\(normalizedFlags.rawValue)" + ) } +#endif + return shouldBypass } -#if DEBUG - func scheduleLaunchServicesBundleRegistrationForTesting( - bundleURL: URL, - scheduler: @escaping (@escaping @Sendable () -> Void) -> Void, - register: @escaping (CFURL) -> OSStatus, - breadcrumb: @escaping (_ message: String, _ data: [String: Any]) -> Void = { _, _ in } - ) { - scheduleLaunchServicesBundleRegistration( - bundleURL: bundleURL, - scheduler: scheduler, - register: register, - breadcrumb: breadcrumb + private func commandOmnibarSelectionDelta( + flags: NSEvent.ModifierFlags, + chars: String + ) -> Int? { + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: browserAddressBarFocusedPanelId != nil, + flags: flags, + chars: chars ) } -#endif - - private func enforceSingleInstance() { - guard let bundleId = Bundle.main.bundleIdentifier else { return } - let currentPid = ProcessInfo.processInfo.processIdentifier - for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) { - guard app.processIdentifier != currentPid else { continue } - app.terminate() - if !app.isTerminated { - _ = app.forceTerminate() - } - } + private func dispatchBrowserOmnibarSelectionMove(delta: Int) { + guard delta != 0 else { return } + guard let panelId = browserAddressBarFocusedPanelId else { return } +#if DEBUG + dlog( + "browser.focus.omnibar.selectionMove panel=\(panelId.uuidString.prefix(5)) " + + "delta=\(delta) repeatKey=\(browserOmnibarRepeatKeyCode.map(String.init) ?? "nil")" + ) +#endif + NotificationCenter.default.post( + name: .browserMoveOmnibarSelection, + object: panelId, + userInfo: ["delta": delta] + ) } - private func observeDuplicateLaunches() { - guard let bundleId = Bundle.main.bundleIdentifier else { return } - let embeddedCLIURL = Bundle.main.bundleURL - .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) - .standardizedFileURL - .resolvingSymlinksInPath() - let currentPid = ProcessInfo.processInfo.processIdentifier - - workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( - forName: NSWorkspace.didLaunchApplicationNotification, - object: nil, - queue: .main - ) { [weak self] notification in - guard self != nil else { return } - guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return } - guard app.bundleIdentifier == bundleId, app.processIdentifier != currentPid else { return } - if let executableURL = app.executableURL? - .standardizedFileURL - .resolvingSymlinksInPath(), - executableURL == embeddedCLIURL { - return - } + private func startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: UInt16, delta: Int) { + guard delta != 0 else { return } + guard browserAddressBarFocusedPanelId != nil else { +#if DEBUG + dlog( + "browser.focus.omnibar.repeat.start key=\(keyCode) delta=\(delta) " + + "result=skip_no_focused_address_bar" + ) +#endif + return + } - app.terminate() - if !app.isTerminated { - _ = app.forceTerminate() - } - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + if browserOmnibarRepeatKeyCode == keyCode, browserOmnibarRepeatDelta == delta { +#if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.omnibar.repeat.start panel=\(panelToken) " + + "key=\(keyCode) delta=\(delta) result=reuse" + ) +#endif + return } - } - func userNotificationCenter( - _ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void - ) { - handleNotificationResponse(response) - completionHandler() - } + stopBrowserOmnibarSelectionRepeat() + browserOmnibarRepeatKeyCode = keyCode + browserOmnibarRepeatDelta = delta +#if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.omnibar.repeat.start panel=\(panelToken) " + + "key=\(keyCode) delta=\(delta) result=armed" + ) +#endif - func userNotificationCenter( - _ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void - ) { - var options: UNNotificationPresentationOptions = [.banner, .list] - if notification.request.content.sound != nil { - options.insert(.sound) + let start = DispatchWorkItem { [weak self] in + self?.scheduleBrowserOmnibarSelectionRepeatTick() } - completionHandler(options) + browserOmnibarRepeatStartWorkItem = start + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: start) } - private func handleNotificationResponse(_ response: UNNotificationResponse) { - guard let tabIdString = response.notification.request.content.userInfo["tabId"] as? String, - let tabId = UUID(uuidString: tabIdString) else { + private func scheduleBrowserOmnibarSelectionRepeatTick() { + browserOmnibarRepeatStartWorkItem = nil + guard browserAddressBarFocusedPanelId != nil else { +#if DEBUG + dlog("browser.focus.omnibar.repeat.tick result=stop_no_focused_address_bar") +#endif + stopBrowserOmnibarSelectionRepeat() return } - let surfaceId: UUID? = { - guard let surfaceIdString = response.notification.request.content.userInfo["surfaceId"] as? String else { - return nil - } - return UUID(uuidString: surfaceIdString) - }() + guard browserOmnibarRepeatKeyCode != nil else { return } - switch response.actionIdentifier { - case UNNotificationDefaultActionIdentifier, TerminalNotificationStore.actionShowIdentifier: - let notificationId: UUID? = { - if let id = UUID(uuidString: response.notification.request.identifier) { - return id - } - if let idString = response.notification.request.content.userInfo["notificationId"] as? String, - let id = UUID(uuidString: idString) { - return id - } - return nil - }() - DispatchQueue.main.async { - _ = self.openNotification(tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) - } - case UNNotificationDismissActionIdentifier: - DispatchQueue.main.async { - if let notificationId = UUID(uuidString: response.notification.request.identifier) { - self.notificationStore?.markRead(id: notificationId) - } else if let notificationIdString = response.notification.request.content.userInfo["notificationId"] as? String, - let notificationId = UUID(uuidString: notificationIdString) { - self.notificationStore?.markRead(id: notificationId) - } - } - default: - break +#if DEBUG + let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + dlog( + "browser.focus.omnibar.repeat.tick panel=\(panelToken) " + + "delta=\(browserOmnibarRepeatDelta)" + ) +#endif + dispatchBrowserOmnibarSelectionMove(delta: browserOmnibarRepeatDelta) + + let tick = DispatchWorkItem { [weak self] in + self?.scheduleBrowserOmnibarSelectionRepeatTick() } + browserOmnibarRepeatTickWorkItem = tick + DispatchQueue.main.asyncAfter(deadline: .now() + 0.055, execute: tick) } - private func installMainWindowKeyObserver() { - guard windowKeyObserver == nil else { return } - windowKeyObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didBecomeKeyNotification, - object: nil, - queue: .main - ) { [weak self] note in - guard let self, let window = note.object as? NSWindow else { return } - self.setActiveMainWindow(window) + private func stopBrowserOmnibarSelectionRepeat() { +#if DEBUG + let previousKeyCode = browserOmnibarRepeatKeyCode + let previousDelta = browserOmnibarRepeatDelta +#endif + browserOmnibarRepeatStartWorkItem?.cancel() + browserOmnibarRepeatTickWorkItem?.cancel() + browserOmnibarRepeatStartWorkItem = nil + browserOmnibarRepeatTickWorkItem = nil + browserOmnibarRepeatKeyCode = nil + browserOmnibarRepeatDelta = 0 +#if DEBUG + if previousKeyCode != nil || previousDelta != 0 { + dlog( + "browser.focus.omnibar.repeat.stop key=\(previousKeyCode.map(String.init) ?? "nil") " + + "delta=\(previousDelta)" + ) } +#endif } - private func installBrowserAddressBarFocusObservers() { - guard browserAddressBarFocusObserver == nil, browserAddressBarBlurObserver == nil else { return } + private func handleBrowserOmnibarSelectionRepeatLifecycleEvent(_ event: NSEvent) { + guard browserOmnibarRepeatKeyCode != nil else { return } - browserAddressBarFocusObserver = NotificationCenter.default.addObserver( - forName: .browserDidFocusAddressBar, - object: nil, - queue: .main - ) { [weak self] notification in - guard let self else { return } - guard let panelId = notification.object as? UUID else { return } - self.browserPanel(for: panelId)?.beginSuppressWebViewFocusForAddressBar() - self.browserAddressBarFocusedPanelId = panelId - self.stopBrowserOmnibarSelectionRepeat() + switch event.type { + case .keyUp: + if event.keyCode == browserOmnibarRepeatKeyCode { #if DEBUG - dlog("addressBar FOCUS panelId=\(panelId.uuidString.prefix(8))") + dlog( + "browser.focus.omnibar.repeat.lifecycle event=keyUp key=\(event.keyCode) " + + "action=stop" + ) #endif - } - - browserAddressBarBlurObserver = NotificationCenter.default.addObserver( - forName: .browserDidBlurAddressBar, - object: nil, - queue: .main - ) { [weak self] notification in - guard let self else { return } - guard let panelId = notification.object as? UUID else { return } - self.browserPanel(for: panelId)?.endSuppressWebViewFocusForAddressBar() - if self.browserAddressBarFocusedPanelId == panelId { - self.browserAddressBarFocusedPanelId = nil - self.stopBrowserOmnibarSelectionRepeat() + stopBrowserOmnibarSelectionRepeat() + } + case .flagsChanged: + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if !flags.contains(.command) { #if DEBUG - dlog("addressBar BLUR panelId=\(panelId.uuidString.prefix(8))") + dlog( + "browser.focus.omnibar.repeat.lifecycle event=flagsChanged " + + "flags=\(flags.rawValue) action=stop" + ) #endif + stopBrowserOmnibarSelectionRepeat() } + default: + break } } - private func browserPanel(for panelId: UUID) -> BrowserPanel? { - return tabManager?.selectedWorkspace?.browserPanel(for: panelId) - } - - fileprivate func browserFindBarIsVisible(for webView: ProgramaWebView) -> Bool { - browserPanelOwning(webView)?.searchState != nil - } - - private func shouldLetFocusedBrowserOwnFindShortcut(_ event: NSEvent) -> Bool { - let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow - let shortcutResponder = shortcutWindow?.firstResponder - let owningWebView = tabManager?.focusedBrowserPanel?.webView as? ProgramaWebView - guard let owningWebView else { return false } - return shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( - event, - responder: shortcutResponder, - owningWebView: owningWebView - ) + private func isLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool { + programaIsLikelyWebInspectorResponder(responder) } - private func browserPanelOwning(_ webView: ProgramaWebView) -> BrowserPanel? { - var candidateManagers: [TabManager] = [] - var seenManagers = Set() - - func appendCandidate(_ manager: TabManager?) { - guard let manager else { return } - let identifier = ObjectIdentifier(manager) - guard seenManagers.insert(identifier).inserted else { return } - candidateManagers.append(manager) - } - - if let window = webView.window, - let context = contextForMainWindow(window) { - appendCandidate(context.tabManager) +#if DEBUG + private func developerToolsShortcutProbeKind(event: NSEvent) -> String? { + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) { + return "toggle.configured" } - appendCandidate(tabManager) - for context in mainWindowContexts.values { - appendCandidate(context.tabManager) + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) { + return "console.configured" } - for manager in candidateManagers { - if let panel = browserPanelOwning(webView, in: manager) { - return panel + let chars = (event.charactersIgnoringModifiers ?? "").lowercased() + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags == [.command, .option] { + if chars == "i" || event.keyCode == 34 { + return "toggle.literal" + } + if chars == "c" || event.keyCode == 8 { + return "console.literal" } } return nil } - private func browserPanelOwning(_ webView: ProgramaWebView, in manager: TabManager) -> BrowserPanel? { - for workspace in manager.tabs { - if let panel = workspace.panels.values - .compactMap({ $0 as? BrowserPanel }) - .first(where: { $0.webView === webView }) { - return panel + private func logDeveloperToolsShortcutSnapshot( + phase: String, + event: NSEvent? = nil, + didHandle: Bool? = nil + ) { + let keyWindow = NSApp.keyWindow + let firstResponder = keyWindow?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let eventDescription = event.map(NSWindow.keyDescription) ?? "none" + if let browser = tabManager?.focusedBrowserPanel { + var line = + "browser.devtools shortcut=\(phase) panel=\(browser.id.uuidString.prefix(5)) " + + "\(browser.debugDeveloperToolsStateSummary()) \(browser.debugDeveloperToolsGeometrySummary()) " + + "keyWin=\(keyWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)" + if let didHandle { + line += " handled=\(didHandle ? 1 : 0)" } + dlog(line) + return } - return nil + var line = + "browser.devtools shortcut=\(phase) panel=nil keyWin=\(keyWindow?.windowNumber ?? -1) " + + "fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)" + if let didHandle { + line += " handled=\(didHandle ? 1 : 0)" + } + dlog(line) } - - private func setActiveMainWindow(_ window: NSWindow) { - guard let context = contextForMainTerminalWindow(window) else { return } -#if DEBUG - let beforeManagerToken = debugManagerToken(tabManager) #endif - tabManager = context.tabManager - sidebarState = context.sidebarState - sidebarSelectionState = context.sidebarSelectionState - TerminalController.shared.setActiveTabManager(context.tabManager) -#if DEBUG + + private func prepareFocusedBrowserDevToolsForSplit(directionLabel: String) { + guard let browser = tabManager?.focusedBrowserPanel else { return } + guard browser.shouldPreserveWebViewAttachmentDuringTransientHide() else { return } + guard let keyWindow = NSApp.keyWindow else { return } + guard isLikelyWebInspectorResponder(keyWindow.firstResponder) else { return } + + let beforeResponder = keyWindow.firstResponder + let movedToWebView = keyWindow.makeFirstResponder(browser.webView) + let movedToNil = movedToWebView ? false : keyWindow.makeFirstResponder(nil) + + #if DEBUG + let beforeType = beforeResponder.map { String(describing: type(of: $0)) } ?? "nil" + let beforePtr = beforeResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let afterResponder = keyWindow.firstResponder + let afterType = afterResponder.map { String(describing: type(of: $0)) } ?? "nil" + let afterPtr = afterResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" dlog( - "mainWindow.active window={\(debugWindowToken(window))} context={\(debugContextToken(context))} beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) \(debugShortcutRouteSnapshot())" + "split.shortcut inspector.preflight dir=\(directionLabel) panel=\(browser.id.uuidString.prefix(5)) " + + "before=\(beforeType)@\(beforePtr) after=\(afterType)@\(afterPtr) " + + "moveWeb=\(movedToWebView ? 1 : 0) moveNil=\(movedToNil ? 1 : 0) \(browser.debugDeveloperToolsStateSummary())" ) -#endif + #endif } - private func unregisterMainWindow(_ window: NSWindow) { - // Reset cascade point so the next new window appears near the closing - // window's position, matching upstream Ghostty behavior. - let frame = window.frame - lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY) + @discardableResult + func performSplitShortcut(direction: SplitDirection, preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) + _ = synchronizeActiveMainWindowContext(preferredWindow: targetWindow) - // Keep geometry available as a fallback even if the full session snapshot - // is removed when the last window closes. - persistWindowGeometry(from: window) - guard let removed = unregisterMainWindowContext(for: window) else { return } - commandPaletteVisibilityByWindowId.removeValue(forKey: removed.windowId) - commandPalettePendingOpenByWindowId.removeValue(forKey: removed.windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: removed.windowId) - commandPaletteEscapeSuppressionByWindowId.remove(removed.windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: removed.windowId) - commandPaletteSelectionByWindowId.removeValue(forKey: removed.windowId) - commandPaletteSnapshotByWindowId.removeValue(forKey: removed.windowId) + let directionLabel: String + switch direction { + case .left: directionLabel = "left" + case .right: directionLabel = "right" + case .up: directionLabel = "up" + case .down: directionLabel = "down" + } - // Avoid stale notifications that can no longer be opened once the owning window is gone. - if let store = notificationStore { - for tab in removed.tabManager.tabs { - store.clearNotifications(forTabId: tab.id) + #if DEBUG + let keyWindow = NSApp.keyWindow + let firstResponder = keyWindow?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let firstResponderWindow: Int = { + if let v = firstResponder as? NSView { + return v.window?.windowNumber ?? -1 + } + if let w = firstResponder as? NSWindow { + return w.windowNumber } + return -1 + }() + let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)" + if let browser = tabManager?.focusedBrowserPanel { + let webWindow = browser.webView.window?.windowNumber ?? -1 + let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") + } else { + dlog("split.shortcut dir=\(directionLabel) pre panel=nil \(splitContext)") } + #endif - if tabManager === removed.tabManager { - // Repoint "active" pointers to any remaining main terminal window. - let nextContext: MainWindowContext? = { - if let keyWindow = NSApp.keyWindow, - let ctx = contextForMainTerminalWindow(keyWindow, reindex: false) { - return ctx + prepareFocusedBrowserDevToolsForSplit(directionLabel: directionLabel) + let didCreateSplit: Bool = { + if let terminalContext { + return terminalContext.tabManager.createSplit( + tabId: terminalContext.workspaceId, + surfaceId: terminalContext.panelId, + direction: direction + ) != nil + } + return tabManager?.createSplit(direction: direction) != nil + }() +#if DEBUG + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in + let keyWindow = NSApp.keyWindow + let firstResponder = keyWindow?.firstResponder + let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + let firstResponderWindow: Int = { + if let v = firstResponder as? NSView { + return v.window?.windowNumber ?? -1 } - return mainWindowContexts.values.first + if let w = firstResponder as? NSWindow { + return w.windowNumber + } + return -1 }() - - if let nextContext { - tabManager = nextContext.tabManager - sidebarState = nextContext.sidebarState - sidebarSelectionState = nextContext.sidebarSelectionState - TerminalController.shared.setActiveTabManager(nextContext.tabManager) + let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)" + if let browser = self?.tabManager?.focusedBrowserPanel { + let webWindow = browser.webView.window?.windowNumber ?? -1 + let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil" + dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)") } else { - tabManager = nil - sidebarState = nil - sidebarSelectionState = nil - TerminalController.shared.setActiveTabManager(nil) + dlog("split.shortcut dir=\(directionLabel) post panel=nil \(splitContext)") } } + recordGotoSplitSplitIfNeeded(direction: direction) +#endif + return didCreateSplit + } - // During app termination we already persisted a full snapshot (with scrollback) - // in applicationShouldTerminate/applicationWillTerminate. Saving again here would - // overwrite it as windows tear down one-by-one, dropping closed windows and replay. - if Self.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: isTerminatingApp) { - _ = saveSessionSnapshot( - includeScrollback: false, - removeWhenEmpty: Self.shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister( - isTerminatingApp: isTerminatingApp - ) - ) + @discardableResult + func performBrowserSplitShortcut(direction: SplitDirection) -> Bool { + _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) + + #if DEBUG + let directionLabel: String + switch direction { + case .left: directionLabel = "left" + case .right: directionLabel = "right" + case .up: directionLabel = "up" + case .down: directionLabel = "down" + } + let selectedTabBefore = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" + let focusedPanelBefore = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" + dlog( + "split.browser.shortcut pre dir=\(directionLabel) " + + "tab=\(selectedTabBefore) focusedPanel=\(focusedPanelBefore)" + ) + #endif + + guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { + #if DEBUG + dlog("split.browser.shortcut failed dir=\(directionLabel)") + #endif + return false } + + #if DEBUG + let selectedTabAfter = tabManager?.selectedTabId?.uuidString.prefix(5) ?? "nil" + let focusedPanelAfter = tabManager?.selectedWorkspace?.focusedPanelId?.uuidString.prefix(5) ?? "nil" + dlog( + "split.browser.shortcut post dir=\(directionLabel) " + + "created=\(panelId.uuidString.prefix(5)) tab=\(selectedTabAfter) focusedPanel=\(focusedPanelAfter)" + ) + #endif + + _ = focusBrowserAddressBar(panelId: panelId) + return true } - private func isMainTerminalWindow(_ window: NSWindow) -> Bool { - if mainWindowContexts[ObjectIdentifier(window)] != nil { - return true - } - guard let raw = window.identifier?.rawValue else { return false } - return raw == "cmux.main" || raw.hasPrefix("cmux.main.") + /// Allow AppKit-backed browser surfaces (WKWebView) to route non-menu shortcuts + /// through the same app-level shortcut handler used by the local key monitor. + @discardableResult + func handleBrowserSurfaceKeyEquivalent(_ event: NSEvent) -> Bool { + handleCustomShortcut(event: event) } - private func contextContainingTabId(_ tabId: UUID) -> MainWindowContext? { - for context in mainWindowContexts.values { - if context.tabManager.tabs.contains(where: { $0.id == tabId }) { - return context - } - } - return nil + @discardableResult + func requestRenameWorkspaceViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + requestCommandPaletteRenameWorkspace( + preferredWindow: targetWindow, + source: "shortcut.renameWorkspace" + ) + return true } - /// Returns the `TabManager` that owns `tabId`, if any. - func tabManagerFor(tabId: UUID) -> TabManager? { - contextContainingTabId(tabId)?.tabManager + @discardableResult + func requestEditWorkspaceDescriptionViaCommandPalette(preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow +#if DEBUG + dlog( + "shortcut.editWorkspaceDescription request target={\(debugWindowToken(targetWindow))} " + + "fr=\(targetWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil")" + ) +#endif + requestCommandPaletteEditWorkspaceDescription( + preferredWindow: targetWindow, + source: "shortcut.editWorkspaceDescription" + ) + return true } - private func workspaceForMainActor(tabId: UUID) -> Workspace? { - contextContainingTabId(tabId)?.tabManager.tabs.first(where: { $0.id == tabId }) +#if DEBUG + // Debug/test hook: allow socket-driven shortcut simulation to reuse the same shortcut routing + // logic as the local NSEvent monitor, without relying on AppKit event monitor behavior for + // synthetic NSEvents. + func debugHandleCustomShortcut(event: NSEvent) -> Bool { + handleCustomShortcut(event: event) } - /// Returns the `Workspace` that owns `tabId`, if any. - @MainActor - func workspaceFor(tabId: UUID) -> Workspace? { - workspaceForMainActor(tabId: tabId) + // Debug/test hook: mirrors local monitor routing (keyDown + keyUp lifecycle). + func debugHandleShortcutMonitorEvent(event: NSEvent) -> Bool { + if event.type == .keyDown { + return handleCustomShortcut(event: event) + } + handleBrowserOmnibarSelectionRepeatLifecycleEvent(event) + return clearEscapeSuppressionForKeyUp(event: event, consumeIfSuppressed: true) } - func closeMainWindowContainingTabId(_ tabId: UUID) { - guard let context = contextContainingTabId(tabId) else { return } - let expectedIdentifier = "cmux.main.\(context.windowId.uuidString)" - let window: NSWindow? = context.window ?? NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) - window?.performClose(nil) + func debugMarkCommandPaletteOpenPending(window: NSWindow) { + markCommandPaletteOpenRequested(for: window) } @discardableResult - func openNotification(tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { -#if DEBUG - let isJumpUnreadUITest = ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" - if isJumpUnreadUITest { - writeJumpUnreadTestData([ - "jumpUnreadOpenCalled": "1", - "jumpUnreadOpenTabId": tabId.uuidString, - "jumpUnreadOpenSurfaceId": surfaceId?.uuidString ?? "", - ]) + func debugSetCommandPalettePendingOpenAge(window: NSWindow, age: TimeInterval) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + commandPalettePendingOpenByWindowId[windowId] = true + commandPaletteRecentRequestAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime - max(age, 0) + return true + } + + // Test hook: remap a window context under a detached window key so direct + // ObjectIdentifier(window) lookups fail and fallback logic is exercised. + @discardableResult + func debugInjectWindowContextKeyMismatch(windowId: UUID) -> Bool { + guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }), + let window = context.window ?? windowForMainWindowId(windowId) else { + return false } -#endif - guard let context = contextContainingTabId(tabId) else { -#if DEBUG - recordMultiWindowNotificationOpenFailureIfNeeded( - tabId: tabId, - surfaceId: surfaceId, - notificationId: notificationId, - reason: "missing_context" - ) -#endif -#if DEBUG - if isJumpUnreadUITest { - writeJumpUnreadTestData(["jumpUnreadOpenContextFound": "0", "jumpUnreadOpenUsedFallback": "1"]) - } -#endif - let ok = openNotificationFallback(tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) -#if DEBUG - if isJumpUnreadUITest { - writeJumpUnreadTestData(["jumpUnreadOpenResult": ok ? "1" : "0"]) - } -#endif - return ok + + let detachedWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 16, height: 16), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + debugDetachedContextWindows.append(detachedWindow) + + let contextKeys = mainWindowContexts.compactMap { key, value in + value === context ? key : nil } -#if DEBUG - if isJumpUnreadUITest { - writeJumpUnreadTestData(["jumpUnreadOpenContextFound": "1", "jumpUnreadOpenUsedFallback": "0"]) + for key in contextKeys { + mainWindowContexts.removeValue(forKey: key) } + mainWindowContexts[ObjectIdentifier(detachedWindow)] = context + context.window = window + return true + } #endif - return openNotificationInContext(context, tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) + + private func findButton(in view: NSView, titled title: String) -> NSButton? { + if let button = view as? NSButton, button.title == title { + return button + } + for subview in view.subviews { + if let found = findButton(in: subview, titled: title) { + return found + } + } + return nil } - private func openNotificationInContext(_ context: MainWindowContext, tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { - let expectedIdentifier = "cmux.main.\(context.windowId.uuidString)" - let window: NSWindow? = context.window ?? NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) - guard let window else { -#if DEBUG - recordMultiWindowNotificationOpenFailureIfNeeded( - tabId: tabId, - surfaceId: surfaceId, - notificationId: notificationId, - reason: "missing_window expectedIdentifier=\(expectedIdentifier)" - ) -#endif - return false + private func findStaticText(in view: NSView, equals text: String) -> Bool { + if let field = view as? NSTextField, field.stringValue == text { + return true + } + for subview in view.subviews { + if findStaticText(in: subview, equals: text) { + return true + } } + return false + } - context.sidebarSelectionState.selection = .tabs - bringToFront(window) - guard context.tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else { -#if DEBUG - recordMultiWindowNotificationOpenFailureIfNeeded( - tabId: tabId, - surfaceId: surfaceId, - notificationId: notificationId, - reason: "focus_failed" - ) - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadOpenResult": "0"]) + private func matchConfiguredShortcut(event: NSEvent, action: KeyboardShortcutSettings.Action) -> Bool { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) + if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { + guard let secondStroke = shortcut.secondStroke, + shortcut.firstStroke == prefix else { + return false } -#endif - return false + return matchShortcutStroke(event: event, stroke: secondStroke) } + guard !shortcut.hasChord else { return false } + return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) + } -#if DEBUG - // UI test support: Jump-to-unread asserts that the correct workspace/panel is focused. - // Recording via first-responder can be flaky on the VM, so verify focus via the model. - recordJumpUnreadFocusFromModelIfNeeded( - tabManager: context.tabManager, - tabId: tabId, - expectedSurfaceId: surfaceId - ) -#endif + private func numberedConfiguredShortcutDigit( + event: NSEvent, + action: KeyboardShortcutSettings.Action + ) -> Int? { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) + if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { + guard let secondStroke = shortcut.secondStroke, + shortcut.firstStroke == prefix else { + return nil + } + return numberedShortcutDigit(event: event, stroke: secondStroke) + } + guard !shortcut.hasChord else { return nil } + return numberedShortcutDigit(event: event, stroke: shortcut.firstStroke) + } - if let notificationId, let store = notificationStore { - markReadIfFocused( - notificationId: notificationId, - tabId: tabId, - surfaceId: surfaceId, - tabManager: context.tabManager, - notificationStore: store + private func matchConfiguredDirectionalShortcut( + event: NSEvent, + action: KeyboardShortcutSettings.Action, + arrowGlyph: String, + arrowKeyCode: UInt16 + ) -> Bool { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) + if let prefix = activeConfiguredShortcutChordPrefixForCurrentEvent { + guard let secondStroke = shortcut.secondStroke, + shortcut.firstStroke == prefix else { + return false + } + return matchDirectionalShortcut( + event: event, + stroke: secondStroke, + arrowGlyph: arrowGlyph, + arrowKeyCode: arrowKeyCode ) } - -#if DEBUG - recordMultiWindowNotificationFocusIfNeeded( - windowId: context.windowId, - tabId: tabId, - surfaceId: surfaceId, - sidebarSelection: context.sidebarSelectionState.selection + guard !shortcut.hasChord else { return false } + return matchDirectionalShortcut( + event: event, + stroke: shortcut.firstStroke, + arrowGlyph: arrowGlyph, + arrowKeyCode: arrowKeyCode ) - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadOpenInContext": "1", "jumpUnreadOpenResult": "1"]) - } -#endif - return true } - private func openNotificationFallback(tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { - // If the owning window context hasn't been registered yet, fall back to the "active" window. - guard let tabManager else { -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadFallbackFail": "missing_tabManager"]) - } -#endif - return false - } - guard tabManager.tabs.contains(where: { $0.id == tabId }) else { -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadFallbackFail": "tab_not_in_active_manager"]) - } -#endif - return false + private func configuredShortcutChordWindowNumber(for event: NSEvent) -> Int? { + if let window = mainWindowForShortcutEvent(event) { + return window.windowNumber } - guard let window = (NSApp.keyWindow ?? NSApp.windows.first(where: { isMainTerminalWindow($0) })) else { -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadFallbackFail": "missing_window"]) - } -#endif - return false + if let window = event.window { + return window.windowNumber } + return event.windowNumber > 0 ? event.windowNumber : nil + } - sidebarSelectionState?.selection = .tabs - bringToFront(window) - guard tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else { -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData([ - "jumpUnreadFallbackFail": "focus_failed", - "jumpUnreadOpenResult": "0", - ]) + private func armConfiguredShortcutChordIfNeeded( + event: NSEvent, + actions: [KeyboardShortcutSettings.Action]? = nil + ) -> Bool { + for action in actions ?? configuredShortcutChordActions { + let shortcut = KeyboardShortcutSettings.shortcut(for: action) + guard shortcut.hasChord else { continue } + if matchShortcutStroke(event: event, stroke: shortcut.firstStroke) { + pendingConfiguredShortcutChord = PendingConfiguredShortcutChord( + firstStroke: shortcut.firstStroke, + windowNumber: configuredShortcutChordWindowNumber(for: event) + ) + return true } -#endif - return false } + return false + } -#if DEBUG - recordJumpUnreadFocusFromModelIfNeeded( - tabManager: tabManager, - tabId: tabId, - expectedSurfaceId: surfaceId - ) -#endif + /// Match a shortcut stroke against an event, handling normal keys. + private func matchShortcutStroke(event: NSEvent, stroke: ShortcutStroke) -> Bool { + // Some keys can include extra flags (e.g. .function) depending on the responder chain. + // Strip those for consistent matching across first responders (terminal, WebKit, etc). + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + guard flags == stroke.modifierFlags else { return false } - if let notificationId, let store = notificationStore { - markReadIfFocused( - notificationId: notificationId, - tabId: tabId, - surfaceId: surfaceId, - tabManager: tabManager, - notificationStore: store - ) + let shortcutKey = stroke.key.lowercased() + if shortcutKey == "\r" { + return event.keyCode == 36 || event.keyCode == 76 } -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { - writeJumpUnreadTestData(["jumpUnreadOpenInFallback": "1", "jumpUnreadOpenResult": "1"]) + + let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers + if shortcutCharacterMatches( + eventCharacter: eventCharsIgnoringModifiers, + shortcutKey: shortcutKey, + applyShiftSymbolNormalization: flags.contains(.shift), + eventKeyCode: event.keyCode + ) { + return true } -#endif - return true - } -#if DEBUG - private func recordJumpUnreadFocusFromModelIfNeeded( - tabManager: TabManager, - tabId: UUID, - expectedSurfaceId: UUID? - ) { - let env = ProcessInfo.processInfo.environment - guard env["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" else { return } - guard let expectedSurfaceId else { return } + // For command-based shortcuts, trust AppKit's layout-aware characters when present. + // Keep this strict for letter shortcuts to avoid physical-key collisions across layouts, + // while still allowing keyCode fallback for digit/punctuation shortcuts on non-US layouts. + // When a non-Latin input source is active (Russian, Korean, Chinese, Japanese, etc.), + // charactersIgnoringModifiers returns non-ASCII characters that can never match + // a Latin shortcut key — skip this guard and fall through to layout-based matching. + let hasEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) + let eventCharsAreASCII = eventCharsIgnoringModifiers?.allSatisfy(\.isASCII) ?? true + let shortcutKeyIsDigit = shortcutKey.count == 1 && shortcutKey.first?.isNumber == true + if shortcutKeyIsDigit, + hasEventChars, + eventCharsAreASCII, + digitForNumberKeyCode(event.keyCode) == nil { + return false + } + if hasEventChars, + eventCharsAreASCII, + flags.contains(.command), + !flags.contains(.control), + shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) { + return false + } - // Ensure the expectation is armed even if the view doesn't become first responder. - armJumpUnreadFocusRecord(tabId: tabId, surfaceId: expectedSurfaceId) + // Match using the current keyboard layout so Command shortcuts stay character-based + // across layouts (QWERTY, Dvorak, etc.) instead of being tied to ANSI physical keys. + let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) + if shortcutCharacterMatches( + eventCharacter: layoutCharacter, + shortcutKey: shortcutKey, + applyShiftSymbolNormalization: false, + eventKeyCode: event.keyCode + ) { + return true + } - if tabManager.selectedTabId == tabId, - tabManager.focusedSurfaceId(for: tabId) == expectedSurfaceId { - recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId) - return + // Control-key combos can surface as ASCII control characters (e.g. Ctrl+H => backspace), + // so keep ANSI keyCode fallback for control-modified shortcuts. Also allow fallback for + // command punctuation shortcuts, since some non-US layouts report different characters + // for the same physical key even when menu-equivalent semantics should still apply. + // When a non-Latin input source is active (Russian, Korean, Chinese, Japanese, etc.), + // event chars carry no usable Latin key identity. Always allow keyCode fallback as a + // safety net — even when the layout-based translation resolved a character, the + // physical key code is the definitive identifier for the intended shortcut. + // For empty-character events (synthetic/browser key equivalents), preserve the original + // behavior: only fall back when the layout translation also failed. + let hasUsableEventChars = hasEventChars && eventCharsAreASCII + let allowANSIKeyCodeFallback = flags.contains(.control) + || (flags.contains(.command) + && !flags.contains(.control) + && ( + !shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) + || (hasEventChars && !eventCharsAreASCII) + || (!hasEventChars && (layoutCharacter?.isEmpty ?? true)) + )) + if allowANSIKeyCodeFallback, let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) { + return event.keyCode == expectedKeyCode } + return false + } - var resolved = false - var observers: [NSObjectProtocol] = [] - var cancellables: [AnyCancellable] = [] + private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { + guard !shortcut.hasChord else { return false } + return matchShortcutStroke(event: event, stroke: shortcut.firstStroke) + } - func cleanup() { - observers.forEach { NotificationCenter.default.removeObserver($0) } - observers.removeAll() - cancellables.forEach { $0.cancel() } - cancellables.removeAll() - } + private func numberedShortcutDigit(event: NSEvent, stroke: ShortcutStroke) -> Int? { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + guard flags == stroke.modifierFlags else { return nil } + let numberKeyDigit = digitForNumberKeyCode(event.keyCode) - @MainActor - func finishIfFocused() { - guard !resolved else { return } - guard tabManager.selectedTabId == tabId, - tabManager.focusedSurfaceId(for: tabId) == expectedSurfaceId else { - return - } - resolved = true - cleanup() - self.recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId) + if let digit = numberedShortcutDigit( + eventCharacter: event.charactersIgnoringModifiers, + applyShiftSymbolNormalization: flags.contains(.shift), + eventKeyCode: event.keyCode + ) { + return digit } - observers.append(NotificationCenter.default.addObserver( - forName: .ghosttyDidFocusSurface, - object: nil, - queue: .main - ) { note in - guard let surfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, - surfaceId == expectedSurfaceId else { return } - Task { @MainActor in finishIfFocused() } - }) - cancellables.append(tabManager.$selectedTabId.sink { _ in - Task { @MainActor in finishIfFocused() } - }) - if let workspace = tabManager.tabs.first(where: { $0.id == tabId }) { - cancellables.append(workspace.$panels - .map { _ in () } - .sink { _ in - Task { @MainActor in finishIfFocused() } - }) - } - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - Task { @MainActor in - guard !resolved else { return } - cleanup() + let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers + let hasUsableASCIIEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) + && (eventCharsIgnoringModifiers?.allSatisfy(\.isASCII) ?? true) + if !hasUsableASCIIEventChars || numberKeyDigit != nil { + let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) + if let digit = numberedShortcutDigit( + eventCharacter: layoutCharacter, + applyShiftSymbolNormalization: false, + eventKeyCode: event.keyCode + ) { + return digit } } - Task { @MainActor in finishIfFocused() } + + return numberKeyDigit } -#endif - func tabTitle(for tabId: UUID) -> String? { - if let context = contextContainingTabId(tabId) { - return context.tabManager.tabs.first(where: { $0.id == tabId })?.title - } - return tabManager?.tabs.first(where: { $0.id == tabId })?.title + private func numberedShortcutDigit(event: NSEvent, shortcut: StoredShortcut) -> Int? { + guard !shortcut.hasChord else { return nil } + return numberedShortcutDigit(event: event, stroke: shortcut.firstStroke) } - private func bringToFront(_ window: NSWindow) { - if TerminalController.shouldSuppressSocketCommandActivation(), - !TerminalController.socketCommandAllowsInAppFocusMutations() { - return - } - setActiveMainWindow(window) - if window.isMiniaturized { - window.deminiaturize(nil) - } - window.makeKeyAndOrderFront(nil) - // Improve reliability across Spaces / when other helper panels are key. - NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + private func numberedShortcutDigit( + eventCharacter: String?, + applyShiftSymbolNormalization: Bool, + eventKeyCode: UInt16 + ) -> Int? { + guard let eventCharacter, !eventCharacter.isEmpty else { return nil } + let normalized = normalizedShortcutEventCharacter( + eventCharacter, + applyShiftSymbolNormalization: applyShiftSymbolNormalization, + eventKeyCode: eventKeyCode + ) + guard let digit = Int(normalized), (1...9).contains(digit) else { return nil } + return digit } - private func markReadIfFocused( - notificationId: UUID, - tabId: UUID, - surfaceId: UUID?, - tabManager: TabManager, - notificationStore: TerminalNotificationStore - ) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - guard tabManager.selectedTabId == tabId else { return } - if let surfaceId { - guard tabManager.focusedSurfaceId(for: tabId) == surfaceId else { return } - } - notificationStore.markRead(id: notificationId) + private func shouldRequireCharacterMatchForCommandShortcut(shortcutKey: String) -> Bool { + guard shortcutKey.count == 1, let scalar = shortcutKey.unicodeScalars.first else { + return false } + return CharacterSet.letters.contains(scalar) } -#if DEBUG - private func recordMultiWindowNotificationOpenFailureIfNeeded( - tabId: UUID, - surfaceId: UUID?, - notificationId: UUID?, - reason: String - ) { - let env = ProcessInfo.processInfo.environment - guard let path = env["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_PATH"], !path.isEmpty else { return } - - let contextSummaries: [String] = mainWindowContexts.values.map { ctx in - let tabIds = ctx.tabManager.tabs.map { $0.id.uuidString }.joined(separator: ",") - let hasWindow = (ctx.window != nil) ? "1" : "0" - return "windowId=\(ctx.windowId.uuidString) hasWindow=\(hasWindow) tabs=[\(tabIds)]" + private func shortcutCharacterMatches( + eventCharacter: String?, + shortcutKey: String, + applyShiftSymbolNormalization: Bool, + eventKeyCode: UInt16 + ) -> Bool { + guard let eventCharacter, !eventCharacter.isEmpty else { return false } + if normalizedShortcutEventCharacter( + eventCharacter, + applyShiftSymbolNormalization: applyShiftSymbolNormalization, + eventKeyCode: eventKeyCode + ) == shortcutKey { + return true } - - writeMultiWindowNotificationTestData([ - "focusToken": UUID().uuidString, - "openFailureTabId": tabId.uuidString, - "openFailureSurfaceId": surfaceId?.uuidString ?? "", - "openFailureNotificationId": notificationId?.uuidString ?? "", - "openFailureReason": reason, - "openFailureContexts": contextSummaries.joined(separator: "; "), - ], at: path) + return false } -#endif - - /// Configures a Programa main window's AppKit-level presentation (identifier, - /// titlebar, movability, transparency, glass effect, decorations) and registers - /// it for window-context tracking, then installs the file-drop overlay. - /// - /// Extracted verbatim from `ContentView`'s `WindowAccessor` trailing closure - /// (nuclear-review CV2b) so this AppKit window mutation lives with the - /// window-context layer instead of the SwiftUI view layer. `ContentView` still - /// owns tracking its own `@State` (`observedWindow`, `isFullScreen`, - /// `titlebarPadding`) — this method returns the computed titlebar padding so - /// the caller can decide whether to update that `@State`. - @discardableResult - func configureMainWindow( - _ window: NSWindow, - windowId: UUID, - windowIdentifier: String, - tabManager: TabManager, - sidebarState: SidebarState, - sidebarSelectionState: SidebarSelectionState, - sidebarBlendMode: String, - bgGlassEnabled: Bool, - bgGlassTintHex: String, - bgGlassTintOpacity: Double - ) -> CGFloat { - window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier) - window.titlebarAppearsTransparent = true - // Keep window immovable; the sidebar's WindowDragHandleView handles - // drag-to-move via performDrag with temporary movable override. - // isMovableByWindowBackground=true breaks tab reordering, and - // isMovable=true blocks clicks on sidebar buttons in minimal mode. - window.isMovableByWindowBackground = false - window.isMovable = false - window.styleMask.insert(.fullSizeContentView) - // Keep content below the titlebar so drags on Bonsplit's tab bar don't - // get interpreted as window drags. - let computedTitlebarHeight = window.frame.height - window.contentLayoutRect.height - let nextTitlebarPadding = max(28, min(72, computedTitlebarHeight)) -#if DEBUG - if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_MODE"] == "1" { - UpdateLogStore.shared.append("ui test window accessor: id=\(windowIdentifier) visible=\(window.isVisible)") - } -#endif - // User settings decide whether window glass is active. The native Tahoe - // NSGlassEffectView path vs the older NSVisualEffectView fallback is chosen - // inside WindowGlassEffect.apply. - let currentThemeBackground = GhosttyBackgroundTheme.currentColor() - let shouldApplyWindowGlass = cmuxShouldApplyWindowGlass( - sidebarBlendMode: sidebarBlendMode, - bgGlassEnabled: bgGlassEnabled, - glassEffectAvailable: WindowGlassEffect.isAvailable - ) - let shouldForceTransparentHosting = - shouldApplyWindowGlass || currentThemeBackground.alphaComponent < 0.999 + private func normalizedShortcutEventCharacter( + _ eventCharacter: String, + applyShiftSymbolNormalization: Bool, + eventKeyCode: UInt16 + ) -> String { + let lowered = eventCharacter.lowercased() + guard applyShiftSymbolNormalization else { return lowered } - if shouldForceTransparentHosting { - window.isOpaque = false - // Keep the window clear whenever translucency is active. Relying only on - // terminal focus-driven updates can leave stale opaque window fills. - window.backgroundColor = NSColor.white.withAlphaComponent(0.001) - // Configure contentView hierarchy for transparency. - if let contentView = window.contentView { - ContentView.makeViewHierarchyTransparent(contentView) - } - } else { - // Browser-focused workspaces may not have an active terminal panel to refresh - // the NSWindow background. Keep opaque theme changes applied here as well. - window.backgroundColor = currentThemeBackground - window.isOpaque = currentThemeBackground.alphaComponent >= 0.999 + switch lowered { + case "{": return "[" + case "}": return "]" + case "<": return eventKeyCode == 43 ? "," : lowered // kVK_ANSI_Comma + case ">": return eventKeyCode == 47 ? "." : lowered // kVK_ANSI_Period + case "?": return "/" + case ":": return ";" + case "\"": return "'" + case "|": return "\\" + case "~": return "`" + case "+": return "=" + case "_": return "-" + case "!": return eventKeyCode == 18 ? "1" : lowered // kVK_ANSI_1 + case "@": return eventKeyCode == 19 ? "2" : lowered // kVK_ANSI_2 + case "#": return eventKeyCode == 20 ? "3" : lowered // kVK_ANSI_3 + case "$": return eventKeyCode == 21 ? "4" : lowered // kVK_ANSI_4 + case "%": return eventKeyCode == 23 ? "5" : lowered // kVK_ANSI_5 + case "^": return eventKeyCode == 22 ? "6" : lowered // kVK_ANSI_6 + case "&": return eventKeyCode == 26 ? "7" : lowered // kVK_ANSI_7 + case "*": return eventKeyCode == 28 ? "8" : lowered // kVK_ANSI_8 + case "(": return eventKeyCode == 25 ? "9" : lowered // kVK_ANSI_9 + case ")": return eventKeyCode == 29 ? "0" : lowered // kVK_ANSI_0 + default: return lowered } + } - if shouldApplyWindowGlass { - // Apply liquid glass effect to the window with tint from settings - let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) - WindowGlassEffect.apply(to: window, tintColor: tintColor) - } else { - WindowGlassEffect.remove(from: window) + private func keyCodeForShortcutKey(_ key: String) -> UInt16? { + // Matches macOS ANSI key codes. This is intentionally limited to keys we + // support in StoredShortcut/ghostty trigger translation. + switch key { + case "a": return 0 // kVK_ANSI_A + case "s": return 1 // kVK_ANSI_S + case "d": return 2 // kVK_ANSI_D + case "f": return 3 // kVK_ANSI_F + case "h": return 4 // kVK_ANSI_H + case "g": return 5 // kVK_ANSI_G + case "z": return 6 // kVK_ANSI_Z + case "x": return 7 // kVK_ANSI_X + case "c": return 8 // kVK_ANSI_C + case "v": return 9 // kVK_ANSI_V + case "b": return 11 // kVK_ANSI_B + case "q": return 12 // kVK_ANSI_Q + case "w": return 13 // kVK_ANSI_W + case "e": return 14 // kVK_ANSI_E + case "r": return 15 // kVK_ANSI_R + case "y": return 16 // kVK_ANSI_Y + case "t": return 17 // kVK_ANSI_T + case "1": return 18 // kVK_ANSI_1 + case "2": return 19 // kVK_ANSI_2 + case "3": return 20 // kVK_ANSI_3 + case "4": return 21 // kVK_ANSI_4 + case "6": return 22 // kVK_ANSI_6 + case "5": return 23 // kVK_ANSI_5 + case "=": return 24 // kVK_ANSI_Equal + case "9": return 25 // kVK_ANSI_9 + case "7": return 26 // kVK_ANSI_7 + case "-": return 27 // kVK_ANSI_Minus + case "8": return 28 // kVK_ANSI_8 + case "0": return 29 // kVK_ANSI_0 + case "]": return 30 // kVK_ANSI_RightBracket + case "o": return 31 // kVK_ANSI_O + case "u": return 32 // kVK_ANSI_U + case "[": return 33 // kVK_ANSI_LeftBracket + case "i": return 34 // kVK_ANSI_I + case "p": return 35 // kVK_ANSI_P + case "l": return 37 // kVK_ANSI_L + case "j": return 38 // kVK_ANSI_J + case "'": return 39 // kVK_ANSI_Quote + case "k": return 40 // kVK_ANSI_K + case ";": return 41 // kVK_ANSI_Semicolon + case "\\": return 42 // kVK_ANSI_Backslash + case ",": return 43 // kVK_ANSI_Comma + case "/": return 44 // kVK_ANSI_Slash + case "n": return 45 // kVK_ANSI_N + case "m": return 46 // kVK_ANSI_M + case ".": return 47 // kVK_ANSI_Period + case "`": return 50 // kVK_ANSI_Grave + case "\r": return 36 // kVK_Return + case "←": return 123 // kVK_LeftArrow + case "→": return 124 // kVK_RightArrow + case "↓": return 125 // kVK_DownArrow + case "↑": return 126 // kVK_UpArrow + default: + return nil } - AppDelegate.shared?.attachUpdateAccessory(to: window) - AppDelegate.shared?.applyWindowDecorations(to: window) - AppDelegate.shared?.registerMainWindow( - window, - windowId: windowId, - tabManager: tabManager, - sidebarState: sidebarState, - sidebarSelectionState: sidebarSelectionState - ) - installFileDropOverlay(on: window, tabManager: tabManager) - - return nextTitlebarPadding } -} - -@MainActor -final class MenuBarExtraController: NSObject, NSMenuDelegate { - private let statusItem: NSStatusItem - private let menu = NSMenu(title: "Programa") - private let notificationStore: TerminalNotificationStore - private let onShowNotifications: () -> Void - private let onOpenNotification: (TerminalNotification) -> Void - private let onJumpToLatestUnread: () -> Void - private let onCheckForUpdates: () -> Void - private let onOpenPreferences: () -> Void - private let onQuitApp: () -> Void - private var notificationsCancellable: AnyCancellable? - private let buildHintTitle: String? - - private let stateHintItem = NSMenuItem(title: String(localized: "statusMenu.noUnread", defaultValue: "No unread notifications"), action: nil, keyEquivalent: "") - private let buildHintItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") - private let notificationListSeparator = NSMenuItem.separator() - private let notificationSectionSeparator = NSMenuItem.separator() - private let showNotificationsItem = NSMenuItem(title: String(localized: "statusMenu.showNotifications", defaultValue: "Show Notifications"), action: nil, keyEquivalent: "") - private let jumpToUnreadItem = NSMenuItem(title: String(localized: "statusMenu.jumpToLatestUnread", defaultValue: "Jump to Latest Unread"), action: nil, keyEquivalent: "") - private let markAllReadItem = NSMenuItem(title: String(localized: "statusMenu.markAllRead", defaultValue: "Mark All Read"), action: nil, keyEquivalent: "") - private let clearAllItem = NSMenuItem(title: String(localized: "statusMenu.clearAll", defaultValue: "Clear All"), action: nil, keyEquivalent: "") - private let checkForUpdatesItem = NSMenuItem(title: String(localized: "menu.checkForUpdates", defaultValue: "Check for Updates…"), action: nil, keyEquivalent: "") - private let preferencesItem = NSMenuItem(title: String(localized: "menu.preferences", defaultValue: "Preferences…"), action: nil, keyEquivalent: "") - private let quitItem = NSMenuItem(title: String(localized: "menu.quitPrograma", defaultValue: "Quit Programa"), action: nil, keyEquivalent: "") - - private var notificationItems: [NSMenuItem] = [] - private let maxInlineNotificationItems = 6 - - init( - notificationStore: TerminalNotificationStore, - onShowNotifications: @escaping () -> Void, - onOpenNotification: @escaping (TerminalNotification) -> Void, - onJumpToLatestUnread: @escaping () -> Void, - onCheckForUpdates: @escaping () -> Void, - onOpenPreferences: @escaping () -> Void, - onQuitApp: @escaping () -> Void - ) { - self.notificationStore = notificationStore - self.onShowNotifications = onShowNotifications - self.onOpenNotification = onOpenNotification - self.onJumpToLatestUnread = onJumpToLatestUnread - self.onCheckForUpdates = onCheckForUpdates - self.onOpenPreferences = onOpenPreferences - self.onQuitApp = onQuitApp - self.buildHintTitle = MenuBarBuildHintFormatter.menuTitle() - self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - super.init() - - buildMenu() - statusItem.menu = menu - if let button = statusItem.button { - button.imagePosition = .imageOnly - button.imageScaling = .scaleProportionallyDown - button.image = MenuBarIconRenderer.makeImage(unreadCount: 0) - button.toolTip = "Programa" + private func digitForNumberKeyCode(_ keyCode: UInt16) -> Int? { + switch keyCode { + case 18: return 1 // kVK_ANSI_1 + case 19: return 2 // kVK_ANSI_2 + case 20: return 3 // kVK_ANSI_3 + case 21: return 4 // kVK_ANSI_4 + case 23: return 5 // kVK_ANSI_5 + case 22: return 6 // kVK_ANSI_6 + case 26: return 7 // kVK_ANSI_7 + case 28: return 8 // kVK_ANSI_8 + case 25: return 9 // kVK_ANSI_9 + default: + return nil } - - notificationsCancellable = notificationStore.$notifications - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.refreshUI() - } - - refreshUI() } - private func buildMenu() { - menu.autoenablesItems = false - menu.delegate = self - - stateHintItem.isEnabled = false - menu.addItem(stateHintItem) - if let buildHintTitle { - buildHintItem.title = buildHintTitle - buildHintItem.isEnabled = false - menu.addItem(buildHintItem) - } - - menu.addItem(notificationListSeparator) - notificationSectionSeparator.isHidden = true - menu.addItem(notificationSectionSeparator) - - showNotificationsItem.target = self - showNotificationsItem.action = #selector(showNotificationsAction) - menu.addItem(showNotificationsItem) - - jumpToUnreadItem.target = self - jumpToUnreadItem.action = #selector(jumpToUnreadAction) - menu.addItem(jumpToUnreadItem) - - markAllReadItem.target = self - markAllReadItem.action = #selector(markAllReadAction) - menu.addItem(markAllReadItem) - - clearAllItem.target = self - clearAllItem.action = #selector(clearAllAction) - menu.addItem(clearAllItem) - - menu.addItem(.separator()) - - checkForUpdatesItem.target = self - checkForUpdatesItem.action = #selector(checkForUpdatesAction) - menu.addItem(checkForUpdatesItem) - - preferencesItem.target = self - preferencesItem.action = #selector(preferencesAction) - menu.addItem(preferencesItem) - - menu.addItem(.separator()) - - quitItem.target = self - quitItem.action = #selector(quitAction) - menu.addItem(quitItem) + /// Match arrow key shortcuts using keyCode + /// Arrow keys include .numericPad and .function in their modifierFlags, so strip those before comparing. + private func matchArrowShortcut(event: NSEvent, stroke: ShortcutStroke, keyCode: UInt16) -> Bool { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + return event.keyCode == keyCode && flags == stroke.modifierFlags } - func menuWillOpen(_ menu: NSMenu) { - refreshUI() + /// Match tab key shortcuts using keyCode 48 + private func matchTabShortcut(event: NSEvent, stroke: ShortcutStroke) -> Bool { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + return event.keyCode == 48 && flags == stroke.modifierFlags } - func refreshForDebugControls() { - refreshUI() + private func matchTabShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool { + guard !shortcut.hasChord else { return false } + return matchTabShortcut(event: event, stroke: shortcut.firstStroke) } - func removeFromMenuBar() { - notificationsCancellable?.cancel() - notificationsCancellable = nil - statusItem.menu = nil - NSStatusBar.system.removeStatusItem(statusItem) + /// Directional shortcuts default to arrow keys, but the shortcut recorder only supports letter/number keys. + /// Support both so users can customize pane navigation (e.g. Cmd+Ctrl+H/J/K/L). + private func matchDirectionalShortcut( + event: NSEvent, + stroke: ShortcutStroke, + arrowGlyph: String, + arrowKeyCode: UInt16 + ) -> Bool { + if stroke.key == arrowGlyph { + return matchArrowShortcut(event: event, stroke: stroke, keyCode: arrowKeyCode) + } + return matchShortcutStroke(event: event, stroke: stroke) } - private func refreshUI() { - let snapshot = NotificationMenuSnapshotBuilder.make( - notifications: notificationStore.notifications, - maxInlineNotificationItems: maxInlineNotificationItems + private func matchDirectionalShortcut( + event: NSEvent, + shortcut: StoredShortcut, + arrowGlyph: String, + arrowKeyCode: UInt16 + ) -> Bool { + guard !shortcut.hasChord else { return false } + return matchDirectionalShortcut( + event: event, + stroke: shortcut.firstStroke, + arrowGlyph: arrowGlyph, + arrowKeyCode: arrowKeyCode ) - let actualUnreadCount = snapshot.unreadCount - - let displayedUnreadCount: Int -#if DEBUG - displayedUnreadCount = MenuBarIconDebugSettings.displayedUnreadCount(actualUnreadCount: actualUnreadCount) -#else - displayedUnreadCount = actualUnreadCount -#endif + } - stateHintItem.title = snapshot.stateHintTitle + func validateMenuItem(_ item: NSMenuItem) -> Bool { + updateController.validateMenuItem(item) + } - applyShortcut(KeyboardShortcutSettings.shortcut(for: .showNotifications), to: showNotificationsItem) - applyShortcut(KeyboardShortcutSettings.shortcut(for: .jumpToUnread), to: jumpToUnreadItem) - jumpToUnreadItem.isEnabled = snapshot.hasUnreadNotifications - markAllReadItem.isEnabled = snapshot.hasUnreadNotifications - clearAllItem.isEnabled = snapshot.hasNotifications + private func configureUserNotifications() { + let actions = [ + UNNotificationAction( + identifier: TerminalNotificationStore.actionShowIdentifier, + title: "Show" + ) + ] - rebuildInlineNotificationItems(recentNotifications: snapshot.recentNotifications) + let category = UNNotificationCategory( + identifier: TerminalNotificationStore.categoryIdentifier, + actions: actions, + intentIdentifiers: [], + options: [.customDismissAction] + ) - if let button = statusItem.button { - button.image = MenuBarIconRenderer.makeImage(unreadCount: displayedUnreadCount) - button.toolTip = displayedUnreadCount == 0 - ? "Programa" - : displayedUnreadCount == 1 - ? "Programa: " + String(localized: "statusMenu.tooltip.unread.one", defaultValue: "1 unread notification") - : "Programa: " + String(localized: "statusMenu.tooltip.unread.other", defaultValue: "\(displayedUnreadCount) unread notifications") - } + let center = UNUserNotificationCenter.current() + center.setNotificationCategories([category]) + center.delegate = self } - private func applyShortcut(_ shortcut: StoredShortcut, to item: NSMenuItem) { - guard let keyEquivalent = shortcut.menuItemKeyEquivalent else { - item.keyEquivalent = "" - item.keyEquivalentModifierMask = [] - return - } - item.keyEquivalent = keyEquivalent - item.keyEquivalentModifierMask = shortcut.modifierFlags + private func disableNativeTabbingShortcut() { + guard let menu = NSApp.mainMenu else { return } + disableMenuItemShortcut(in: menu, action: #selector(NSWindow.toggleTabBar(_:))) } - private func rebuildInlineNotificationItems(recentNotifications: [TerminalNotification]) { - for item in notificationItems { - menu.removeItem(item) - } - notificationItems.removeAll(keepingCapacity: true) - - notificationListSeparator.isHidden = recentNotifications.isEmpty - notificationSectionSeparator.isHidden = recentNotifications.isEmpty - guard !recentNotifications.isEmpty else { return } - - let insertionIndex = menu.index(of: showNotificationsItem) - guard insertionIndex >= 0 else { return } - - for (offset, notification) in recentNotifications.enumerated() { - let tabTitle = AppDelegate.shared?.tabTitle(for: notification.tabId) - let item = makeNotificationItem(notification: notification, tabTitle: tabTitle) - menu.insertItem(item, at: insertionIndex + offset) - notificationItems.append(item) + private func disableMenuItemShortcut(in menu: NSMenu, action: Selector) { + for item in menu.items { + if item.action == action { + item.keyEquivalent = "" + item.keyEquivalentModifierMask = [] + item.isEnabled = false + } + if let submenu = item.submenu { + disableMenuItemShortcut(in: submenu, action: action) + } } } - private func makeNotificationItem(notification: TerminalNotification, tabTitle: String?) -> NSMenuItem { - let item = NSMenuItem(title: "", action: #selector(openNotificationItemAction(_:)), keyEquivalent: "") - item.target = self - item.attributedTitle = MenuBarNotificationLineFormatter.attributedTitle(notification: notification, tabTitle: tabTitle) - item.toolTip = MenuBarNotificationLineFormatter.tooltip(notification: notification, tabTitle: tabTitle) - item.representedObject = NotificationMenuItemPayload(notification: notification) - return item + private func ensureApplicationIcon() { + // Manual light/dark override removed; the icon always tracks system + // appearance. Clear any stale pre-removal override so the dock-tile + // plugin (which reads the raw key cross-process) falls back too. + UserDefaults.standard.removeObject(forKey: "appIconMode") + AppIconAppearanceObserver.shared.startObserving() } - @objc private func openNotificationItemAction(_ sender: NSMenuItem) { - guard let payload = sender.representedObject as? NotificationMenuItemPayload else { return } - onOpenNotification(payload.notification) - } + private func scheduleLaunchServicesBundleRegistration( + bundleURL: URL = Bundle.main.bundleURL.standardizedFileURL, + scheduler: @escaping (@escaping @Sendable () -> Void) -> Void = AppDelegate.enqueueLaunchServicesRegistrationWork, + register: @escaping (CFURL) -> OSStatus = { url in + LSRegisterURL(url, true) + }, + breadcrumb: @escaping (_ message: String, _ data: [String: Any]) -> Void = { _, _ in } + ) { + let normalizedURL = bundleURL.standardizedFileURL + breadcrumb("launchservices.register.schedule", [ + "bundlePath": normalizedURL.path + ]) - @objc private func showNotificationsAction() { - onShowNotifications() - } + scheduler { + let startedAt = CFAbsoluteTimeGetCurrent() + let registerStatus = register(normalizedURL as CFURL) + let durationMs = Int(((CFAbsoluteTimeGetCurrent() - startedAt) * 1000).rounded()) - @objc private func jumpToUnreadAction() { - onJumpToLatestUnread() - } + breadcrumb("launchservices.register.complete", [ + "bundlePath": normalizedURL.path, + "status": Int(registerStatus), + "durationMs": durationMs + ]) - @objc private func markAllReadAction() { - notificationStore.markAllRead() + if registerStatus != noErr { + NSLog("LaunchServices registration failed (status: \(registerStatus)) for \(normalizedURL.path)") + } + } } - @objc private func clearAllAction() { - notificationStore.clearAll() +#if DEBUG + func scheduleLaunchServicesBundleRegistrationForTesting( + bundleURL: URL, + scheduler: @escaping (@escaping @Sendable () -> Void) -> Void, + register: @escaping (CFURL) -> OSStatus, + breadcrumb: @escaping (_ message: String, _ data: [String: Any]) -> Void = { _, _ in } + ) { + scheduleLaunchServicesBundleRegistration( + bundleURL: bundleURL, + scheduler: scheduler, + register: register, + breadcrumb: breadcrumb + ) } +#endif - @objc private func checkForUpdatesAction() { - onCheckForUpdates() - } + private func enforceSingleInstance() { + guard let bundleId = Bundle.main.bundleIdentifier else { return } + let currentPid = ProcessInfo.processInfo.processIdentifier - @objc private func preferencesAction() { - onOpenPreferences() + for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) { + guard app.processIdentifier != currentPid else { continue } + app.terminate() + if !app.isTerminated { + _ = app.forceTerminate() + } + } } - @objc private func quitAction() { - onQuitApp() - } -} + private func observeDuplicateLaunches() { + guard let bundleId = Bundle.main.bundleIdentifier else { return } + let embeddedCLIURL = Bundle.main.bundleURL + .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) + .standardizedFileURL + .resolvingSymlinksInPath() + let currentPid = ProcessInfo.processInfo.processIdentifier -private final class NotificationMenuItemPayload: NSObject { - let notification: TerminalNotification + workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didLaunchApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard self != nil else { return } + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return } + guard app.bundleIdentifier == bundleId, app.processIdentifier != currentPid else { return } + if let executableURL = app.executableURL? + .standardizedFileURL + .resolvingSymlinksInPath(), + executableURL == embeddedCLIURL { + return + } - init(notification: TerminalNotification) { - self.notification = notification - super.init() + app.terminate() + if !app.isTerminated { + _ = app.forceTerminate() + } + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + } } -} - -struct NotificationMenuSnapshot { - let unreadCount: Int - let hasNotifications: Bool - let recentNotifications: [TerminalNotification] - var hasUnreadNotifications: Bool { - unreadCount > 0 + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + handleNotificationResponse(response) + completionHandler() } - var stateHintTitle: String { - NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: unreadCount) + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + var options: UNNotificationPresentationOptions = [.banner, .list] + if notification.request.content.sound != nil { + options.insert(.sound) + } + completionHandler(options) } -} -enum NotificationMenuSnapshotBuilder { - static let defaultInlineNotificationLimit = 6 + private func handleNotificationResponse(_ response: UNNotificationResponse) { + guard let tabIdString = response.notification.request.content.userInfo["tabId"] as? String, + let tabId = UUID(uuidString: tabIdString) else { + return + } + let surfaceId: UUID? = { + guard let surfaceIdString = response.notification.request.content.userInfo["surfaceId"] as? String else { + return nil + } + return UUID(uuidString: surfaceIdString) + }() - static func make( - notifications: [TerminalNotification], - maxInlineNotificationItems: Int = defaultInlineNotificationLimit - ) -> NotificationMenuSnapshot { - let unreadCount = notifications.reduce(into: 0) { count, notification in - if !notification.isRead { - count += 1 + switch response.actionIdentifier { + case UNNotificationDefaultActionIdentifier, TerminalNotificationStore.actionShowIdentifier: + let notificationId: UUID? = { + if let id = UUID(uuidString: response.notification.request.identifier) { + return id + } + if let idString = response.notification.request.content.userInfo["notificationId"] as? String, + let id = UUID(uuidString: idString) { + return id + } + return nil + }() + DispatchQueue.main.async { + _ = self.openNotification(tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) + } + case UNNotificationDismissActionIdentifier: + DispatchQueue.main.async { + if let notificationId = UUID(uuidString: response.notification.request.identifier) { + self.notificationStore?.markRead(id: notificationId) + } else if let notificationIdString = response.notification.request.content.userInfo["notificationId"] as? String, + let notificationId = UUID(uuidString: notificationIdString) { + self.notificationStore?.markRead(id: notificationId) + } } - } - - let inlineLimit = max(0, maxInlineNotificationItems) - return NotificationMenuSnapshot( - unreadCount: unreadCount, - hasNotifications: !notifications.isEmpty, - recentNotifications: Array(notifications.prefix(inlineLimit)) - ) - } - - static func stateHintTitle(unreadCount: Int) -> String { - switch unreadCount { - case 0: - return String(localized: "statusMenu.noUnread", defaultValue: "No unread notifications") - case 1: - return String(localized: "statusMenu.unreadCount.one", defaultValue: "1 unread notification") default: - return String(localized: "statusMenu.unreadCount.other", defaultValue: "\(unreadCount) unread notifications") + break } } -} -enum MenuBarBadgeLabelFormatter { - static func badgeText(for unreadCount: Int) -> String? { - guard unreadCount > 0 else { return nil } - if unreadCount > 9 { - return "9+" + private func installMainWindowKeyObserver() { + guard windowKeyObserver == nil else { return } + windowKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: nil, + queue: .main + ) { [weak self] note in + guard let self, let window = note.object as? NSWindow else { return } + self.setActiveMainWindow(window) } - return String(unreadCount) } -} - -enum MenuBarNotificationLineFormatter { - static let defaultMaxMenuTextWidth: CGFloat = 280 - static let defaultMaxMenuTextLines = 3 - static func plainTitle(notification: TerminalNotification, tabTitle: String?) -> String { - let dot = notification.isRead ? " " : "● " - let timeText = notification.createdAt.formatted(date: .omitted, time: .shortened) - var lines: [String] = [] - lines.append("\(dot)\(notification.title) \(timeText)") + private func installBrowserAddressBarFocusObservers() { + guard browserAddressBarFocusObserver == nil, browserAddressBarBlurObserver == nil else { return } - let detail = notification.body.isEmpty ? notification.subtitle : notification.body - if !detail.isEmpty { - lines.append(detail) + browserAddressBarFocusObserver = NotificationCenter.default.addObserver( + forName: .browserDidFocusAddressBar, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + guard let panelId = notification.object as? UUID else { return } + self.browserPanel(for: panelId)?.beginSuppressWebViewFocusForAddressBar() + self.browserAddressBarFocusedPanelId = panelId + self.stopBrowserOmnibarSelectionRepeat() +#if DEBUG + dlog("addressBar FOCUS panelId=\(panelId.uuidString.prefix(8))") +#endif } - if let tabTitle, !tabTitle.isEmpty { - lines.append(tabTitle) + browserAddressBarBlurObserver = NotificationCenter.default.addObserver( + forName: .browserDidBlurAddressBar, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + guard let panelId = notification.object as? UUID else { return } + self.browserPanel(for: panelId)?.endSuppressWebViewFocusForAddressBar() + if self.browserAddressBarFocusedPanelId == panelId { + self.browserAddressBarFocusedPanelId = nil + self.stopBrowserOmnibarSelectionRepeat() +#if DEBUG + dlog("addressBar BLUR panelId=\(panelId.uuidString.prefix(8))") +#endif + } } - - return lines.joined(separator: "\n") } - static func menuTitle( - notification: TerminalNotification, - tabTitle: String?, - maxWidth: CGFloat = defaultMaxMenuTextWidth, - maxLines: Int = defaultMaxMenuTextLines - ) -> String { - let base = plainTitle(notification: notification, tabTitle: tabTitle) - return wrappedAndTruncated(base, maxWidth: maxWidth, maxLines: maxLines) - } - - static func attributedTitle(notification: TerminalNotification, tabTitle: String?) -> NSAttributedString { - let paragraph = NSMutableParagraphStyle() - paragraph.lineBreakMode = .byWordWrapping - return NSAttributedString( - string: menuTitle(notification: notification, tabTitle: tabTitle), - attributes: [ - .font: NSFont.menuFont(ofSize: NSFont.systemFontSize), - .foregroundColor: NSColor.labelColor, - .paragraphStyle: paragraph, - ] - ) + private func browserPanel(for panelId: UUID) -> BrowserPanel? { + return tabManager?.selectedWorkspace?.browserPanel(for: panelId) } - static func tooltip(notification: TerminalNotification, tabTitle: String?) -> String { - plainTitle(notification: notification, tabTitle: tabTitle) + fileprivate func browserFindBarIsVisible(for webView: ProgramaWebView) -> Bool { + browserPanelOwning(webView)?.searchState != nil } - private static func wrappedAndTruncated(_ text: String, maxWidth: CGFloat, maxLines: Int) -> String { - let width = max(60, maxWidth) - let lines = max(1, maxLines) - let font = NSFont.menuFont(ofSize: NSFont.systemFontSize) - let wrapped = wrappedLines(for: text, maxWidth: width, font: font) - guard wrapped.count > lines else { return wrapped.joined(separator: "\n") } - - var clipped = Array(wrapped.prefix(lines)) - clipped[lines - 1] = truncateLine(clipped[lines - 1], maxWidth: width, font: font) - return clipped.joined(separator: "\n") + private func shouldLetFocusedBrowserOwnFindShortcut(_ event: NSEvent) -> Bool { + let shortcutWindow = resolvedShortcutEventWindow(event) ?? NSApp.keyWindow ?? NSApp.mainWindow + let shortcutResponder = shortcutWindow?.firstResponder + let owningWebView = tabManager?.focusedBrowserPanel?.webView as? ProgramaWebView + guard let owningWebView else { return false } + return shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( + event, + responder: shortcutResponder, + owningWebView: owningWebView + ) } - private static func wrappedLines(for text: String, maxWidth: CGFloat, font: NSFont) -> [String] { - let storage = NSTextStorage(string: text, attributes: [.font: font]) - let layout = NSLayoutManager() - let container = NSTextContainer(size: NSSize(width: maxWidth, height: .greatestFiniteMagnitude)) - container.lineFragmentPadding = 0 - container.lineBreakMode = .byWordWrapping - layout.addTextContainer(container) - storage.addLayoutManager(layout) - _ = layout.glyphRange(for: container) - - let fullText = text as NSString - var rows: [String] = [] - var glyphIndex = 0 - while glyphIndex < layout.numberOfGlyphs { - var glyphRange = NSRange() - layout.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: &glyphRange) - if glyphRange.length == 0 { break } + private func browserPanelOwning(_ webView: ProgramaWebView) -> BrowserPanel? { + var candidateManagers: [TabManager] = [] + var seenManagers = Set() - let charRange = layout.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) - let row = fullText.substring(with: charRange).trimmingCharacters(in: .newlines) - rows.append(row) - glyphIndex = NSMaxRange(glyphRange) + func appendCandidate(_ manager: TabManager?) { + guard let manager else { return } + let identifier = ObjectIdentifier(manager) + guard seenManagers.insert(identifier).inserted else { return } + candidateManagers.append(manager) } - if rows.isEmpty { - return [text] + if let window = webView.window, + let context = contextForMainWindow(window) { + appendCandidate(context.tabManager) } - return rows - } - - private static func truncateLine(_ line: String, maxWidth: CGFloat, font: NSFont) -> String { - let ellipsis = "…" - let full = line.trimmingCharacters(in: .whitespacesAndNewlines) - if full.isEmpty { return ellipsis } - - if measuredWidth(full + ellipsis, font: font) <= maxWidth { - return full + ellipsis + appendCandidate(tabManager) + for context in mainWindowContexts.values { + appendCandidate(context.tabManager) } - var chars = Array(full) - while !chars.isEmpty { - chars.removeLast() - let candidateBase = String(chars).trimmingCharacters(in: .whitespacesAndNewlines) - let candidate = (candidateBase.isEmpty ? "" : candidateBase) + ellipsis - if measuredWidth(candidate, font: font) <= maxWidth { - return candidate + for manager in candidateManagers { + if let panel = browserPanelOwning(webView, in: manager) { + return panel } } - return ellipsis - } - - private static func measuredWidth(_ text: String, font: NSFont) -> CGFloat { - (text as NSString).size(withAttributes: [.font: font]).width - } -} - -enum MenuBarBuildHintFormatter { - static func menuTitle( - appName: String = defaultAppName(), - isDebugBuild: Bool = _isDebugAssertConfiguration() - ) -> String? { - guard isDebugBuild else { return nil } - let normalized = appName.trimmingCharacters(in: .whitespacesAndNewlines) - let prefix = "cmux DEV" - guard normalized.hasPrefix(prefix) else { return "Build: DEV" } - - let suffix = String(normalized.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) - if suffix.isEmpty { - return "Build: DEV (untagged)" - } - return "Build Tag: \(suffix)" - } - - private static func defaultAppName() -> String { - let bundle = Bundle.main - if let displayName = bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String, - !displayName.isEmpty { - return displayName - } - if let name = bundle.object(forInfoDictionaryKey: "CFBundleName") as? String, !name.isEmpty { - return name - } - return ProcessInfo.processInfo.processName + return nil } -} -enum MenuBarExtraSettings { - static let showInMenuBarKey = "showMenuBarExtra" - static let defaultShowInMenuBar = true - - static func showsMenuBarExtra(defaults: UserDefaults = .standard) -> Bool { - if defaults.object(forKey: showInMenuBarKey) == nil { - return defaultShowInMenuBar + private func browserPanelOwning(_ webView: ProgramaWebView, in manager: TabManager) -> BrowserPanel? { + for workspace in manager.tabs { + if let panel = workspace.panels.values + .compactMap({ $0 as? BrowserPanel }) + .first(where: { $0.webView === webView }) { + return panel + } } - return defaults.bool(forKey: showInMenuBarKey) + return nil } -} -struct MenuBarBadgeRenderConfig { - var badgeRect: NSRect - var singleDigitFontSize: CGFloat - var multiDigitFontSize: CGFloat - var singleDigitYOffset: CGFloat - var multiDigitYOffset: CGFloat - var singleDigitXAdjust: CGFloat - var multiDigitXAdjust: CGFloat - var textRectWidthAdjust: CGFloat -} - -enum MenuBarIconDebugSettings { - static let previewEnabledKey = "menubarDebugPreviewEnabled" - static let previewCountKey = "menubarDebugPreviewCount" - static let badgeRectXKey = "menubarDebugBadgeRectX" - static let badgeRectYKey = "menubarDebugBadgeRectY" - static let badgeRectWidthKey = "menubarDebugBadgeRectWidth" - static let badgeRectHeightKey = "menubarDebugBadgeRectHeight" - static let singleDigitFontSizeKey = "menubarDebugSingleDigitFontSize" - static let multiDigitFontSizeKey = "menubarDebugMultiDigitFontSize" - static let singleDigitYOffsetKey = "menubarDebugSingleDigitYOffset" - static let multiDigitYOffsetKey = "menubarDebugMultiDigitYOffset" - static let singleDigitXAdjustKey = "menubarDebugSingleDigitXAdjust" - static let legacySingleDigitXAdjustKey = "menubarDebugTextRectXAdjust" - static let multiDigitXAdjustKey = "menubarDebugMultiDigitXAdjust" - static let textRectWidthAdjustKey = "menubarDebugTextRectWidthAdjust" - - static let defaultBadgeRect = NSRect(x: 5.38, y: 6.43, width: 10.75, height: 11.58) - static let defaultSingleDigitFontSize: CGFloat = 6.7 - static let defaultMultiDigitFontSize: CGFloat = 6.7 - static let defaultSingleDigitYOffset: CGFloat = 0.6 - static let defaultMultiDigitYOffset: CGFloat = 0.6 - static let defaultSingleDigitXAdjust: CGFloat = -1.1 - static let defaultMultiDigitXAdjust: CGFloat = 2.42 - static let defaultTextRectWidthAdjust: CGFloat = 1.8 - - static func displayedUnreadCount(actualUnreadCount: Int, defaults: UserDefaults = .standard) -> Int { - guard defaults.bool(forKey: previewEnabledKey) else { return actualUnreadCount } - let value = defaults.integer(forKey: previewCountKey) - return max(0, min(value, 99)) - } - - static func badgeRenderConfig(defaults: UserDefaults = .standard) -> MenuBarBadgeRenderConfig { - let x = value(defaults, key: badgeRectXKey, fallback: defaultBadgeRect.origin.x, range: 0...20) - let y = value(defaults, key: badgeRectYKey, fallback: defaultBadgeRect.origin.y, range: 0...20) - let width = value(defaults, key: badgeRectWidthKey, fallback: defaultBadgeRect.width, range: 4...14) - let height = value(defaults, key: badgeRectHeightKey, fallback: defaultBadgeRect.height, range: 4...14) - let singleFont = value(defaults, key: singleDigitFontSizeKey, fallback: defaultSingleDigitFontSize, range: 6...14) - let multiFont = value(defaults, key: multiDigitFontSizeKey, fallback: defaultMultiDigitFontSize, range: 6...14) - let singleY = value(defaults, key: singleDigitYOffsetKey, fallback: defaultSingleDigitYOffset, range: -3...4) - let multiY = value(defaults, key: multiDigitYOffsetKey, fallback: defaultMultiDigitYOffset, range: -3...4) - let singleX = value( - defaults, - key: singleDigitXAdjustKey, - legacyKey: legacySingleDigitXAdjustKey, - fallback: defaultSingleDigitXAdjust, - range: -4...4 - ) - let multiX = value(defaults, key: multiDigitXAdjustKey, fallback: defaultMultiDigitXAdjust, range: -4...4) - let widthAdjust = value(defaults, key: textRectWidthAdjustKey, fallback: defaultTextRectWidthAdjust, range: -3...5) - - return MenuBarBadgeRenderConfig( - badgeRect: NSRect(x: x, y: y, width: width, height: height), - singleDigitFontSize: singleFont, - multiDigitFontSize: multiFont, - singleDigitYOffset: singleY, - multiDigitYOffset: multiY, - singleDigitXAdjust: singleX, - multiDigitXAdjust: multiX, - textRectWidthAdjust: widthAdjust + private func setActiveMainWindow(_ window: NSWindow) { + guard let context = contextForMainTerminalWindow(window) else { return } +#if DEBUG + let beforeManagerToken = debugManagerToken(tabManager) +#endif + tabManager = context.tabManager + sidebarState = context.sidebarState + sidebarSelectionState = context.sidebarSelectionState + TerminalController.shared.setActiveTabManager(context.tabManager) +#if DEBUG + dlog( + "mainWindow.active window={\(debugWindowToken(window))} context={\(debugContextToken(context))} beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) \(debugShortcutRouteSnapshot())" ) +#endif } - static func copyPayload(defaults: UserDefaults = .standard) -> String { - let config = badgeRenderConfig(defaults: defaults) - let previewEnabled = defaults.bool(forKey: previewEnabledKey) - let previewCount = max(0, min(defaults.integer(forKey: previewCountKey), 99)) - return """ - menubarDebugPreviewEnabled=\(previewEnabled) - menubarDebugPreviewCount=\(previewCount) - menubarDebugBadgeRectX=\(String(format: "%.2f", config.badgeRect.origin.x)) - menubarDebugBadgeRectY=\(String(format: "%.2f", config.badgeRect.origin.y)) - menubarDebugBadgeRectWidth=\(String(format: "%.2f", config.badgeRect.width)) - menubarDebugBadgeRectHeight=\(String(format: "%.2f", config.badgeRect.height)) - menubarDebugSingleDigitFontSize=\(String(format: "%.2f", config.singleDigitFontSize)) - menubarDebugMultiDigitFontSize=\(String(format: "%.2f", config.multiDigitFontSize)) - menubarDebugSingleDigitYOffset=\(String(format: "%.2f", config.singleDigitYOffset)) - menubarDebugMultiDigitYOffset=\(String(format: "%.2f", config.multiDigitYOffset)) - menubarDebugSingleDigitXAdjust=\(String(format: "%.2f", config.singleDigitXAdjust)) - menubarDebugMultiDigitXAdjust=\(String(format: "%.2f", config.multiDigitXAdjust)) - menubarDebugTextRectWidthAdjust=\(String(format: "%.2f", config.textRectWidthAdjust)) - """ - } - - private static func value( - _ defaults: UserDefaults, - key: String, - legacyKey: String? = nil, - fallback: CGFloat, - range: ClosedRange - ) -> CGFloat { - if let parsed = parse(defaults.object(forKey: key), fallback: fallback, range: range) { - return parsed - } - if let legacyKey, let parsed = parse(defaults.object(forKey: legacyKey), fallback: fallback, range: range) { - return parsed - } - return fallback - } - - private static func parse( - _ object: Any?, - fallback: CGFloat, - range: ClosedRange - ) -> CGFloat? { - guard let number = object as? NSNumber else { - return nil - } - let candidate = CGFloat(number.doubleValue) - guard candidate.isFinite else { return fallback } - return max(range.lowerBound, min(candidate, range.upperBound)) - } -} - -enum MenuBarIconRenderer { - - static func makeImage(unreadCount: Int) -> NSImage { - let badgeText = MenuBarBadgeLabelFormatter.badgeText(for: unreadCount) - let config = MenuBarIconDebugSettings.badgeRenderConfig() - let size = NSSize(width: 18, height: 18) - let image = NSImage(size: size) - image.lockFocus() - defer { image.unlockFocus() } + private func unregisterMainWindow(_ window: NSWindow) { + // Reset cascade point so the next new window appears near the closing + // window's position, matching upstream Ghostty behavior. + let frame = window.frame + lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY) - let glyphRect = NSRect(x: 1.2, y: 1.5, width: 11.6, height: 15.0) - drawGlyph(in: glyphRect) + // Keep geometry available as a fallback even if the full session snapshot + // is removed when the last window closes. + persistWindowGeometry(from: window) + guard let removed = unregisterMainWindowContext(for: window) else { return } + commandPaletteVisibilityByWindowId.removeValue(forKey: removed.windowId) + commandPalettePendingOpenByWindowId.removeValue(forKey: removed.windowId) + commandPaletteRecentRequestAtByWindowId.removeValue(forKey: removed.windowId) + commandPaletteEscapeSuppressionByWindowId.remove(removed.windowId) + commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: removed.windowId) + commandPaletteSelectionByWindowId.removeValue(forKey: removed.windowId) + commandPaletteSnapshotByWindowId.removeValue(forKey: removed.windowId) - if let text = badgeText { - drawBadge(text: text, in: config.badgeRect, config: config) + // Avoid stale notifications that can no longer be opened once the owning window is gone. + if let store = notificationStore { + for tab in removed.tabManager.tabs { + store.clearNotifications(forTabId: tab.id) + } } - image.isTemplate = true - return image - } + if tabManager === removed.tabManager { + // Repoint "active" pointers to any remaining main terminal window. + let nextContext: MainWindowContext? = { + if let keyWindow = NSApp.keyWindow, + let ctx = contextForMainTerminalWindow(keyWindow, reindex: false) { + return ctx + } + return mainWindowContexts.values.first + }() - private static func drawGlyph(in rect: NSRect) { - // Match the canonical cmux center-mark path from Icon Center Image Artwork.svg. - let srcMinX: CGFloat = 384.0 - let srcMinY: CGFloat = 255.0 - let srcWidth: CGFloat = 369.0 - let srcHeight: CGFloat = 513.0 + if let nextContext { + tabManager = nextContext.tabManager + sidebarState = nextContext.sidebarState + sidebarSelectionState = nextContext.sidebarSelectionState + TerminalController.shared.setActiveTabManager(nextContext.tabManager) + } else { + tabManager = nil + sidebarState = nil + sidebarSelectionState = nil + TerminalController.shared.setActiveTabManager(nil) + } + } - func map(_ x: CGFloat, _ y: CGFloat) -> NSPoint { - let nx = (x - srcMinX) / srcWidth - let ny = (y - srcMinY) / srcHeight - return NSPoint( - x: rect.minX + nx * rect.width, - y: rect.minY + (1.0 - ny) * rect.height + // During app termination we already persisted a full snapshot (with scrollback) + // in applicationShouldTerminate/applicationWillTerminate. Saving again here would + // overwrite it as windows tear down one-by-one, dropping closed windows and replay. + if Self.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: isTerminatingApp) { + _ = saveSessionSnapshot( + includeScrollback: false, + removeWhenEmpty: Self.shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister( + isTerminatingApp: isTerminatingApp + ) ) } - - let path = NSBezierPath() - path.move(to: map(384.0, 255.0)) - path.line(to: map(753.0, 511.5)) - path.line(to: map(384.0, 768.0)) - path.line(to: map(384.0, 654.0)) - path.line(to: map(582.692, 511.5)) - path.line(to: map(384.0, 369.0)) - path.close() - - NSColor.black.setFill() - path.fill() } - private static func drawBadge(text: String, in rect: NSRect, config: MenuBarBadgeRenderConfig) { - let paragraph = NSMutableParagraphStyle() - paragraph.alignment = .center - let fontSize: CGFloat = text.count > 1 ? config.multiDigitFontSize : config.singleDigitFontSize - let attrs: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: fontSize, weight: .bold), - .foregroundColor: NSColor.systemBlue, - .paragraphStyle: paragraph, - ] - let yOffset: CGFloat = text.count > 1 ? config.multiDigitYOffset : config.singleDigitYOffset - let xAdjust: CGFloat = text.count > 1 ? config.multiDigitXAdjust : config.singleDigitXAdjust - let textRect = NSRect( - x: rect.origin.x + xAdjust, - y: rect.origin.y + yOffset, - width: rect.width + config.textRectWidthAdjust, - height: rect.height - ) - (text as NSString).draw(in: textRect, withAttributes: attrs) + private func isMainTerminalWindow(_ window: NSWindow) -> Bool { + if mainWindowContexts[ObjectIdentifier(window)] != nil { + return true + } + guard let raw = window.identifier?.rawValue else { return false } + return raw == "cmux.main" || raw.hasPrefix("cmux.main.") } -} - -#if DEBUG -private var programaFirstResponderGuardCurrentEventOverride: NSEvent? -private var programaFirstResponderGuardHitViewOverride: NSView? -#endif -private var programaFirstResponderGuardCurrentEventContext: NSEvent? -private var programaFirstResponderGuardHitViewContext: NSView? -private var programaFirstResponderGuardContextWindowNumber: Int? -private var programaBrowserReturnForwardingDepth = 0 -private var programaWindowFirstResponderBypassDepth = 0 -private var programaFieldEditorOwningWebViewAssociationKey: UInt8 = 0 - -@discardableResult -func cmuxWithWindowFirstResponderBypass(_ body: () -> T) -> T { - programaWindowFirstResponderBypassDepth += 1 - defer { - programaWindowFirstResponderBypassDepth = max(0, programaWindowFirstResponderBypassDepth - 1) + private func contextContainingTabId(_ tabId: UUID) -> MainWindowContext? { + for context in mainWindowContexts.values { + if context.tabManager.tabs.contains(where: { $0.id == tabId }) { + return context + } + } + return nil } - return body() -} -func programaIsWindowFirstResponderBypassActive() -> Bool { - programaWindowFirstResponderBypassDepth > 0 -} - -private final class ProgramaFieldEditorOwningWebViewBox: NSObject { - weak var webView: ProgramaWebView? - - init(webView: ProgramaWebView?) { - self.webView = webView + /// Returns the `TabManager` that owns `tabId`, if any. + func tabManagerFor(tabId: UUID) -> TabManager? { + contextContainingTabId(tabId)?.tabManager } -} -private extension NSApplication { - @objc func programa_applicationSendEvent(_ event: NSEvent) { -#if DEBUG - let typingTimingStart = event.type == .keyDown ? ProgramaTypingTiming.start() : nil - let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 - if event.type == .keyDown { - ProgramaTypingTiming.logEventDelay(path: "app.sendEvent", event: event) - } - defer { - if event.type == .keyDown { - let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 - ProgramaTypingTiming.logBreakdown( - path: "app.sendEvent.phase", - totalMs: totalMs, - event: event, - thresholdMs: 1.0, - parts: [("dispatchMs", totalMs)] - ) - ProgramaTypingTiming.logDuration( - path: "app.sendEvent", - startedAt: typingTimingStart, - event: event - ) - } - } -#endif - programa_applicationSendEvent(event) + private func workspaceForMainActor(tabId: UUID) -> Workspace? { + contextContainingTabId(tabId)?.tabManager.tabs.first(where: { $0.id == tabId }) } -} -private extension AppDelegate { - @objc func handleThemesReloadNotification(_ notification: Notification) { - DispatchQueue.main.async { - GhosttyApp.shared.reloadConfiguration(source: "distributed.cmux.themes") - } + /// Returns the `Workspace` that owns `tabId`, if any. + @MainActor + func workspaceFor(tabId: UUID) -> Workspace? { + workspaceForMainActor(tabId: tabId) } -} - -private extension NSWindow { - @objc func programa_makeFirstResponder(_ responder: NSResponder?) -> Bool { - if programaIsWindowFirstResponderBypassActive() { -#if DEBUG - dlog( - "focus.guard bypassFirstResponder responder=\(String(describing: responder.map { type(of: $0) })) " + - "window=\(ObjectIdentifier(self))" - ) -#endif - return false - } - let currentEvent = Self.programaCurrentEvent(for: self) - let responderWebView = responder.flatMap { - Self.programaOwningWebView(for: $0, in: self, event: currentEvent) - } - var pointerInitiatedWebFocus = false + func closeMainWindowContainingTabId(_ tabId: UUID) { + guard let context = contextContainingTabId(tabId) else { return } + let expectedIdentifier = "cmux.main.\(context.windowId.uuidString)" + let window: NSWindow? = context.window ?? NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) + window?.performClose(nil) + } - if AppDelegate.shared?.shouldBlockFirstResponderChangeWhileCommandPaletteVisible( - window: self, - responder: responder - ) == true { + @discardableResult + func openNotification(tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { #if DEBUG - dlog( - "focus.guard commandPaletteBlocked responder=\(String(describing: responder.map { type(of: $0) })) " + - "window=\(ObjectIdentifier(self))" - ) -#endif - return false + let isJumpUnreadUITest = ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" + if isJumpUnreadUITest { + writeJumpUnreadTestData([ + "jumpUnreadOpenCalled": "1", + "jumpUnreadOpenTabId": tabId.uuidString, + "jumpUnreadOpenSurfaceId": surfaceId?.uuidString ?? "", + ]) } - - if let responder, - let webView = responderWebView, - !webView.allowsFirstResponderAcquisitionEffective { - let pointerInitiatedFocus = Self.programaShouldAllowPointerInitiatedWebViewFocus( - window: self, - webView: webView, - event: currentEvent - ) - if pointerInitiatedFocus { - pointerInitiatedWebFocus = true -#if DEBUG - dlog( - "focus.guard allowPointerFirstResponder responder=\(String(describing: type(of: responder))) " + - "window=\(ObjectIdentifier(self)) " + - "web=\(ObjectIdentifier(webView)) " + - "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + - "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + - "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" - ) -#endif - } else { -#if DEBUG - dlog( - "focus.guard blockedFirstResponder responder=\(String(describing: type(of: responder))) " + - "window=\(ObjectIdentifier(self)) " + - "web=\(ObjectIdentifier(webView)) " + - "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + - "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + - "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" - ) #endif - return false - } - } + guard let context = contextContainingTabId(tabId) else { #if DEBUG - if let responder, - let webView = responderWebView { - dlog( - "focus.guard allowFirstResponder responder=\(String(describing: type(of: responder))) " + - "window=\(ObjectIdentifier(self)) " + - "web=\(ObjectIdentifier(webView)) " + - "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + - "pointerDepth=\(webView.debugPointerFocusAllowanceDepth)" + recordMultiWindowNotificationOpenFailureIfNeeded( + tabId: tabId, + surfaceId: surfaceId, + notificationId: notificationId, + reason: "missing_context" ) - } #endif - let result: Bool - if pointerInitiatedWebFocus, let webView = responderWebView { - // `NSWindow.makeFirstResponder` may run before `ProgramaWebView.mouseDown(with:)`. - // Preserve pointer intent during this synchronous responder change. - result = webView.withPointerFocusAllowance { - programa_makeFirstResponder(responder) - } - } else { - result = programa_makeFirstResponder(responder) - } - if result { - if let fieldEditor = responder as? NSTextView, fieldEditor.isFieldEditor { - Self.programaTrackFieldEditor(fieldEditor, owningWebView: responderWebView) - } else if let fieldEditor = self.firstResponder as? NSTextView, fieldEditor.isFieldEditor { - Self.programaTrackFieldEditor(fieldEditor, owningWebView: responderWebView) - } - } - return result - } - - @objc func programa_sendEvent(_ event: NSEvent) { #if DEBUG - let typingTimingStart = event.type == .keyDown ? ProgramaTypingTiming.start() : nil - let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 - var contextSetupMs: Double = 0 - var focusRepairMs: 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.programaOwningWebView(for: $0, in: self, event: event) - } - let hitWebView = Self.programaHitViewForEventDispatch(in: self, event: event).flatMap { - Self.programaOwningWebView(for: $0) + if isJumpUnreadUITest { + writeJumpUnreadTestData(["jumpUnreadOpenContextFound": "0", "jumpUnreadOpenUsedFallback": "1"]) } - 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 { - ProgramaTypingTiming.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() - } + let ok = openNotificationFallback(tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) #if DEBUG - defer { - if event.type == .keyDown { - let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 - ProgramaTypingTiming.logBreakdown( - path: "window.sendEvent.phase", - totalMs: totalMs, - event: event, - thresholdMs: 1.0, - parts: [ - ("contextSetupMs", contextSetupMs), - ("focusRepairMs", focusRepairMs), - ("folderGuardMs", folderGuardMs), - ("originalDispatchMs", originalDispatchMs), - ], - extra: typingTimingExtra - ) - ProgramaTypingTiming.logDuration( - path: "window.sendEvent", - startedAt: typingTimingStart, - event: event, - extra: typingTimingExtra - ) + if isJumpUnreadUITest { + writeJumpUnreadTestData(["jumpUnreadOpenResult": ok ? "1" : "0"]) } - } - let contextSetupStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 #endif - let previousContextEvent = programaFirstResponderGuardCurrentEventContext - let previousContextHitView = programaFirstResponderGuardHitViewContext - let previousContextWindowNumber = programaFirstResponderGuardContextWindowNumber - programaFirstResponderGuardCurrentEventContext = event - programaFirstResponderGuardHitViewContext = Self.programaHitViewForEventDispatch(in: self, event: event) - programaFirstResponderGuardContextWindowNumber = self.windowNumber + return ok + } #if DEBUG - if event.type == .keyDown { - contextSetupMs = (ProcessInfo.processInfo.systemUptime - contextSetupStart) * 1000.0 + if isJumpUnreadUITest { + writeJumpUnreadTestData(["jumpUnreadOpenContextFound": "1", "jumpUnreadOpenUsedFallback": "0"]) } - let focusRepairStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 #endif - if event.type == .keyDown { - AppDelegate.shared?.repairFocusedTerminalKeyboardRoutingIfNeeded( - window: self, - event: event - ) - } + return openNotificationInContext(context, tabId: tabId, surfaceId: surfaceId, notificationId: notificationId) + } + + private func openNotificationInContext(_ context: MainWindowContext, tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { + let expectedIdentifier = "cmux.main.\(context.windowId.uuidString)" + let window: NSWindow? = context.window ?? NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) + guard let window else { #if DEBUG - if event.type == .keyDown { - focusRepairMs = (ProcessInfo.processInfo.systemUptime - focusRepairStart) * 1000.0 - } - let folderGuardStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 + recordMultiWindowNotificationOpenFailureIfNeeded( + tabId: tabId, + surfaceId: surfaceId, + notificationId: notificationId, + reason: "missing_window expectedIdentifier=\(expectedIdentifier)" + ) #endif - defer { - programaFirstResponderGuardCurrentEventContext = previousContextEvent - programaFirstResponderGuardHitViewContext = previousContextHitView - programaFirstResponderGuardContextWindowNumber = previousContextWindowNumber + return false } - guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event), - let contentView = self.contentView else { + context.sidebarSelectionState.selection = .tabs + bringToFront(window) + guard context.tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else { #if DEBUG - if event.type == .keyDown { - folderGuardMs = (ProcessInfo.processInfo.systemUptime - folderGuardStart) * 1000.0 - let originalDispatchStart = ProcessInfo.processInfo.systemUptime - programa_sendEvent(event) - originalDispatchMs = (ProcessInfo.processInfo.systemUptime - originalDispatchStart) * 1000.0 - return + recordMultiWindowNotificationOpenFailureIfNeeded( + tabId: tabId, + surfaceId: surfaceId, + notificationId: notificationId, + reason: "focus_failed" + ) + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadOpenResult": "0"]) } #endif - programa_sendEvent(event) - return + return false } + #if DEBUG - if event.type == .keyDown { - folderGuardMs = (ProcessInfo.processInfo.systemUptime - folderGuardStart) * 1000.0 - } - let originalDispatchStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 + // UI test support: Jump-to-unread asserts that the correct workspace/panel is focused. + // Recording via first-responder can be flaky on the VM, so verify focus via the model. + recordJumpUnreadFocusFromModelIfNeeded( + tabManager: context.tabManager, + tabId: tabId, + expectedSurfaceId: surfaceId + ) #endif - let contentPoint = contentView.convert(event.locationInWindow, from: nil) - let hitView = contentView.hitTest(contentPoint) - let previousMovableState = isMovable - if previousMovableState { - isMovable = false + if let notificationId, let store = notificationStore { + markReadIfFocused( + notificationId: notificationId, + tabId: tabId, + surfaceId: surfaceId, + tabManager: context.tabManager, + notificationStore: store + ) } - #if DEBUG - let hitDesc = hitView.map { String(describing: type(of: $0)) } ?? "nil" - dlog("window.sendEvent.folderDown suppress=1 hit=\(hitDesc) wasMovable=\(previousMovableState)") - #endif - - programa_sendEvent(event) #if DEBUG - if event.type == .keyDown { - originalDispatchMs = (ProcessInfo.processInfo.systemUptime - originalDispatchStart) * 1000.0 + recordMultiWindowNotificationFocusIfNeeded( + windowId: context.windowId, + tabId: tabId, + surfaceId: surfaceId, + sidebarSelection: context.sidebarSelectionState.selection + ) + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadOpenInContext": "1", "jumpUnreadOpenResult": "1"]) } #endif - - if previousMovableState { - isMovable = previousMovableState - } - - #if DEBUG - dlog("window.sendEvent.folderDown restore nowMovable=\(isMovable)") - #endif + return true } - @objc func programa_performKeyEquivalent(with event: NSEvent) -> Bool { -#if DEBUG - let typingTimingStart = ProgramaTypingTiming.start() - defer { - ProgramaTypingTiming.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 - - // When the terminal surface is the first responder, prevent SwiftUI's - // hosting view from consuming key events via performKeyEquivalent. - // After a browser panel (WKWebView) has been in the responder chain, - // SwiftUI's internal focus system can get into a broken state where it - // intercepts key events in the content view hierarchy, returns true - // (claiming consumption), but never actually fires the action closure. - // - // For non-Command keys: bypass the view hierarchy entirely and send - // directly to the terminal so arrow keys, Ctrl+N/P, etc. reach keyDown. - // - // For Command keys: bypass the SwiftUI content view hierarchy and - // dispatch directly to the main menu. No SwiftUI view should be handling - // Command shortcuts when the terminal is focused — the local event monitor - // (handleCustomShortcut) already handles app-level shortcuts, and anything - // remaining should be menu items. - let firstResponderGhosttyView = cmuxOwningGhosttyView(for: self.firstResponder) - let firstResponderWebView = self.firstResponder.flatMap { - Self.programaOwningWebView(for: $0, in: self, event: event) - } - let firstResponderHasMarkedText = browserResponderHasMarkedText(self.firstResponder) - if let ghosttyView = firstResponderGhosttyView { - // If the IME is composing and the key has no Cmd modifier, don't intercept — - // let it flow through normal AppKit event dispatch so the input method can - // process it. Cmd-based shortcuts should still work during composition since - // Cmd is never part of IME input sequences. - if ghosttyView.hasMarkedText(), !event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) { - return programa_performKeyEquivalent(with: event) - } - - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if !flags.contains(.command) { - let result = ghosttyView.performKeyEquivalent(with: event) + private func openNotificationFallback(tabId: UUID, surfaceId: UUID?, notificationId: UUID?) -> Bool { + // If the owning window context hasn't been registered yet, fall back to the "active" window. + guard let tabManager else { #if DEBUG - dlog(" → ghostty direct: \(result)") -#endif - return result + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadFallbackFail": "missing_tabManager"]) } - - // Preserve Ghostty's terminal font-size shortcuts (Cmd +/−/0) when - // the terminal is focused. Otherwise our browser menu shortcuts can - // consume the event even when no browser panel is focused. - if shouldRouteTerminalFontZoomShortcutToGhostty( - firstResponderIsGhostty: true, - flags: event.modifierFlags, - chars: event.charactersIgnoringModifiers ?? "", - keyCode: event.keyCode, - literalChars: event.characters - ) { - ghosttyView.keyDown(with: event) -#if DEBUG - dlog("zoom.shortcut stage=window.ghosttyKeyDownDirect event=\(Self.keyDescription(event)) handled=1") #endif - return true - } + return false } - - // Web forms rely on Return/Enter flowing through keyDown. If the original - // NSWindow.performKeyEquivalent consumes Enter first, submission never reaches - // WebKit. Route Return/Enter directly to the current first responder and - // mark handled to avoid the AppKit alert sound path. - if shouldDispatchBrowserReturnViaFirstResponderKeyDown( - keyCode: event.keyCode, - firstResponderIsBrowser: firstResponderWebView != nil, - firstResponderHasMarkedText: firstResponderHasMarkedText, - flags: event.modifierFlags - ) { - // Forwarding keyDown can re-enter performKeyEquivalent in WebKit/AppKit internals. - // On re-entry, fall back to normal dispatch to avoid an infinite loop. - if programaBrowserReturnForwardingDepth > 0 { + guard tabManager.tabs.contains(where: { $0.id == tabId }) else { #if DEBUG - dlog(" → browser Return/Enter reentry; using normal dispatch") -#endif - return false + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadFallbackFail": "tab_not_in_active_manager"]) } - programaBrowserReturnForwardingDepth += 1 - defer { programaBrowserReturnForwardingDepth = max(0, programaBrowserReturnForwardingDepth - 1) } -#if DEBUG - dlog(" → browser Return/Enter routed to firstResponder.keyDown") #endif - self.firstResponder?.keyDown(with: event) - return true + return false } - - if let firstResponderWebView, - shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( - event, - responder: self.firstResponder, - owningWebView: firstResponderWebView - ) { - let result = firstResponderWebView.performKeyEquivalent(with: event) + guard let window = (NSApp.keyWindow ?? NSApp.windows.first(where: { isMainTerminalWindow($0) })) else { #if DEBUG - if result { - dlog(" → browser find command resolved before window menu path") - } else { - dlog(" → browser find command preflight left unclaimed; suppressing replay") + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadFallbackFail": "missing_window"]) } #endif - // The focused web view has already received this Find-family shortcut once. - // Do not fall through into the original NSWindow.performKeyEquivalent path, - // or WebKit can observe the same key equivalent a second time before AppKit - // reaches keyDown/menu fallback. - return true - } - - if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { -#if DEBUG - dlog(" → consumed by handleBrowserSurfaceKeyEquivalent") -#endif - return true + return false } - // When the terminal is focused, skip the full NSWindow.performKeyEquivalent - // (which walks the SwiftUI content view hierarchy) and dispatch Command-key - // events directly to the main menu. This avoids the broken SwiftUI focus path. - if firstResponderGhosttyView != nil, - shouldRouteCommandEquivalentDirectlyToMainMenu(event), - let mainMenu = NSApp.mainMenu { - let consumedByMenu = mainMenu.performKeyEquivalent(with: event) + sidebarSelectionState?.selection = .tabs + bringToFront(window) + guard tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else { #if DEBUG - if browserZoomShortcutTraceCandidate( - flags: event.modifierFlags, - chars: event.charactersIgnoringModifiers ?? "", - keyCode: event.keyCode, - literalChars: event.characters - ) { - dlog( - "zoom.shortcut stage=window.mainMenuBypass event=\(Self.keyDescription(event)) " + - "consumed=\(consumedByMenu ? 1 : 0) fr=GhosttyNSView" - ) + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData([ + "jumpUnreadFallbackFail": "focus_failed", + "jumpUnreadOpenResult": "0", + ]) } #endif - if !consumedByMenu { - // Fall through to the original performKeyEquivalent path below. - } else { -#if DEBUG - dlog(" → consumed by mainMenu (bypassed SwiftUI)") -#endif - return true - } + return false } - let result = programa_performKeyEquivalent(with: event) #if DEBUG - if result { dlog(" → consumed by original performKeyEquivalent") } + recordJumpUnreadFocusFromModelIfNeeded( + tabManager: tabManager, + tabId: tabId, + expectedSurfaceId: surfaceId + ) #endif - return result - } - - static func keyDescription(_ event: NSEvent) -> String { - var parts: [String] = [] - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if flags.contains(.command) { parts.append("Cmd") } - if flags.contains(.shift) { parts.append("Shift") } - if flags.contains(.option) { parts.append("Opt") } - if flags.contains(.control) { parts.append("Ctrl") } - let chars = event.charactersIgnoringModifiers ?? "?" - parts.append("'\(chars)'(\(event.keyCode))") - return parts.joined(separator: "+") - } - private static func programaOwningWebView(for responder: NSResponder) -> ProgramaWebView? { - if let webView = responder as? ProgramaWebView { - return webView - } - - if let view = responder as? NSView, - let webView = programaOwningWebView(for: view) { - return webView + if let notificationId, let store = notificationStore { + markReadIfFocused( + notificationId: notificationId, + tabId: tabId, + surfaceId: surfaceId, + tabManager: tabManager, + notificationStore: store + ) } - - // NSTextView.delegate is unsafe-unretained in AppKit. Reading it here while - // a responder chain is tearing down can trap with "unowned reference". - var current = responder.nextResponder - while let next = current { - if let webView = next as? ProgramaWebView { - return webView - } - if let view = next as? NSView, - let webView = programaOwningWebView(for: view) { - return webView - } - current = next.nextResponder +#if DEBUG + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" { + writeJumpUnreadTestData(["jumpUnreadOpenInFallback": "1", "jumpUnreadOpenResult": "1"]) } - - return nil +#endif + return true } - private static func programaOwningWebView( - for responder: NSResponder, - in window: NSWindow, - event: NSEvent? - ) -> ProgramaWebView? { - // Browser find runs in the portal slot alongside the hosted WKWebView. - // Treat its native field editor chain as browser chrome, not as web content, - // so Cmd+F can move first responder into the find field while web focus is suppressed. - if BrowserWindowPortalRegistry.searchOverlayPanelId(for: responder, in: window) != nil { - return nil - } - - if let webView = programaOwningWebView(for: responder) { - return webView - } +#if DEBUG + private func recordJumpUnreadFocusFromModelIfNeeded( + tabManager: TabManager, + tabId: UUID, + expectedSurfaceId: UUID? + ) { + let env = ProcessInfo.processInfo.environment + guard env["PROGRAMA_UI_TEST_JUMP_UNREAD_SETUP"] == "1" else { return } + guard let expectedSurfaceId else { return } - guard let textView = responder as? NSTextView, textView.isFieldEditor else { - return nil - } + // Ensure the expectation is armed even if the view doesn't become first responder. + armJumpUnreadFocusRecord(tabId: tabId, surfaceId: expectedSurfaceId) - if let event, - let hitWebView = programaPointerHitWebView(in: window, event: event) { - programaTrackFieldEditor(textView, owningWebView: hitWebView) - return hitWebView + if tabManager.selectedTabId == tabId, + tabManager.focusedSurfaceId(for: tabId) == expectedSurfaceId { + recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId) + return } - return programaTrackedOwningWebView(for: textView) - } + var resolved = false + var observers: [NSObjectProtocol] = [] + var cancellables: [AnyCancellable] = [] - private static func programaOwningWebView(for view: NSView) -> ProgramaWebView? { - if let webView = view as? ProgramaWebView { - return webView + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + cancellables.forEach { $0.cancel() } + cancellables.removeAll() } - var current: NSView? = view.superview - while let candidate = current { - if let webView = candidate as? ProgramaWebView { - return webView - } - if String(describing: type(of: candidate)).contains("WindowBrowserSlotView"), - let portalWebView = programaUniqueBrowserWebView(in: candidate) { - // Portal-hosted browser chrome (for example the Cmd+F overlay) is a - // sibling of the hosted WKWebView inside WindowBrowserSlotView, not a - // descendant of it. Allow native text-entry controls in that slot to - // acquire first responder directly, but keep generic sibling views - // associated with the hosted web view so blocked browser focus policy - // still protects inspector/overlay chrome from stray focus changes. - if view === portalWebView || view.isDescendant(of: portalWebView) { - return portalWebView - } - if programaAllowsPortalSlotTextEntryFocus(view) { - return nil - } - return portalWebView + @MainActor + func finishIfFocused() { + guard !resolved else { return } + guard tabManager.selectedTabId == tabId, + tabManager.focusedSurfaceId(for: tabId) == expectedSurfaceId else { + return } - current = candidate.superview + resolved = true + cleanup() + self.recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId) } - return nil - } - - private static func programaAllowsPortalSlotTextEntryFocus(_ view: NSView) -> Bool { - var current: NSView? = view - while let candidate = current { - if let textField = candidate as? NSTextField { - return textField.isEditable || textField.acceptsFirstResponder - } - if let textView = candidate as? NSTextView { - return textView.isEditable || textView.isSelectable || textView.isFieldEditor + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { note in + guard let surfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, + surfaceId == expectedSurfaceId else { return } + Task { @MainActor in finishIfFocused() } + }) + cancellables.append(tabManager.$selectedTabId.sink { _ in + Task { @MainActor in finishIfFocused() } + }) + if let workspace = tabManager.tabs.first(where: { $0.id == tabId }) { + cancellables.append(workspace.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in finishIfFocused() } + }) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + Task { @MainActor in + guard !resolved else { return } + cleanup() } - current = candidate.superview } - return false + Task { @MainActor in finishIfFocused() } } +#endif - private static func programaUniqueBrowserWebView(in root: NSView) -> ProgramaWebView? { - var stack: [NSView] = [root] - var found: ProgramaWebView? - while let current = stack.popLast() { - if let webView = current as? ProgramaWebView { - if found == nil { - found = webView - } else if found !== webView { - return nil - } - } - stack.append(contentsOf: current.subviews) + func tabTitle(for tabId: UUID) -> String? { + if let context = contextContainingTabId(tabId) { + return context.tabManager.tabs.first(where: { $0.id == tabId })?.title } - return found + return tabManager?.tabs.first(where: { $0.id == tabId })?.title } - private static func programaCurrentEvent(for window: NSWindow) -> NSEvent? { -#if DEBUG - if let override = programaFirstResponderGuardCurrentEventOverride { - return override + private func bringToFront(_ window: NSWindow) { + if TerminalController.shouldSuppressSocketCommandActivation(), + !TerminalController.socketCommandAllowsInAppFocusMutations() { + return } -#endif - if programaFirstResponderGuardContextWindowNumber == window.windowNumber { - return programaFirstResponderGuardCurrentEventContext + setActiveMainWindow(window) + if window.isMiniaturized { + window.deminiaturize(nil) } - return NSApp.currentEvent + window.makeKeyAndOrderFront(nil) + // Improve reliability across Spaces / when other helper panels are key. + NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) } - private static func programaHitViewInThemeFrame(in window: NSWindow, event: NSEvent) -> NSView? { - guard let contentView = window.contentView, - let themeFrame = contentView.superview else { - return nil + private func markReadIfFocused( + notificationId: UUID, + tabId: UUID, + surfaceId: UUID?, + tabManager: TabManager, + notificationStore: TerminalNotificationStore + ) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + guard tabManager.selectedTabId == tabId else { return } + if let surfaceId { + guard tabManager.focusedSurfaceId(for: tabId) == surfaceId else { return } + } + notificationStore.markRead(id: notificationId) } - let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil) - return themeFrame.hitTest(pointInTheme) } - private static func programaHitViewInContentView(in window: NSWindow, event: NSEvent) -> NSView? { - guard let contentView = window.contentView else { - return nil - } - let pointInContent = contentView.convert(event.locationInWindow, from: nil) - return contentView.hitTest(pointInContent) - } +#if DEBUG + private func recordMultiWindowNotificationOpenFailureIfNeeded( + tabId: UUID, + surfaceId: UUID?, + notificationId: UUID?, + reason: String + ) { + let env = ProcessInfo.processInfo.environment + guard let path = env["PROGRAMA_UI_TEST_MULTI_WINDOW_NOTIF_PATH"], !path.isEmpty else { return } - private static func programaTopHitViewForEvent(in window: NSWindow, event: NSEvent) -> NSView? { - if let hitInThemeFrame = programaHitViewInThemeFrame(in: window, event: event) { - return hitInThemeFrame + let contextSummaries: [String] = mainWindowContexts.values.map { ctx in + let tabIds = ctx.tabManager.tabs.map { $0.id.uuidString }.joined(separator: ",") + let hasWindow = (ctx.window != nil) ? "1" : "0" + return "windowId=\(ctx.windowId.uuidString) hasWindow=\(hasWindow) tabs=[\(tabIds)]" } - return programaHitViewInContentView(in: window, event: event) - } - private static func programaHitViewForEventDispatch(in window: NSWindow, event: NSEvent) -> NSView? { - if event.windowNumber != 0, event.windowNumber != window.windowNumber { - return nil - } - if let eventWindow = event.window, eventWindow !== window { - return nil - } - return programaTopHitViewForEvent(in: window, event: event) + writeMultiWindowNotificationTestData([ + "focusToken": UUID().uuidString, + "openFailureTabId": tabId.uuidString, + "openFailureSurfaceId": surfaceId?.uuidString ?? "", + "openFailureNotificationId": notificationId?.uuidString ?? "", + "openFailureReason": reason, + "openFailureContexts": contextSummaries.joined(separator: "; "), + ], at: path) } +#endif + + /// Configures a Programa main window's AppKit-level presentation (identifier, + /// titlebar, movability, transparency, glass effect, decorations) and registers + /// it for window-context tracking, then installs the file-drop overlay. + /// + /// Extracted verbatim from `ContentView`'s `WindowAccessor` trailing closure + /// (nuclear-review CV2b) so this AppKit window mutation lives with the + /// window-context layer instead of the SwiftUI view layer. `ContentView` still + /// owns tracking its own `@State` (`observedWindow`, `isFullScreen`, + /// `titlebarPadding`) — this method returns the computed titlebar padding so + /// the caller can decide whether to update that `@State`. + @discardableResult + func configureMainWindow( + _ window: NSWindow, + windowId: UUID, + windowIdentifier: String, + tabManager: TabManager, + sidebarState: SidebarState, + sidebarSelectionState: SidebarSelectionState, + sidebarBlendMode: String, + bgGlassEnabled: Bool, + bgGlassTintHex: String, + bgGlassTintOpacity: Double + ) -> CGFloat { + window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier) + window.titlebarAppearsTransparent = true + // Keep window immovable; the sidebar's WindowDragHandleView handles + // drag-to-move via performDrag with temporary movable override. + // isMovableByWindowBackground=true breaks tab reordering, and + // isMovable=true blocks clicks on sidebar buttons in minimal mode. + window.isMovableByWindowBackground = false + window.isMovable = false + window.styleMask.insert(.fullSizeContentView) - private static func programaHitViewForCurrentEvent(in window: NSWindow, event: NSEvent) -> NSView? { + // Keep content below the titlebar so drags on Bonsplit's tab bar don't + // get interpreted as window drags. + let computedTitlebarHeight = window.frame.height - window.contentLayoutRect.height + let nextTitlebarPadding = max(28, min(72, computedTitlebarHeight)) #if DEBUG - if let override = programaFirstResponderGuardHitViewOverride { - return override + if ProcessInfo.processInfo.environment["PROGRAMA_UI_TEST_MODE"] == "1" { + UpdateLogStore.shared.append("ui test window accessor: id=\(windowIdentifier) visible=\(window.isVisible)") } #endif - if programaFirstResponderGuardContextWindowNumber == window.windowNumber, - let contextHitView = programaFirstResponderGuardHitViewContext { - return contextHitView - } - return programaTopHitViewForEvent(in: window, event: event) - } + // User settings decide whether window glass is active. The native Tahoe + // NSGlassEffectView path vs the older NSVisualEffectView fallback is chosen + // inside WindowGlassEffect.apply. + let currentThemeBackground = GhosttyBackgroundTheme.currentColor() + let shouldApplyWindowGlass = cmuxShouldApplyWindowGlass( + sidebarBlendMode: sidebarBlendMode, + bgGlassEnabled: bgGlassEnabled, + glassEffectAvailable: WindowGlassEffect.isAvailable + ) + let shouldForceTransparentHosting = + shouldApplyWindowGlass || currentThemeBackground.alphaComponent < 0.999 - private static func programaTrackFieldEditor(_ fieldEditor: NSTextView, owningWebView webView: ProgramaWebView?) { - if let webView { - objc_setAssociatedObject( - fieldEditor, - &programaFieldEditorOwningWebViewAssociationKey, - ProgramaFieldEditorOwningWebViewBox(webView: webView), - .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) + if shouldForceTransparentHosting { + window.isOpaque = false + // Keep the window clear whenever translucency is active. Relying only on + // terminal focus-driven updates can leave stale opaque window fills. + window.backgroundColor = NSColor.white.withAlphaComponent(0.001) + // Configure contentView hierarchy for transparency. + if let contentView = window.contentView { + ContentView.makeViewHierarchyTransparent(contentView) + } } else { - objc_setAssociatedObject( - fieldEditor, - &programaFieldEditorOwningWebViewAssociationKey, - nil, - .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) + // Browser-focused workspaces may not have an active terminal panel to refresh + // the NSWindow background. Keep opaque theme changes applied here as well. + window.backgroundColor = currentThemeBackground + window.isOpaque = currentThemeBackground.alphaComponent >= 0.999 } - } - private static func programaTrackedOwningWebView(for fieldEditor: NSTextView) -> ProgramaWebView? { - guard let box = objc_getAssociatedObject( - fieldEditor, - &programaFieldEditorOwningWebViewAssociationKey - ) as? ProgramaFieldEditorOwningWebViewBox else { - return nil - } - guard let webView = box.webView else { - programaTrackFieldEditor(fieldEditor, owningWebView: nil) - return nil + if shouldApplyWindowGlass { + // Apply liquid glass effect to the window with tint from settings + let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) + WindowGlassEffect.apply(to: window, tintColor: tintColor) + } else { + WindowGlassEffect.remove(from: window) } - return webView - } + AppDelegate.shared?.attachUpdateAccessory(to: window) + AppDelegate.shared?.applyWindowDecorations(to: window) + AppDelegate.shared?.registerMainWindow( + window, + windowId: windowId, + tabManager: tabManager, + sidebarState: sidebarState, + sidebarSelectionState: sidebarSelectionState + ) + installFileDropOverlay(on: window, tabManager: tabManager) - private static func programaIsPointerDownEvent(_ event: NSEvent) -> Bool { - switch event.type { - case .leftMouseDown, .rightMouseDown, .otherMouseDown: - return true - default: - return false - } + return nextTitlebarPadding } - private static func programaPointerHitWebView(in window: NSWindow, event: NSEvent) -> ProgramaWebView? { - guard programaIsPointerDownEvent(event) else { return nil } - if event.windowNumber != 0, event.windowNumber != window.windowNumber { - return nil - } - if let eventWindow = event.window, eventWindow !== window { - return nil - } - if let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint( - event.locationInWindow, - in: window - ) as? ProgramaWebView { - return portalWebView - } - guard let hitView = programaHitViewForCurrentEvent(in: window, event: event) else { - return nil - } - return programaOwningWebView(for: hitView) - } +} - private static func programaShouldAllowPointerInitiatedWebViewFocus( - window: NSWindow, - webView: ProgramaWebView, - event: NSEvent? - ) -> Bool { - guard let event, - let hitWebView = programaPointerHitWebView(in: window, event: event) else { - return false +private extension AppDelegate { + @objc func handleThemesReloadNotification(_ notification: Notification) { + DispatchQueue.main.async { + GhosttyApp.shared.reloadConfiguration(source: "distributed.cmux.themes") } - return hitWebView === webView } - } + diff --git a/Sources/CLIInstaller.swift b/Sources/CLIInstaller.swift new file mode 100644 index 00000000000..e41902b633f --- /dev/null +++ b/Sources/CLIInstaller.swift @@ -0,0 +1,324 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +struct ProgramaCLIPathInstaller { + struct InstallOutcome { + let usedAdministratorPrivileges: Bool + let destinationURL: URL + let sourceURL: URL + } + + struct UninstallOutcome { + let usedAdministratorPrivileges: Bool + let destinationURL: URL + let removedExistingEntry: Bool + } + + enum InstallerError: LocalizedError { + case bundledCLIMissing(expectedPath: String) + case destinationParentNotDirectory(path: String) + case destinationIsDirectory(path: String) + case installVerificationFailed(path: String) + case uninstallVerificationFailed(path: String) + case privilegedCommandFailed(message: String) + + var errorDescription: String? { + switch self { + case .bundledCLIMissing(let expectedPath): + return "Bundled Programa CLI was not found at \(expectedPath)." + case .destinationParentNotDirectory(let path): + return "Expected \(path) to be a directory." + case .destinationIsDirectory(let path): + return "\(path) is a directory. Remove or rename it and try again." + case .installVerificationFailed(let path): + return "Installed symlink at \(path) did not point to the bundled programa CLI." + case .uninstallVerificationFailed(let path): + return "Failed to remove \(path)." + case .privilegedCommandFailed(let message): + return "Administrator action failed: \(message)" + } + } + } + + typealias PrivilegedInstallHandler = (_ sourceURL: URL, _ destinationURL: URL) throws -> Void + typealias PrivilegedUninstallHandler = (_ destinationURL: URL) throws -> Void + + let fileManager: FileManager + let destinationURL: URL + private let bundledCLIURLProvider: () -> URL? + private let expectedBundledCLIPath: String + private let privilegedInstaller: PrivilegedInstallHandler + private let privilegedUninstaller: PrivilegedUninstallHandler + + init( + fileManager: FileManager = .default, + destinationURL: URL = URL(fileURLWithPath: "/usr/local/bin/programa"), + bundledCLIURLProvider: @escaping () -> URL? = { + ProgramaCLIPathInstaller.defaultBundledCLIURL() + }, + expectedBundledCLIPath: String = ProgramaCLIPathInstaller.defaultBundledCLIExpectedPath(), + privilegedInstaller: PrivilegedInstallHandler? = nil, + privilegedUninstaller: PrivilegedUninstallHandler? = nil + ) { + self.fileManager = fileManager + self.destinationURL = destinationURL + self.bundledCLIURLProvider = bundledCLIURLProvider + self.expectedBundledCLIPath = expectedBundledCLIPath + self.privilegedInstaller = privilegedInstaller ?? Self.installWithAdministratorPrivileges(sourceURL:destinationURL:) + self.privilegedUninstaller = privilegedUninstaller ?? Self.uninstallWithAdministratorPrivileges(destinationURL:) + } + + var destinationPath: String { + destinationURL.path + } + + func install() throws -> InstallOutcome { + let sourceURL = try resolveBundledCLIURL() + do { + try installWithoutAdministratorPrivileges(sourceURL: sourceURL) + return InstallOutcome( + usedAdministratorPrivileges: false, + destinationURL: destinationURL, + sourceURL: sourceURL + ) + } catch { + guard Self.isPermissionDenied(error) else { throw error } + try ensureDestinationIsNotDirectory() + try privilegedInstaller(sourceURL, destinationURL) + try verifyInstalledSymlinkTarget(sourceURL: sourceURL) + return InstallOutcome( + usedAdministratorPrivileges: true, + destinationURL: destinationURL, + sourceURL: sourceURL + ) + } + } + + func uninstall() throws -> UninstallOutcome { + do { + let removedExistingEntry = try uninstallWithoutAdministratorPrivileges() + return UninstallOutcome( + usedAdministratorPrivileges: false, + destinationURL: destinationURL, + removedExistingEntry: removedExistingEntry + ) + } catch { + guard Self.isPermissionDenied(error) else { throw error } + try ensureDestinationIsNotDirectory() + let removedExistingEntry = destinationEntryExists() + try privilegedUninstaller(destinationURL) + if destinationEntryExists() { + throw InstallerError.uninstallVerificationFailed(path: destinationURL.path) + } + return UninstallOutcome( + usedAdministratorPrivileges: true, + destinationURL: destinationURL, + removedExistingEntry: removedExistingEntry + ) + } + } + + func isInstalled() -> Bool { + guard let sourceURL = bundledCLIURLProvider()?.standardizedFileURL else { return false } + guard let installedTargetURL = symlinkDestinationURL() else { return false } + return installedTargetURL == sourceURL + } + + private func resolveBundledCLIURL() throws -> URL { + guard let sourceURL = bundledCLIURLProvider()?.standardizedFileURL else { + throw InstallerError.bundledCLIMissing(expectedPath: expectedBundledCLIPath) + } + + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory), !isDirectory.boolValue else { + throw InstallerError.bundledCLIMissing(expectedPath: sourceURL.path) + } + return sourceURL + } + + private func installWithoutAdministratorPrivileges(sourceURL: URL) throws { + try ensureDestinationParentDirectoryExists() + try ensureDestinationIsNotDirectory() + if destinationEntryExists() { + try fileManager.removeItem(at: destinationURL) + } + try fileManager.createSymbolicLink(at: destinationURL, withDestinationURL: sourceURL) + try verifyInstalledSymlinkTarget(sourceURL: sourceURL) + } + + @discardableResult + private func uninstallWithoutAdministratorPrivileges() throws -> Bool { + try ensureDestinationIsNotDirectory() + let existed = destinationEntryExists() + if existed { + try fileManager.removeItem(at: destinationURL) + } + if destinationEntryExists() { + throw InstallerError.uninstallVerificationFailed(path: destinationURL.path) + } + return existed + } + + /// Check if the destination path has any filesystem entry (including dangling symlinks). + /// `FileManager.fileExists` follows symlinks, so a dangling symlink returns false. + private func destinationEntryExists() -> Bool { + (try? fileManager.attributesOfItem(atPath: destinationURL.path)) != nil + } + + private func verifyInstalledSymlinkTarget(sourceURL: URL) throws { + guard let installedTargetURL = symlinkDestinationURL(), + installedTargetURL == sourceURL.standardizedFileURL else { + throw InstallerError.installVerificationFailed(path: destinationURL.path) + } + } + + private func symlinkDestinationURL() -> URL? { + guard fileManager.fileExists(atPath: destinationURL.path) else { return nil } + guard let destinationPath = try? fileManager.destinationOfSymbolicLink(atPath: destinationURL.path) else { + return nil + } + return URL( + fileURLWithPath: destinationPath, + relativeTo: destinationURL.deletingLastPathComponent() + ).standardizedFileURL + } + + private func ensureDestinationParentDirectoryExists() throws { + let parentURL = destinationURL.deletingLastPathComponent() + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: parentURL.path, isDirectory: &isDirectory) { + guard isDirectory.boolValue else { + throw InstallerError.destinationParentNotDirectory(path: parentURL.path) + } + return + } + try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true) + } + + private func ensureDestinationIsNotDirectory() throws { + guard let values = try resourceValuesIfFileExists( + at: destinationURL, + keys: [.isDirectoryKey, .isSymbolicLinkKey] + ) else { + return + } + + if values.isDirectory == true, values.isSymbolicLink != true { + throw InstallerError.destinationIsDirectory(path: destinationURL.path) + } + } + + private func resourceValuesIfFileExists( + at url: URL, + keys: Set + ) throws -> URLResourceValues? { + do { + return try url.resourceValues(forKeys: keys) + } catch { + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError { + return nil + } + if nsError.domain == NSPOSIXErrorDomain, + POSIXErrorCode(rawValue: Int32(nsError.code)) == .ENOENT { + return nil + } + throw error + } + } + + private static func defaultBundledCLIURL(bundle: Bundle = .main) -> URL? { + bundle.resourceURL?.appendingPathComponent("bin/programa", isDirectory: false) + } + + private static func defaultBundledCLIExpectedPath(bundle: Bundle = .main) -> String { + bundle.bundleURL + .appendingPathComponent("Contents/Resources/bin/programa", isDirectory: false) + .path + } + + private static func installWithAdministratorPrivileges(sourceURL: URL, destinationURL: URL) throws { + let destinationPath = destinationURL.path + let parentPath = destinationURL.deletingLastPathComponent().path + let command = "/bin/mkdir -p \(shellQuoted(parentPath)) && " + + "/bin/rm -f \(shellQuoted(destinationPath)) && " + + "/bin/ln -s \(shellQuoted(sourceURL.path)) \(shellQuoted(destinationPath))" + try runPrivilegedShellCommand(command) + } + + private static func uninstallWithAdministratorPrivileges(destinationURL: URL) throws { + let command = "/bin/rm -f \(shellQuoted(destinationURL.path))" + try runPrivilegedShellCommand(command) + } + + private static func runPrivilegedShellCommand(_ command: String) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = [ + "-e", "on run argv", + "-e", "do shell script (item 1 of argv) with administrator privileges", + "-e", "end run", + command + ] + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let stderrText = String( + data: stderr.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let stdoutText = String( + data: stdout.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + )?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let details = stderrText.isEmpty ? stdoutText : stderrText + let message = details.isEmpty + ? "osascript exited with status \(process.terminationStatus)." + : details + throw InstallerError.privilegedCommandFailed(message: message) + } + } + + private static func shellQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private static func isPermissionDenied(_ error: Error) -> Bool { + isPermissionDenied(error as NSError) + } + + private static func isPermissionDenied(_ error: NSError) -> Bool { + if error.domain == NSPOSIXErrorDomain, + let code = POSIXErrorCode(rawValue: Int32(error.code)), + code == .EACCES || code == .EPERM || code == .EROFS { + return true + } + + if error.domain == NSCocoaErrorDomain { + switch error.code { + case NSFileWriteNoPermissionError, NSFileReadNoPermissionError, NSFileWriteVolumeReadOnlyError: + return true + default: + break + } + } + + if let underlying = error.userInfo[NSUnderlyingErrorKey] as? NSError { + return isPermissionDenied(underlying) + } + + return false + } +} diff --git a/Sources/MainWindowHostingView.swift b/Sources/MainWindowHostingView.swift new file mode 100644 index 00000000000..21e9cf4a877 --- /dev/null +++ b/Sources/MainWindowHostingView.swift @@ -0,0 +1,33 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +final class MainWindowHostingView: NSHostingView { + private let zeroSafeAreaLayoutGuide = NSLayoutGuide() + + override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } + override var safeAreaRect: NSRect { bounds } + override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide } + + required init(rootView: Content) { + super.init(rootView: rootView) + addLayoutGuide(zeroSafeAreaLayoutGuide) + NSLayoutConstraint.activate([ + zeroSafeAreaLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor), + zeroSafeAreaLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor), + zeroSafeAreaLayoutGuide.topAnchor.constraint(equalTo: topAnchor), + zeroSafeAreaLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Sources/MenuBarIconRenderer.swift b/Sources/MenuBarIconRenderer.swift new file mode 100644 index 00000000000..d9d35ba6fd8 --- /dev/null +++ b/Sources/MenuBarIconRenderer.swift @@ -0,0 +1,669 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +@MainActor +final class MenuBarExtraController: NSObject, NSMenuDelegate { + private let statusItem: NSStatusItem + private let menu = NSMenu(title: "Programa") + private let notificationStore: TerminalNotificationStore + private let onShowNotifications: () -> Void + private let onOpenNotification: (TerminalNotification) -> Void + private let onJumpToLatestUnread: () -> Void + private let onCheckForUpdates: () -> Void + private let onOpenPreferences: () -> Void + private let onQuitApp: () -> Void + private var notificationsCancellable: AnyCancellable? + private let buildHintTitle: String? + + private let stateHintItem = NSMenuItem(title: String(localized: "statusMenu.noUnread", defaultValue: "No unread notifications"), action: nil, keyEquivalent: "") + private let buildHintItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") + private let notificationListSeparator = NSMenuItem.separator() + private let notificationSectionSeparator = NSMenuItem.separator() + private let showNotificationsItem = NSMenuItem(title: String(localized: "statusMenu.showNotifications", defaultValue: "Show Notifications"), action: nil, keyEquivalent: "") + private let jumpToUnreadItem = NSMenuItem(title: String(localized: "statusMenu.jumpToLatestUnread", defaultValue: "Jump to Latest Unread"), action: nil, keyEquivalent: "") + private let markAllReadItem = NSMenuItem(title: String(localized: "statusMenu.markAllRead", defaultValue: "Mark All Read"), action: nil, keyEquivalent: "") + private let clearAllItem = NSMenuItem(title: String(localized: "statusMenu.clearAll", defaultValue: "Clear All"), action: nil, keyEquivalent: "") + private let checkForUpdatesItem = NSMenuItem(title: String(localized: "menu.checkForUpdates", defaultValue: "Check for Updates…"), action: nil, keyEquivalent: "") + private let preferencesItem = NSMenuItem(title: String(localized: "menu.preferences", defaultValue: "Preferences…"), action: nil, keyEquivalent: "") + private let quitItem = NSMenuItem(title: String(localized: "menu.quitPrograma", defaultValue: "Quit Programa"), action: nil, keyEquivalent: "") + + private var notificationItems: [NSMenuItem] = [] + private let maxInlineNotificationItems = 6 + + init( + notificationStore: TerminalNotificationStore, + onShowNotifications: @escaping () -> Void, + onOpenNotification: @escaping (TerminalNotification) -> Void, + onJumpToLatestUnread: @escaping () -> Void, + onCheckForUpdates: @escaping () -> Void, + onOpenPreferences: @escaping () -> Void, + onQuitApp: @escaping () -> Void + ) { + self.notificationStore = notificationStore + self.onShowNotifications = onShowNotifications + self.onOpenNotification = onOpenNotification + self.onJumpToLatestUnread = onJumpToLatestUnread + self.onCheckForUpdates = onCheckForUpdates + self.onOpenPreferences = onOpenPreferences + self.onQuitApp = onQuitApp + self.buildHintTitle = MenuBarBuildHintFormatter.menuTitle() + self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + super.init() + + buildMenu() + statusItem.menu = menu + if let button = statusItem.button { + button.imagePosition = .imageOnly + button.imageScaling = .scaleProportionallyDown + button.image = MenuBarIconRenderer.makeImage(unreadCount: 0) + button.toolTip = "Programa" + } + + notificationsCancellable = notificationStore.$notifications + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshUI() + } + + refreshUI() + } + + private func buildMenu() { + menu.autoenablesItems = false + menu.delegate = self + + stateHintItem.isEnabled = false + menu.addItem(stateHintItem) + if let buildHintTitle { + buildHintItem.title = buildHintTitle + buildHintItem.isEnabled = false + menu.addItem(buildHintItem) + } + + menu.addItem(notificationListSeparator) + notificationSectionSeparator.isHidden = true + menu.addItem(notificationSectionSeparator) + + showNotificationsItem.target = self + showNotificationsItem.action = #selector(showNotificationsAction) + menu.addItem(showNotificationsItem) + + jumpToUnreadItem.target = self + jumpToUnreadItem.action = #selector(jumpToUnreadAction) + menu.addItem(jumpToUnreadItem) + + markAllReadItem.target = self + markAllReadItem.action = #selector(markAllReadAction) + menu.addItem(markAllReadItem) + + clearAllItem.target = self + clearAllItem.action = #selector(clearAllAction) + menu.addItem(clearAllItem) + + menu.addItem(.separator()) + + checkForUpdatesItem.target = self + checkForUpdatesItem.action = #selector(checkForUpdatesAction) + menu.addItem(checkForUpdatesItem) + + preferencesItem.target = self + preferencesItem.action = #selector(preferencesAction) + menu.addItem(preferencesItem) + + menu.addItem(.separator()) + + quitItem.target = self + quitItem.action = #selector(quitAction) + menu.addItem(quitItem) + } + + func menuWillOpen(_ menu: NSMenu) { + refreshUI() + } + + func refreshForDebugControls() { + refreshUI() + } + + func removeFromMenuBar() { + notificationsCancellable?.cancel() + notificationsCancellable = nil + statusItem.menu = nil + NSStatusBar.system.removeStatusItem(statusItem) + } + + private func refreshUI() { + let snapshot = NotificationMenuSnapshotBuilder.make( + notifications: notificationStore.notifications, + maxInlineNotificationItems: maxInlineNotificationItems + ) + let actualUnreadCount = snapshot.unreadCount + + let displayedUnreadCount: Int +#if DEBUG + displayedUnreadCount = MenuBarIconDebugSettings.displayedUnreadCount(actualUnreadCount: actualUnreadCount) +#else + displayedUnreadCount = actualUnreadCount +#endif + + stateHintItem.title = snapshot.stateHintTitle + + applyShortcut(KeyboardShortcutSettings.shortcut(for: .showNotifications), to: showNotificationsItem) + applyShortcut(KeyboardShortcutSettings.shortcut(for: .jumpToUnread), to: jumpToUnreadItem) + + jumpToUnreadItem.isEnabled = snapshot.hasUnreadNotifications + markAllReadItem.isEnabled = snapshot.hasUnreadNotifications + clearAllItem.isEnabled = snapshot.hasNotifications + + rebuildInlineNotificationItems(recentNotifications: snapshot.recentNotifications) + + if let button = statusItem.button { + button.image = MenuBarIconRenderer.makeImage(unreadCount: displayedUnreadCount) + button.toolTip = displayedUnreadCount == 0 + ? "Programa" + : displayedUnreadCount == 1 + ? "Programa: " + String(localized: "statusMenu.tooltip.unread.one", defaultValue: "1 unread notification") + : "Programa: " + String(localized: "statusMenu.tooltip.unread.other", defaultValue: "\(displayedUnreadCount) unread notifications") + } + } + + private func applyShortcut(_ shortcut: StoredShortcut, to item: NSMenuItem) { + guard let keyEquivalent = shortcut.menuItemKeyEquivalent else { + item.keyEquivalent = "" + item.keyEquivalentModifierMask = [] + return + } + item.keyEquivalent = keyEquivalent + item.keyEquivalentModifierMask = shortcut.modifierFlags + } + + private func rebuildInlineNotificationItems(recentNotifications: [TerminalNotification]) { + for item in notificationItems { + menu.removeItem(item) + } + notificationItems.removeAll(keepingCapacity: true) + + notificationListSeparator.isHidden = recentNotifications.isEmpty + notificationSectionSeparator.isHidden = recentNotifications.isEmpty + guard !recentNotifications.isEmpty else { return } + + let insertionIndex = menu.index(of: showNotificationsItem) + guard insertionIndex >= 0 else { return } + + for (offset, notification) in recentNotifications.enumerated() { + let tabTitle = AppDelegate.shared?.tabTitle(for: notification.tabId) + let item = makeNotificationItem(notification: notification, tabTitle: tabTitle) + menu.insertItem(item, at: insertionIndex + offset) + notificationItems.append(item) + } + } + + private func makeNotificationItem(notification: TerminalNotification, tabTitle: String?) -> NSMenuItem { + let item = NSMenuItem(title: "", action: #selector(openNotificationItemAction(_:)), keyEquivalent: "") + item.target = self + item.attributedTitle = MenuBarNotificationLineFormatter.attributedTitle(notification: notification, tabTitle: tabTitle) + item.toolTip = MenuBarNotificationLineFormatter.tooltip(notification: notification, tabTitle: tabTitle) + item.representedObject = NotificationMenuItemPayload(notification: notification) + return item + } + + @objc private func openNotificationItemAction(_ sender: NSMenuItem) { + guard let payload = sender.representedObject as? NotificationMenuItemPayload else { return } + onOpenNotification(payload.notification) + } + + @objc private func showNotificationsAction() { + onShowNotifications() + } + + @objc private func jumpToUnreadAction() { + onJumpToLatestUnread() + } + + @objc private func markAllReadAction() { + notificationStore.markAllRead() + } + + @objc private func clearAllAction() { + notificationStore.clearAll() + } + + @objc private func checkForUpdatesAction() { + onCheckForUpdates() + } + + @objc private func preferencesAction() { + onOpenPreferences() + } + + @objc private func quitAction() { + onQuitApp() + } +} + +private final class NotificationMenuItemPayload: NSObject { + let notification: TerminalNotification + + init(notification: TerminalNotification) { + self.notification = notification + super.init() + } +} + +struct NotificationMenuSnapshot { + let unreadCount: Int + let hasNotifications: Bool + let recentNotifications: [TerminalNotification] + + var hasUnreadNotifications: Bool { + unreadCount > 0 + } + + var stateHintTitle: String { + NotificationMenuSnapshotBuilder.stateHintTitle(unreadCount: unreadCount) + } +} + +enum NotificationMenuSnapshotBuilder { + static let defaultInlineNotificationLimit = 6 + + static func make( + notifications: [TerminalNotification], + maxInlineNotificationItems: Int = defaultInlineNotificationLimit + ) -> NotificationMenuSnapshot { + let unreadCount = notifications.reduce(into: 0) { count, notification in + if !notification.isRead { + count += 1 + } + } + + let inlineLimit = max(0, maxInlineNotificationItems) + return NotificationMenuSnapshot( + unreadCount: unreadCount, + hasNotifications: !notifications.isEmpty, + recentNotifications: Array(notifications.prefix(inlineLimit)) + ) + } + + static func stateHintTitle(unreadCount: Int) -> String { + switch unreadCount { + case 0: + return String(localized: "statusMenu.noUnread", defaultValue: "No unread notifications") + case 1: + return String(localized: "statusMenu.unreadCount.one", defaultValue: "1 unread notification") + default: + return String(localized: "statusMenu.unreadCount.other", defaultValue: "\(unreadCount) unread notifications") + } + } +} + +enum MenuBarBadgeLabelFormatter { + static func badgeText(for unreadCount: Int) -> String? { + guard unreadCount > 0 else { return nil } + if unreadCount > 9 { + return "9+" + } + return String(unreadCount) + } +} + +enum MenuBarNotificationLineFormatter { + static let defaultMaxMenuTextWidth: CGFloat = 280 + static let defaultMaxMenuTextLines = 3 + + static func plainTitle(notification: TerminalNotification, tabTitle: String?) -> String { + let dot = notification.isRead ? " " : "● " + let timeText = notification.createdAt.formatted(date: .omitted, time: .shortened) + var lines: [String] = [] + lines.append("\(dot)\(notification.title) \(timeText)") + + let detail = notification.body.isEmpty ? notification.subtitle : notification.body + if !detail.isEmpty { + lines.append(detail) + } + + if let tabTitle, !tabTitle.isEmpty { + lines.append(tabTitle) + } + + return lines.joined(separator: "\n") + } + + static func menuTitle( + notification: TerminalNotification, + tabTitle: String?, + maxWidth: CGFloat = defaultMaxMenuTextWidth, + maxLines: Int = defaultMaxMenuTextLines + ) -> String { + let base = plainTitle(notification: notification, tabTitle: tabTitle) + return wrappedAndTruncated(base, maxWidth: maxWidth, maxLines: maxLines) + } + + static func attributedTitle(notification: TerminalNotification, tabTitle: String?) -> NSAttributedString { + let paragraph = NSMutableParagraphStyle() + paragraph.lineBreakMode = .byWordWrapping + return NSAttributedString( + string: menuTitle(notification: notification, tabTitle: tabTitle), + attributes: [ + .font: NSFont.menuFont(ofSize: NSFont.systemFontSize), + .foregroundColor: NSColor.labelColor, + .paragraphStyle: paragraph, + ] + ) + } + + static func tooltip(notification: TerminalNotification, tabTitle: String?) -> String { + plainTitle(notification: notification, tabTitle: tabTitle) + } + + private static func wrappedAndTruncated(_ text: String, maxWidth: CGFloat, maxLines: Int) -> String { + let width = max(60, maxWidth) + let lines = max(1, maxLines) + let font = NSFont.menuFont(ofSize: NSFont.systemFontSize) + let wrapped = wrappedLines(for: text, maxWidth: width, font: font) + guard wrapped.count > lines else { return wrapped.joined(separator: "\n") } + + var clipped = Array(wrapped.prefix(lines)) + clipped[lines - 1] = truncateLine(clipped[lines - 1], maxWidth: width, font: font) + return clipped.joined(separator: "\n") + } + + private static func wrappedLines(for text: String, maxWidth: CGFloat, font: NSFont) -> [String] { + let storage = NSTextStorage(string: text, attributes: [.font: font]) + let layout = NSLayoutManager() + let container = NSTextContainer(size: NSSize(width: maxWidth, height: .greatestFiniteMagnitude)) + container.lineFragmentPadding = 0 + container.lineBreakMode = .byWordWrapping + layout.addTextContainer(container) + storage.addLayoutManager(layout) + _ = layout.glyphRange(for: container) + + let fullText = text as NSString + var rows: [String] = [] + var glyphIndex = 0 + while glyphIndex < layout.numberOfGlyphs { + var glyphRange = NSRange() + layout.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: &glyphRange) + if glyphRange.length == 0 { break } + + let charRange = layout.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) + let row = fullText.substring(with: charRange).trimmingCharacters(in: .newlines) + rows.append(row) + glyphIndex = NSMaxRange(glyphRange) + } + + if rows.isEmpty { + return [text] + } + return rows + } + + private static func truncateLine(_ line: String, maxWidth: CGFloat, font: NSFont) -> String { + let ellipsis = "…" + let full = line.trimmingCharacters(in: .whitespacesAndNewlines) + if full.isEmpty { return ellipsis } + + if measuredWidth(full + ellipsis, font: font) <= maxWidth { + return full + ellipsis + } + + var chars = Array(full) + while !chars.isEmpty { + chars.removeLast() + let candidateBase = String(chars).trimmingCharacters(in: .whitespacesAndNewlines) + let candidate = (candidateBase.isEmpty ? "" : candidateBase) + ellipsis + if measuredWidth(candidate, font: font) <= maxWidth { + return candidate + } + } + return ellipsis + } + + private static func measuredWidth(_ text: String, font: NSFont) -> CGFloat { + (text as NSString).size(withAttributes: [.font: font]).width + } +} + +enum MenuBarBuildHintFormatter { + static func menuTitle( + appName: String = defaultAppName(), + isDebugBuild: Bool = _isDebugAssertConfiguration() + ) -> String? { + guard isDebugBuild else { return nil } + let normalized = appName.trimmingCharacters(in: .whitespacesAndNewlines) + let prefix = "cmux DEV" + guard normalized.hasPrefix(prefix) else { return "Build: DEV" } + + let suffix = String(normalized.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) + if suffix.isEmpty { + return "Build: DEV (untagged)" + } + return "Build Tag: \(suffix)" + } + + private static func defaultAppName() -> String { + let bundle = Bundle.main + if let displayName = bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String, + !displayName.isEmpty { + return displayName + } + if let name = bundle.object(forInfoDictionaryKey: "CFBundleName") as? String, !name.isEmpty { + return name + } + return ProcessInfo.processInfo.processName + } +} + +enum MenuBarExtraSettings { + static let showInMenuBarKey = "showMenuBarExtra" + static let defaultShowInMenuBar = true + + static func showsMenuBarExtra(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: showInMenuBarKey) == nil { + return defaultShowInMenuBar + } + return defaults.bool(forKey: showInMenuBarKey) + } +} + +struct MenuBarBadgeRenderConfig { + var badgeRect: NSRect + var singleDigitFontSize: CGFloat + var multiDigitFontSize: CGFloat + var singleDigitYOffset: CGFloat + var multiDigitYOffset: CGFloat + var singleDigitXAdjust: CGFloat + var multiDigitXAdjust: CGFloat + var textRectWidthAdjust: CGFloat +} + +enum MenuBarIconDebugSettings { + static let previewEnabledKey = "menubarDebugPreviewEnabled" + static let previewCountKey = "menubarDebugPreviewCount" + static let badgeRectXKey = "menubarDebugBadgeRectX" + static let badgeRectYKey = "menubarDebugBadgeRectY" + static let badgeRectWidthKey = "menubarDebugBadgeRectWidth" + static let badgeRectHeightKey = "menubarDebugBadgeRectHeight" + static let singleDigitFontSizeKey = "menubarDebugSingleDigitFontSize" + static let multiDigitFontSizeKey = "menubarDebugMultiDigitFontSize" + static let singleDigitYOffsetKey = "menubarDebugSingleDigitYOffset" + static let multiDigitYOffsetKey = "menubarDebugMultiDigitYOffset" + static let singleDigitXAdjustKey = "menubarDebugSingleDigitXAdjust" + static let legacySingleDigitXAdjustKey = "menubarDebugTextRectXAdjust" + static let multiDigitXAdjustKey = "menubarDebugMultiDigitXAdjust" + static let textRectWidthAdjustKey = "menubarDebugTextRectWidthAdjust" + + static let defaultBadgeRect = NSRect(x: 5.38, y: 6.43, width: 10.75, height: 11.58) + static let defaultSingleDigitFontSize: CGFloat = 6.7 + static let defaultMultiDigitFontSize: CGFloat = 6.7 + static let defaultSingleDigitYOffset: CGFloat = 0.6 + static let defaultMultiDigitYOffset: CGFloat = 0.6 + static let defaultSingleDigitXAdjust: CGFloat = -1.1 + static let defaultMultiDigitXAdjust: CGFloat = 2.42 + static let defaultTextRectWidthAdjust: CGFloat = 1.8 + + static func displayedUnreadCount(actualUnreadCount: Int, defaults: UserDefaults = .standard) -> Int { + guard defaults.bool(forKey: previewEnabledKey) else { return actualUnreadCount } + let value = defaults.integer(forKey: previewCountKey) + return max(0, min(value, 99)) + } + + static func badgeRenderConfig(defaults: UserDefaults = .standard) -> MenuBarBadgeRenderConfig { + let x = value(defaults, key: badgeRectXKey, fallback: defaultBadgeRect.origin.x, range: 0...20) + let y = value(defaults, key: badgeRectYKey, fallback: defaultBadgeRect.origin.y, range: 0...20) + let width = value(defaults, key: badgeRectWidthKey, fallback: defaultBadgeRect.width, range: 4...14) + let height = value(defaults, key: badgeRectHeightKey, fallback: defaultBadgeRect.height, range: 4...14) + let singleFont = value(defaults, key: singleDigitFontSizeKey, fallback: defaultSingleDigitFontSize, range: 6...14) + let multiFont = value(defaults, key: multiDigitFontSizeKey, fallback: defaultMultiDigitFontSize, range: 6...14) + let singleY = value(defaults, key: singleDigitYOffsetKey, fallback: defaultSingleDigitYOffset, range: -3...4) + let multiY = value(defaults, key: multiDigitYOffsetKey, fallback: defaultMultiDigitYOffset, range: -3...4) + let singleX = value( + defaults, + key: singleDigitXAdjustKey, + legacyKey: legacySingleDigitXAdjustKey, + fallback: defaultSingleDigitXAdjust, + range: -4...4 + ) + let multiX = value(defaults, key: multiDigitXAdjustKey, fallback: defaultMultiDigitXAdjust, range: -4...4) + let widthAdjust = value(defaults, key: textRectWidthAdjustKey, fallback: defaultTextRectWidthAdjust, range: -3...5) + + return MenuBarBadgeRenderConfig( + badgeRect: NSRect(x: x, y: y, width: width, height: height), + singleDigitFontSize: singleFont, + multiDigitFontSize: multiFont, + singleDigitYOffset: singleY, + multiDigitYOffset: multiY, + singleDigitXAdjust: singleX, + multiDigitXAdjust: multiX, + textRectWidthAdjust: widthAdjust + ) + } + + static func copyPayload(defaults: UserDefaults = .standard) -> String { + let config = badgeRenderConfig(defaults: defaults) + let previewEnabled = defaults.bool(forKey: previewEnabledKey) + let previewCount = max(0, min(defaults.integer(forKey: previewCountKey), 99)) + return """ + menubarDebugPreviewEnabled=\(previewEnabled) + menubarDebugPreviewCount=\(previewCount) + menubarDebugBadgeRectX=\(String(format: "%.2f", config.badgeRect.origin.x)) + menubarDebugBadgeRectY=\(String(format: "%.2f", config.badgeRect.origin.y)) + menubarDebugBadgeRectWidth=\(String(format: "%.2f", config.badgeRect.width)) + menubarDebugBadgeRectHeight=\(String(format: "%.2f", config.badgeRect.height)) + menubarDebugSingleDigitFontSize=\(String(format: "%.2f", config.singleDigitFontSize)) + menubarDebugMultiDigitFontSize=\(String(format: "%.2f", config.multiDigitFontSize)) + menubarDebugSingleDigitYOffset=\(String(format: "%.2f", config.singleDigitYOffset)) + menubarDebugMultiDigitYOffset=\(String(format: "%.2f", config.multiDigitYOffset)) + menubarDebugSingleDigitXAdjust=\(String(format: "%.2f", config.singleDigitXAdjust)) + menubarDebugMultiDigitXAdjust=\(String(format: "%.2f", config.multiDigitXAdjust)) + menubarDebugTextRectWidthAdjust=\(String(format: "%.2f", config.textRectWidthAdjust)) + """ + } + + private static func value( + _ defaults: UserDefaults, + key: String, + legacyKey: String? = nil, + fallback: CGFloat, + range: ClosedRange + ) -> CGFloat { + if let parsed = parse(defaults.object(forKey: key), fallback: fallback, range: range) { + return parsed + } + if let legacyKey, let parsed = parse(defaults.object(forKey: legacyKey), fallback: fallback, range: range) { + return parsed + } + return fallback + } + + private static func parse( + _ object: Any?, + fallback: CGFloat, + range: ClosedRange + ) -> CGFloat? { + guard let number = object as? NSNumber else { + return nil + } + let candidate = CGFloat(number.doubleValue) + guard candidate.isFinite else { return fallback } + return max(range.lowerBound, min(candidate, range.upperBound)) + } +} + +enum MenuBarIconRenderer { + + static func makeImage(unreadCount: Int) -> NSImage { + let badgeText = MenuBarBadgeLabelFormatter.badgeText(for: unreadCount) + let config = MenuBarIconDebugSettings.badgeRenderConfig() + let size = NSSize(width: 18, height: 18) + let image = NSImage(size: size) + image.lockFocus() + defer { image.unlockFocus() } + + let glyphRect = NSRect(x: 1.2, y: 1.5, width: 11.6, height: 15.0) + drawGlyph(in: glyphRect) + + if let text = badgeText { + drawBadge(text: text, in: config.badgeRect, config: config) + } + + image.isTemplate = true + return image + } + + private static func drawGlyph(in rect: NSRect) { + // Match the canonical cmux center-mark path from Icon Center Image Artwork.svg. + let srcMinX: CGFloat = 384.0 + let srcMinY: CGFloat = 255.0 + let srcWidth: CGFloat = 369.0 + let srcHeight: CGFloat = 513.0 + + func map(_ x: CGFloat, _ y: CGFloat) -> NSPoint { + let nx = (x - srcMinX) / srcWidth + let ny = (y - srcMinY) / srcHeight + return NSPoint( + x: rect.minX + nx * rect.width, + y: rect.minY + (1.0 - ny) * rect.height + ) + } + + let path = NSBezierPath() + path.move(to: map(384.0, 255.0)) + path.line(to: map(753.0, 511.5)) + path.line(to: map(384.0, 768.0)) + path.line(to: map(384.0, 654.0)) + path.line(to: map(582.692, 511.5)) + path.line(to: map(384.0, 369.0)) + path.close() + + NSColor.black.setFill() + path.fill() + } + + private static func drawBadge(text: String, in rect: NSRect, config: MenuBarBadgeRenderConfig) { + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + let fontSize: CGFloat = text.count > 1 ? config.multiDigitFontSize : config.singleDigitFontSize + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: fontSize, weight: .bold), + .foregroundColor: NSColor.systemBlue, + .paragraphStyle: paragraph, + ] + let yOffset: CGFloat = text.count > 1 ? config.multiDigitYOffset : config.singleDigitYOffset + let xAdjust: CGFloat = text.count > 1 ? config.multiDigitXAdjust : config.singleDigitXAdjust + let textRect = NSRect( + x: rect.origin.x + xAdjust, + y: rect.origin.y + yOffset, + width: rect.width + config.textRectWidthAdjust, + height: rect.height + ) + (text as NSString).draw(in: textRect, withAttributes: attrs) + } +} diff --git a/Sources/TerminalDirectoryOpener.swift b/Sources/TerminalDirectoryOpener.swift new file mode 100644 index 00000000000..f40f4ef0e85 --- /dev/null +++ b/Sources/TerminalDirectoryOpener.swift @@ -0,0 +1,297 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +enum FinderServicePathResolver { + private static func canonicalDirectoryPath(_ path: String) -> String { + guard path.count > 1 else { return path } + var canonical = path + while canonical.count > 1 && canonical.hasSuffix("/") { + canonical.removeLast() + } + return canonical + } + + private static func normalizedComparisonURL(_ url: URL) -> URL { + url.standardizedFileURL.resolvingSymlinksInPath() + } + + private static func isSameOrDescendant(_ url: URL, of rootURL: URL) -> Bool { + let urlPathComponents = normalizedComparisonURL(url).pathComponents + let rootPathComponents = normalizedComparisonURL(rootURL).pathComponents + guard urlPathComponents.count >= rootPathComponents.count else { return false } + return Array(urlPathComponents.prefix(rootPathComponents.count)) == rootPathComponents + } + + private static func resolvedDirectoryURL(from url: URL) -> URL { + let standardized = url.standardizedFileURL + if standardized.hasDirectoryPath { + return standardized + } + if let resourceValues = try? standardized.resourceValues(forKeys: [.isDirectoryKey]), + resourceValues.isDirectory == true { + return standardized + } + return standardized.deletingLastPathComponent() + } + + static func orderedUniqueDirectories( + from pathURLs: [URL], + excludingDescendantsOf excludedRootURLs: [URL] = [] + ) -> [String] { + var seen: Set = [] + var directories: [String] = [] + + for url in pathURLs { + let directoryURL = resolvedDirectoryURL(from: url) + guard !excludedRootURLs.contains(where: { isSameOrDescendant(directoryURL, of: $0) }) else { + continue + } + let path = canonicalDirectoryPath(directoryURL.path(percentEncoded: false)) + guard !path.isEmpty else { continue } + if seen.insert(path).inserted { + directories.append(path) + } + } + + return directories + } +} + +enum TerminalDirectoryOpenTarget: String, CaseIterable { + case androidStudio + case antigravity + case cursor + case finder + case ghostty + case intellij + case iterm2 + case terminal + case tower + case vscode + case vscodeInline + case warp + case windsurf + case xcode + case zed + + struct DetectionEnvironment { + let homeDirectoryPath: String + let fileExistsAtPath: (String) -> Bool + let isExecutableFileAtPath: (String) -> Bool + let applicationPathForName: (String) -> String? + + static let live = DetectionEnvironment( + homeDirectoryPath: FileManager.default.homeDirectoryForCurrentUser.path, + fileExistsAtPath: { FileManager.default.fileExists(atPath: $0) }, + isExecutableFileAtPath: { FileManager.default.isExecutableFile(atPath: $0) }, + applicationPathForName: { NSWorkspace.shared.fullPath(forApplication: $0) } + ) + } + + static var commandPaletteShortcutTargets: [Self] { + Array(allCases) + } + + static func availableTargets(in environment: DetectionEnvironment = .live) -> Set { + Set(commandPaletteShortcutTargets.filter { $0.isAvailable(in: environment) }) + } + + var commandPaletteCommandId: String { + "palette.terminalOpenDirectory.\(rawValue)" + } + + var commandPaletteTitle: String { + switch self { + case .androidStudio: + return String(localized: "menu.openInAndroidStudio", defaultValue: "Open Current Directory in Android Studio") + case .antigravity: + return String(localized: "menu.openInAntigravity", defaultValue: "Open Current Directory in Antigravity") + case .cursor: + return String(localized: "menu.openInCursor", defaultValue: "Open Current Directory in Cursor") + case .finder: + return String(localized: "menu.openInFinder", defaultValue: "Open Current Directory in Finder") + case .ghostty: + return String(localized: "menu.openInGhostty", defaultValue: "Open Current Directory in Ghostty") + case .intellij: + return String(localized: "menu.openInIntelliJ", defaultValue: "Open Current Directory in IntelliJ IDEA") + case .iterm2: + return String(localized: "menu.openInITerm2", defaultValue: "Open Current Directory in iTerm2") + case .terminal: + return String(localized: "menu.openInTerminal", defaultValue: "Open Current Directory in Terminal") + case .tower: + return String(localized: "menu.openInTower", defaultValue: "Open Current Directory in Tower") + case .vscode: + return String(localized: "menu.openInVSCodeDesktop", defaultValue: "Open Current Directory in VS Code") + case .vscodeInline: + return String(localized: "menu.openInVSCode", defaultValue: "Open Current Directory in VS Code (Inline)") + case .warp: + return String(localized: "menu.openInWarp", defaultValue: "Open Current Directory in Warp") + case .windsurf: + return String(localized: "menu.openInWindsurf", defaultValue: "Open Current Directory in Windsurf") + case .xcode: + return String(localized: "menu.openInXcode", defaultValue: "Open Current Directory in Xcode") + case .zed: + return String(localized: "menu.openInZed", defaultValue: "Open Current Directory in Zed") + } + } + + var commandPaletteKeywords: [String] { + let common = ["terminal", "directory", "open", "ide"] + switch self { + case .androidStudio: + return common + ["android", "studio"] + case .antigravity: + return common + ["antigravity"] + case .cursor: + return common + ["cursor"] + case .finder: + return common + ["finder", "file", "manager", "reveal"] + case .ghostty: + return common + ["ghostty", "terminal", "shell"] + case .intellij: + return common + ["intellij", "idea", "jetbrains"] + case .iterm2: + return common + ["iterm", "iterm2", "terminal", "shell"] + case .terminal: + return common + ["terminal", "shell"] + case .tower: + return common + ["tower", "git", "client"] + case .vscode: + return common + ["vs", "code", "visual", "studio", "desktop", "app"] + case .vscodeInline: + return common + ["vs", "code", "visual", "studio", "inline", "browser", "serve-web"] + case .warp: + return common + ["warp", "terminal", "shell"] + case .windsurf: + return common + ["windsurf"] + case .xcode: + return common + ["xcode", "apple"] + case .zed: + return common + ["zed"] + } + } + + func isAvailable(in environment: DetectionEnvironment = .live) -> Bool { + guard let applicationPath = applicationPath(in: environment) else { return false } + guard self == .vscodeInline else { return true } + return VSCodeCLILaunchConfigurationBuilder.launchConfiguration( + vscodeApplicationURL: URL(fileURLWithPath: applicationPath, isDirectory: true), + isExecutableAtPath: environment.isExecutableFileAtPath + ) != nil + } + + func applicationURL(in environment: DetectionEnvironment = .live) -> URL? { + guard let path = applicationPath(in: environment) else { return nil } + return URL(fileURLWithPath: path, isDirectory: true) + } + + private func applicationPath(in environment: DetectionEnvironment) -> String? { + for path in expandedCandidatePaths(in: environment) where environment.fileExistsAtPath(path) { + return path + } + + // Fall back to LaunchServices so apps outside the standard bundle paths + // still appear in the command palette. + for applicationName in applicationSearchNames { + guard let resolvedPath = environment.applicationPathForName(applicationName), + environment.fileExistsAtPath(resolvedPath) else { + continue + } + return resolvedPath + } + + return nil + } + + private func expandedCandidatePaths(in environment: DetectionEnvironment) -> [String] { + let globalPrefix = "/Applications/" + let userPrefix = "\(environment.homeDirectoryPath)/Applications/" + var expanded: [String] = [] + + for candidate in applicationBundlePathCandidates { + expanded.append(candidate) + if candidate.hasPrefix(globalPrefix) { + let suffix = String(candidate.dropFirst(globalPrefix.count)) + expanded.append(userPrefix + suffix) + } + } + + return uniquePreservingOrder(expanded) + } + + private var applicationSearchNames: [String] { + uniquePreservingOrder( + applicationBundlePathCandidates.map { + URL(fileURLWithPath: $0).deletingPathExtension().lastPathComponent + } + ) + } + + private var applicationBundlePathCandidates: [String] { + switch self { + case .androidStudio: + return ["/Applications/Android Studio.app"] + case .antigravity: + return ["/Applications/Antigravity.app"] + case .cursor: + return [ + "/Applications/Cursor.app", + "/Applications/Cursor Preview.app", + "/Applications/Cursor Nightly.app", + ] + case .finder: + return ["/System/Library/CoreServices/Finder.app"] + case .ghostty: + return ["/Applications/Ghostty.app"] + case .intellij: + return ["/Applications/IntelliJ IDEA.app"] + case .iterm2: + return [ + "/Applications/iTerm.app", + "/Applications/iTerm2.app", + ] + case .terminal: + return ["/System/Applications/Utilities/Terminal.app"] + case .tower: + return ["/Applications/Tower.app"] + case .vscode: + return [ + "/Applications/Visual Studio Code.app", + "/Applications/Code.app", + ] + case .vscodeInline: + return [ + "/Applications/Visual Studio Code.app", + "/Applications/Code.app", + ] + case .warp: + return ["/Applications/Warp.app"] + case .windsurf: + return ["/Applications/Windsurf.app"] + case .xcode: + return ["/Applications/Xcode.app"] + case .zed: + return [ + "/Applications/Zed.app", + "/Applications/Zed Preview.app", + "/Applications/Zed Nightly.app", + ] + } + } + + private func uniquePreservingOrder(_ paths: [String]) -> [String] { + var seen: Set = [] + var deduped: [String] = [] + for path in paths where seen.insert(path).inserted { + deduped.append(path) + } + return deduped + } +} diff --git a/Sources/TypingProfiler.swift b/Sources/TypingProfiler.swift new file mode 100644 index 00000000000..a6406fa107c --- /dev/null +++ b/Sources/TypingProfiler.swift @@ -0,0 +1,354 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +#if DEBUG +enum ProgramaTypingTiming { + static let isEnabled: Bool = { + let environment = ProcessInfo.processInfo.environment + if environment["PROGRAMA_TYPING_TIMING_LOGS"] == "1" || environment["PROGRAMA_KEY_LATENCY_PROBE"] == "1" { + return true + } + let defaults = UserDefaults.standard + return defaults.bool(forKey: "programaTypingTimingLogs") || defaults.bool(forKey: "programaKeyLatencyProbe") + }() + static let isVerboseProbeEnabled: Bool = { + let environment = ProcessInfo.processInfo.environment + if environment["PROGRAMA_KEY_LATENCY_PROBE"] == "1" { + return true + } + return UserDefaults.standard.bool(forKey: "programaKeyLatencyProbe") + }() + 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) { + ProgramaMainThreadTurnProfiler.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 ProgramaMainRunLoopStallMonitor { + static let shared = ProgramaMainRunLoopStallMonitor() + + 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 ProgramaTypingTiming.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 ProgramaMainThreadTurnProfiler { + static let shared = ProgramaMainThreadTurnProfiler() + + 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, ProgramaTypingTiming.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 ProgramaTypingTiming.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 diff --git a/Sources/VSCodeIntegration.swift b/Sources/VSCodeIntegration.swift new file mode 100644 index 00000000000..0b5bb6cdc7a --- /dev/null +++ b/Sources/VSCodeIntegration.swift @@ -0,0 +1,593 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +enum VSCodeServeWebURLBuilder { + static func extractWebUIURL(from output: String) -> URL? { + let prefix = "Web UI available at " + for line in output.split(whereSeparator: \.isNewline).reversed() { + guard let range = line.range(of: prefix) else { continue } + let rawURL = line[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines) + guard !rawURL.isEmpty, let url = URL(string: rawURL) else { continue } + return url + } + return nil + } + + static func openFolderURL(baseWebUIURL: URL, directoryPath: String) -> URL? { + var components = URLComponents(url: baseWebUIURL, resolvingAgainstBaseURL: false) + var queryItems = components?.queryItems ?? [] + queryItems.removeAll { $0.name == "folder" } + queryItems.append(URLQueryItem(name: "folder", value: directoryPath)) + components?.queryItems = queryItems + return components?.url + } +} + +struct VSCodeCLILaunchConfiguration { + let executableURL: URL + let argumentsPrefix: [String] + let environment: [String: String] +} + +enum VSCodeCLILaunchConfigurationBuilder { + static func launchConfiguration( + vscodeApplicationURL: URL, + baseEnvironment: [String: String] = ProcessInfo.processInfo.environment, + isExecutableAtPath: (String) -> Bool = { FileManager.default.isExecutableFile(atPath: $0) } + ) -> VSCodeCLILaunchConfiguration? { + let contentsURL = vscodeApplicationURL.appendingPathComponent("Contents", isDirectory: true) + let codeTunnelURL = contentsURL.appendingPathComponent("Resources/app/bin/code-tunnel", isDirectory: false) + guard isExecutableAtPath(codeTunnelURL.path) else { return nil } + + var environment = baseEnvironment + environment["ELECTRON_RUN_AS_NODE"] = "1" + environment.removeValue(forKey: "VSCODE_NODE_OPTIONS") + environment.removeValue(forKey: "VSCODE_NODE_REPL_EXTERNAL_MODULE") + if let nodeOptions = environment["NODE_OPTIONS"] { + environment["VSCODE_NODE_OPTIONS"] = nodeOptions + } + if let nodeReplExternalModule = environment["NODE_REPL_EXTERNAL_MODULE"] { + environment["VSCODE_NODE_REPL_EXTERNAL_MODULE"] = nodeReplExternalModule + } + environment.removeValue(forKey: "NODE_OPTIONS") + environment.removeValue(forKey: "NODE_REPL_EXTERNAL_MODULE") + environment["VSCODE_CLI_USE_FILE_KEYRING"] = "1" + + return VSCodeCLILaunchConfiguration( + executableURL: codeTunnelURL, + argumentsPrefix: [], + environment: environment + ) + } +} + +final class VSCodeServeWebController { + static let shared = VSCodeServeWebController() + private static let serveWebStartupTimeoutSeconds: TimeInterval = 60 + + private let queue = DispatchQueue(label: "programa.vscode.serveWeb") + private let launchQueue = DispatchQueue(label: "programa.vscode.serveWeb.launch") + private let launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? + private var serveWebProcess: Process? + private var launchingProcess: Process? + private var connectionTokenFilesByProcessID: [ObjectIdentifier: URL] = [:] + private var serveWebURL: URL? + private var pendingCompletions: [(generation: UInt64, completion: (URL?) -> Void)] = [] + private var isLaunching = false + private var activeLaunchGeneration: UInt64? + private var lifecycleGeneration: UInt64 = 0 +#if DEBUG + private var testingTrackedProcesses: [Process] = [] +#endif + + private init(launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? = nil) { + self.launchProcessOverride = launchProcessOverride + } + +#if DEBUG + static func makeForTesting( + launchProcessOverride: @escaping (URL, UInt64) -> (process: Process, url: URL)? + ) -> VSCodeServeWebController { + VSCodeServeWebController(launchProcessOverride: launchProcessOverride) + } + + func trackConnectionTokenFileForTesting( + _ connectionTokenFileURL: URL, + setAsLaunchingProcess: Bool = false, + setAsServeWebProcess: Bool = false + ) { + let process = Process() + queue.sync { + if setAsLaunchingProcess { + self.launchingProcess = process + } + if setAsServeWebProcess { + self.serveWebProcess = process + } + if !setAsLaunchingProcess && !setAsServeWebProcess { + self.testingTrackedProcesses.append(process) + } + self.connectionTokenFilesByProcessID[ObjectIdentifier(process)] = connectionTokenFileURL + } + } +#endif + + func ensureServeWebURL(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) { + queue.async { + if let process = self.serveWebProcess, + process.isRunning, + let url = self.serveWebURL { + DispatchQueue.main.async { + completion(url) + } + return + } + + let completionGeneration = self.lifecycleGeneration + self.pendingCompletions.append((generation: completionGeneration, completion: completion)) + guard !self.isLaunching else { return } + + self.isLaunching = true + let launchGeneration = completionGeneration + self.activeLaunchGeneration = launchGeneration + + self.launchQueue.async { + let shouldLaunch = self.queue.sync { + self.lifecycleGeneration == launchGeneration + } + guard shouldLaunch else { + self.queue.async { + guard self.activeLaunchGeneration == launchGeneration else { return } + self.isLaunching = false + self.activeLaunchGeneration = nil + } + return + } + let launchResult = self.launchServeWebProcess( + vscodeApplicationURL: vscodeApplicationURL, + expectedGeneration: launchGeneration + ) + self.queue.async { + guard self.activeLaunchGeneration == launchGeneration else { + if let process = launchResult?.process, process.isRunning { + process.terminate() + } + return + } + self.isLaunching = false + self.activeLaunchGeneration = nil + + guard self.lifecycleGeneration == launchGeneration else { + if let launchedProcess = launchResult?.process, + self.launchingProcess === launchedProcess { + self.launchingProcess = nil + } + if let process = launchResult?.process, process.isRunning { + process.terminate() + } + return + } + + if let launchResult { + self.launchingProcess = nil + self.serveWebProcess = launchResult.process + self.serveWebURL = launchResult.url + } else { + self.launchingProcess = nil + self.serveWebProcess = nil + self.serveWebURL = nil + } + + var completions: [(URL?) -> Void] = [] + var remaining: [(generation: UInt64, completion: (URL?) -> Void)] = [] + for pending in self.pendingCompletions { + if pending.generation == launchGeneration { + completions.append(pending.completion) + } else { + remaining.append(pending) + } + } + self.pendingCompletions = remaining + let resolvedURL = self.serveWebURL + DispatchQueue.main.async { + completions.forEach { $0(resolvedURL) } + } + } + } + } + } + + func stop() { + let (processes, tokenFileURLs, completions): ([Process], [URL], [(URL?) -> Void]) = queue.sync { + self.lifecycleGeneration &+= 1 + self.isLaunching = false + self.activeLaunchGeneration = nil + var processes: [Process] = [] + if let process = self.serveWebProcess { + processes.append(process) + } + if let process = self.launchingProcess, + !processes.contains(where: { $0 === process }) { + processes.append(process) + } + self.serveWebProcess = nil + self.launchingProcess = nil +#if DEBUG + self.testingTrackedProcesses.removeAll() +#endif + var tokenFileURLs = processes.compactMap { + self.connectionTokenFilesByProcessID.removeValue(forKey: ObjectIdentifier($0)) + } + tokenFileURLs.append(contentsOf: self.connectionTokenFilesByProcessID.values) + self.connectionTokenFilesByProcessID.removeAll() + self.serveWebURL = nil + let completions = self.pendingCompletions.map(\.completion) + self.pendingCompletions.removeAll() + return (processes, tokenFileURLs, completions) + } + + for tokenFileURL in tokenFileURLs where tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { + Self.removeConnectionTokenFile(at: tokenFileURL) + } + + for process in processes where process.isRunning { + process.terminate() + } + + if !completions.isEmpty { + DispatchQueue.main.async { + completions.forEach { $0(nil) } + } + } + } + + func restart(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) { + stop() + ensureServeWebURL(vscodeApplicationURL: vscodeApplicationURL, completion: completion) + } + + private func launchServeWebProcess( + vscodeApplicationURL: URL, + expectedGeneration: UInt64 + ) -> (process: Process, url: URL)? { + if let launchProcessOverride { + return launchProcessOverride(vscodeApplicationURL, expectedGeneration) + } + + guard let launchConfiguration = VSCodeCLILaunchConfigurationBuilder.launchConfiguration( + vscodeApplicationURL: vscodeApplicationURL + ) else { return nil } + + guard let connectionTokenFileURL = Self.makeConnectionTokenFile() else { + return nil + } + + let process = Process() + process.executableURL = launchConfiguration.executableURL + // #21: reuse the port VS Code assigned on a previous run so the embedded browser + // keeps the same URL across restarts. ServeWebPortStore returns the persisted port + // only when it is still bindable, otherwise "0" (OS-assigned) — so a now-occupied + // port falls back gracefully instead of failing the launch. The --server-data-dir + // and persistent connection-token already fix Settings Sync / OAuth auth. + process.arguments = launchConfiguration.argumentsPrefix + [ + "serve-web", + "--accept-server-license-terms", + "--host", "127.0.0.1", + "--port", ServeWebPortStore.portArgument(persistedIn: Self.vscodeServerDataDir), + "--server-data-dir", Self.vscodeServerDataDir?.path ?? NSTemporaryDirectory(), + "--connection-token-file", connectionTokenFileURL.path, + ] + process.environment = launchConfiguration.environment + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let collector = ServeWebOutputCollector() + let outputReader: (FileHandle) -> Void = { fileHandle in + let data = fileHandle.availableData + guard !data.isEmpty else { return } + collector.append(data) + } + stdoutPipe.fileHandleForReading.readabilityHandler = outputReader + stderrPipe.fileHandleForReading.readabilityHandler = outputReader + + process.terminationHandler = { [weak self] terminatedProcess in + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + Self.drainAvailableOutput(from: stdoutPipe.fileHandleForReading, collector: collector) + Self.drainAvailableOutput(from: stderrPipe.fileHandleForReading, collector: collector) + collector.markProcessExited() + self?.queue.async { + guard let self else { return } + if self.launchingProcess === terminatedProcess { + self.launchingProcess = nil + } + if self.serveWebProcess === terminatedProcess { + self.serveWebProcess = nil + self.serveWebURL = nil + } + if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( + forKey: ObjectIdentifier(terminatedProcess) + ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { + Self.removeConnectionTokenFile(at: tokenFileURL) + } + } + } + + let didStart: Bool = queue.sync { + guard self.lifecycleGeneration == expectedGeneration, + self.activeLaunchGeneration == expectedGeneration else { + return false + } + self.launchingProcess = process + self.connectionTokenFilesByProcessID[ObjectIdentifier(process)] = connectionTokenFileURL + do { + try process.run() + return true + } catch { + if self.launchingProcess === process { + self.launchingProcess = nil + } + if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( + forKey: ObjectIdentifier(process) + ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { + Self.removeConnectionTokenFile(at: tokenFileURL) + } + return false + } + } + guard didStart else { + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + if connectionTokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { + Self.removeConnectionTokenFile(at: connectionTokenFileURL) + } + return nil + } + + guard collector.waitForURL(timeoutSeconds: Self.serveWebStartupTimeoutSeconds), + let serveWebURL = collector.webUIURL else { + stdoutPipe.fileHandleForReading.readabilityHandler = nil + stderrPipe.fileHandleForReading.readabilityHandler = nil + if process.isRunning { + process.terminate() + } else { + queue.sync { + if self.launchingProcess === process { + self.launchingProcess = nil + } + if self.serveWebProcess === process { + self.serveWebProcess = nil + self.serveWebURL = nil + } + if let tokenFileURL = self.connectionTokenFilesByProcessID.removeValue( + forKey: ObjectIdentifier(process) + ), tokenFileURL.path.hasPrefix(NSTemporaryDirectory()) { + Self.removeConnectionTokenFile(at: tokenFileURL) + } + } + } + return nil + } + + // #21: remember the assigned port so the next launch can request it again. + if let assignedPort = serveWebURL.port { + ServeWebPortStore.persist(port: assignedPort, in: Self.vscodeServerDataDir) + } + + return (process, serveWebURL) + } + + private static func drainAvailableOutput(from fileHandle: FileHandle, collector: ServeWebOutputCollector) { + while true { + let data = fileHandle.availableData + guard !data.isEmpty else { return } + collector.append(data) + } + } + + /// Stable Application Support directory for VS Code serve-web state. + /// Mirrors the "programa" subdirectory convention used elsewhere in the app. + private static var vscodeServerDataDir: URL? { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first? + .appendingPathComponent("programa", isDirectory: true) + .appendingPathComponent("vscode-server", isDirectory: true) + } + + private static func randomConnectionToken() -> String { + UUID().uuidString.replacingOccurrences(of: "-", with: "") + } + + /// Returns a URL for the connection token file. Prefers a persistent file + /// under Application Support so the token (and the browser's vscode-tkn + /// cookie) survives Programa restarts (issue #21). Falls back to an + /// ephemeral temp-dir file when Application Support is unavailable. + private static func makeConnectionTokenFile() -> URL? { + if let persistentURL = vscodeServerDataDir? + .appendingPathComponent("connection-token", isDirectory: false) { + if let url = makePersistentConnectionTokenFile(at: persistentURL) { + return url + } + } + return makeEphemeralConnectionTokenFile() + } + + private static func makePersistentConnectionTokenFile(at tokenFileURL: URL) -> URL? { + let dir = tokenFileURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + // Reuse an existing non-empty token so the browser cookie stays valid. + if let existingData = try? Data(contentsOf: tokenFileURL), !existingData.isEmpty { + return tokenFileURL + } + let token = randomConnectionToken() + guard let tokenData = token.data(using: .utf8) else { return nil } + let fd = open(tokenFileURL.path, O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR) + guard fd >= 0 else { return nil } + defer { _ = close(fd) } + let wroteAllBytes = tokenData.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return false } + return write(fd, baseAddress, rawBuffer.count) == rawBuffer.count + } + guard wroteAllBytes else { + try? FileManager.default.removeItem(at: tokenFileURL) + return nil + } + return tokenFileURL + } + + private static func makeEphemeralConnectionTokenFile() -> URL? { + let token = randomConnectionToken() + let tokenFileName = "cmux-vscode-token-\(UUID().uuidString)" + let tokenFileURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(tokenFileName, isDirectory: false) + guard let tokenData = token.data(using: .utf8) else { return nil } + let fileDescriptor = open(tokenFileURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR) + guard fileDescriptor >= 0 else { return nil } + defer { _ = close(fileDescriptor) } + let wroteAllBytes = tokenData.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return false } + return write(fileDescriptor, baseAddress, rawBuffer.count) == rawBuffer.count + } + guard wroteAllBytes else { + removeConnectionTokenFile(at: tokenFileURL) + return nil + } + return tokenFileURL + } + + private static func removeConnectionTokenFile(at url: URL) { + try? FileManager.default.removeItem(at: url) + } +} + +final class ServeWebOutputCollector { + private let lock = NSLock() + private let semaphore = DispatchSemaphore(value: 0) + private var outputBuffer = "" + private var resolvedURL: URL? + private var didSignal = false + + var webUIURL: URL? { + lock.lock() + defer { lock.unlock() } + return resolvedURL + } + + func append(_ data: Data) { + guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return } + lock.lock() + defer { lock.unlock() } + guard resolvedURL == nil else { return } + outputBuffer.append(text) + while let newlineIndex = outputBuffer.firstIndex(where: \.isNewline) { + let line = String(outputBuffer[.. Bool { + if webUIURL != nil { return true } + _ = semaphore.wait(timeout: .now() + timeoutSeconds) + return webUIURL != nil + } +} + +/// Persists the VS Code serve-web port across restarts (#21) so the embedded browser +/// keeps the same URL. The port `code serve-web` assigns is written under the serve-web +/// data dir and reused on the next launch — but only when it is still bindable, so a +/// now-occupied port falls back to an OS-assigned one instead of failing the launch. +enum ServeWebPortStore { + static let fileName = "serve-web-port" + + /// The `--port` argument for `code serve-web`: the persisted port when it is valid and + /// currently free, otherwise "0" (let the OS assign one). `isPortAvailable` is injectable + /// for testing; it defaults to a real loopback bind probe. + static func portArgument( + persistedIn directory: URL?, + isPortAvailable: (Int) -> Bool = ServeWebPortStore.isPortAvailable + ) -> String { + guard let url = portFileURL(in: directory), + let data = try? Data(contentsOf: url), + let raw = String(data: data, encoding: .utf8), + let port = parsePort(raw), + isPortAvailable(port) else { + return "0" + } + return String(port) + } + + /// Records the port VS Code assigned so the next launch can request it again. No-op for + /// out-of-range ports or when the data dir is unavailable. + static func persist(port: Int, in directory: URL?) { + guard isValidPort(port), let url = portFileURL(in: directory) else { return } + let dir = url.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try? Data(String(port).utf8).write(to: url, options: .atomic) + } + + /// Parses a stored port string, returning nil for malformed or out-of-range values. + static func parsePort(_ raw: String) -> Int? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard let port = Int(trimmed), isValidPort(port) else { return nil } + return port + } + + /// True when a TCP socket can bind 127.0.0.1:port right now. + static func isPortAvailable(_ port: Int) -> Bool { + guard isValidPort(port) else { return false } + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return false } + defer { close(fd) } + var reuse: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size)) + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = in_port_t(UInt16(port)).bigEndian + addr.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &addr) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + bind(fd, sockaddrPointer, socklen_t(MemoryLayout.size)) + } + } + return bound == 0 + } + + private static func isValidPort(_ port: Int) -> Bool { (1...65535).contains(port) } + + private static func portFileURL(in directory: URL?) -> URL? { + directory?.appendingPathComponent(fileName, isDirectory: false) + } +} diff --git a/Sources/WindowSwizzles.swift b/Sources/WindowSwizzles.swift new file mode 100644 index 00000000000..6e67f8ae87c --- /dev/null +++ b/Sources/WindowSwizzles.swift @@ -0,0 +1,734 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +#if DEBUG +// Widened from `private` to `internal`: AppDelegate.setWindowFirstResponderGuardTesting(...) +// / .clearWindowFirstResponderGuardTesting() (in AppDelegate.swift) write these directly. Refs #95. +var programaFirstResponderGuardCurrentEventOverride: NSEvent? +var programaFirstResponderGuardHitViewOverride: NSView? +#endif +private var programaFirstResponderGuardCurrentEventContext: NSEvent? +private var programaFirstResponderGuardHitViewContext: NSView? +private var programaFirstResponderGuardContextWindowNumber: Int? +private var programaBrowserReturnForwardingDepth = 0 +private var programaWindowFirstResponderBypassDepth = 0 +private var programaFieldEditorOwningWebViewAssociationKey: UInt8 = 0 + +@discardableResult +func cmuxWithWindowFirstResponderBypass(_ body: () -> T) -> T { + programaWindowFirstResponderBypassDepth += 1 + defer { + programaWindowFirstResponderBypassDepth = max(0, programaWindowFirstResponderBypassDepth - 1) + } + return body() +} + +func programaIsWindowFirstResponderBypassActive() -> Bool { + programaWindowFirstResponderBypassDepth > 0 +} + +private final class ProgramaFieldEditorOwningWebViewBox: NSObject { + weak var webView: ProgramaWebView? + + init(webView: ProgramaWebView?) { + self.webView = webView + } +} + +// Widened from `private extension` to `extension`: AppDelegate.installWindowResponderSwizzles() +// (in AppDelegate.swift) references these @objc methods via #selector(...) for method swizzling. Refs #95. +extension NSApplication { + @objc func programa_applicationSendEvent(_ event: NSEvent) { +#if DEBUG + let typingTimingStart = event.type == .keyDown ? ProgramaTypingTiming.start() : nil + let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 + if event.type == .keyDown { + ProgramaTypingTiming.logEventDelay(path: "app.sendEvent", event: event) + } + defer { + if event.type == .keyDown { + let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0 + ProgramaTypingTiming.logBreakdown( + path: "app.sendEvent.phase", + totalMs: totalMs, + event: event, + thresholdMs: 1.0, + parts: [("dispatchMs", totalMs)] + ) + ProgramaTypingTiming.logDuration( + path: "app.sendEvent", + startedAt: typingTimingStart, + event: event + ) + } + } +#endif + programa_applicationSendEvent(event) + } +} + +// Widened from `private extension` to `extension`: AppDelegate.installWindowResponderSwizzles() +// (in AppDelegate.swift) references these @objc methods via #selector(...) for method swizzling. Refs #95. +extension NSWindow { + @objc func programa_makeFirstResponder(_ responder: NSResponder?) -> Bool { + if programaIsWindowFirstResponderBypassActive() { +#if DEBUG + dlog( + "focus.guard bypassFirstResponder responder=\(String(describing: responder.map { type(of: $0) })) " + + "window=\(ObjectIdentifier(self))" + ) +#endif + return false + } + + let currentEvent = Self.programaCurrentEvent(for: self) + let responderWebView = responder.flatMap { + Self.programaOwningWebView(for: $0, in: self, event: currentEvent) + } + var pointerInitiatedWebFocus = false + + if AppDelegate.shared?.shouldBlockFirstResponderChangeWhileCommandPaletteVisible( + window: self, + responder: responder + ) == true { +#if DEBUG + dlog( + "focus.guard commandPaletteBlocked responder=\(String(describing: responder.map { type(of: $0) })) " + + "window=\(ObjectIdentifier(self))" + ) +#endif + return false + } + + if let responder, + let webView = responderWebView, + !webView.allowsFirstResponderAcquisitionEffective { + let pointerInitiatedFocus = Self.programaShouldAllowPointerInitiatedWebViewFocus( + window: self, + webView: webView, + event: currentEvent + ) + if pointerInitiatedFocus { + pointerInitiatedWebFocus = true +#if DEBUG + dlog( + "focus.guard allowPointerFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self)) " + + "web=\(ObjectIdentifier(webView)) " + + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + + "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" + ) +#endif + } else { +#if DEBUG + dlog( + "focus.guard blockedFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self)) " + + "web=\(ObjectIdentifier(webView)) " + + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth) " + + "eventType=\(currentEvent.map { String(describing: $0.type) } ?? "nil")" + ) +#endif + return false + } + } +#if DEBUG + if let responder, + let webView = responderWebView { + dlog( + "focus.guard allowFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self)) " + + "web=\(ObjectIdentifier(webView)) " + + "policy=\(webView.allowsFirstResponderAcquisition ? 1 : 0) " + + "pointerDepth=\(webView.debugPointerFocusAllowanceDepth)" + ) + } +#endif + let result: Bool + if pointerInitiatedWebFocus, let webView = responderWebView { + // `NSWindow.makeFirstResponder` may run before `ProgramaWebView.mouseDown(with:)`. + // Preserve pointer intent during this synchronous responder change. + result = webView.withPointerFocusAllowance { + programa_makeFirstResponder(responder) + } + } else { + result = programa_makeFirstResponder(responder) + } + if result { + if let fieldEditor = responder as? NSTextView, fieldEditor.isFieldEditor { + Self.programaTrackFieldEditor(fieldEditor, owningWebView: responderWebView) + } else if let fieldEditor = self.firstResponder as? NSTextView, fieldEditor.isFieldEditor { + Self.programaTrackFieldEditor(fieldEditor, owningWebView: responderWebView) + } + } + return result + } + + @objc func programa_sendEvent(_ event: NSEvent) { +#if DEBUG + let typingTimingStart = event.type == .keyDown ? ProgramaTypingTiming.start() : nil + let phaseTotalStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 + var contextSetupMs: Double = 0 + var focusRepairMs: 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.programaOwningWebView(for: $0, in: self, event: event) + } + let hitWebView = Self.programaHitViewForEventDispatch(in: self, event: event).flatMap { + Self.programaOwningWebView(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 { + ProgramaTypingTiming.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 + ProgramaTypingTiming.logBreakdown( + path: "window.sendEvent.phase", + totalMs: totalMs, + event: event, + thresholdMs: 1.0, + parts: [ + ("contextSetupMs", contextSetupMs), + ("focusRepairMs", focusRepairMs), + ("folderGuardMs", folderGuardMs), + ("originalDispatchMs", originalDispatchMs), + ], + extra: typingTimingExtra + ) + ProgramaTypingTiming.logDuration( + path: "window.sendEvent", + startedAt: typingTimingStart, + event: event, + extra: typingTimingExtra + ) + } + } + let contextSetupStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 +#endif + let previousContextEvent = programaFirstResponderGuardCurrentEventContext + let previousContextHitView = programaFirstResponderGuardHitViewContext + let previousContextWindowNumber = programaFirstResponderGuardContextWindowNumber + programaFirstResponderGuardCurrentEventContext = event + programaFirstResponderGuardHitViewContext = Self.programaHitViewForEventDispatch(in: self, event: event) + programaFirstResponderGuardContextWindowNumber = self.windowNumber +#if DEBUG + if event.type == .keyDown { + contextSetupMs = (ProcessInfo.processInfo.systemUptime - contextSetupStart) * 1000.0 + } + let focusRepairStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 +#endif + if event.type == .keyDown { + AppDelegate.shared?.repairFocusedTerminalKeyboardRoutingIfNeeded( + window: self, + event: event + ) + } +#if DEBUG + if event.type == .keyDown { + focusRepairMs = (ProcessInfo.processInfo.systemUptime - focusRepairStart) * 1000.0 + } + let folderGuardStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 +#endif + defer { + programaFirstResponderGuardCurrentEventContext = previousContextEvent + programaFirstResponderGuardHitViewContext = previousContextHitView + programaFirstResponderGuardContextWindowNumber = previousContextWindowNumber + } + + 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 + programa_sendEvent(event) + originalDispatchMs = (ProcessInfo.processInfo.systemUptime - originalDispatchStart) * 1000.0 + return + } +#endif + programa_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) + let previousMovableState = isMovable + if previousMovableState { + isMovable = false + } + + #if DEBUG + let hitDesc = hitView.map { String(describing: type(of: $0)) } ?? "nil" + dlog("window.sendEvent.folderDown suppress=1 hit=\(hitDesc) wasMovable=\(previousMovableState)") + #endif + + programa_sendEvent(event) +#if DEBUG + if event.type == .keyDown { + originalDispatchMs = (ProcessInfo.processInfo.systemUptime - originalDispatchStart) * 1000.0 + } +#endif + + if previousMovableState { + isMovable = previousMovableState + } + + #if DEBUG + dlog("window.sendEvent.folderDown restore nowMovable=\(isMovable)") + #endif + } + + @objc func programa_performKeyEquivalent(with event: NSEvent) -> Bool { +#if DEBUG + let typingTimingStart = ProgramaTypingTiming.start() + defer { + ProgramaTypingTiming.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 + + // When the terminal surface is the first responder, prevent SwiftUI's + // hosting view from consuming key events via performKeyEquivalent. + // After a browser panel (WKWebView) has been in the responder chain, + // SwiftUI's internal focus system can get into a broken state where it + // intercepts key events in the content view hierarchy, returns true + // (claiming consumption), but never actually fires the action closure. + // + // For non-Command keys: bypass the view hierarchy entirely and send + // directly to the terminal so arrow keys, Ctrl+N/P, etc. reach keyDown. + // + // For Command keys: bypass the SwiftUI content view hierarchy and + // dispatch directly to the main menu. No SwiftUI view should be handling + // Command shortcuts when the terminal is focused — the local event monitor + // (handleCustomShortcut) already handles app-level shortcuts, and anything + // remaining should be menu items. + let firstResponderGhosttyView = cmuxOwningGhosttyView(for: self.firstResponder) + let firstResponderWebView = self.firstResponder.flatMap { + Self.programaOwningWebView(for: $0, in: self, event: event) + } + let firstResponderHasMarkedText = browserResponderHasMarkedText(self.firstResponder) + if let ghosttyView = firstResponderGhosttyView { + // If the IME is composing and the key has no Cmd modifier, don't intercept — + // let it flow through normal AppKit event dispatch so the input method can + // process it. Cmd-based shortcuts should still work during composition since + // Cmd is never part of IME input sequences. + if ghosttyView.hasMarkedText(), !event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) { + return programa_performKeyEquivalent(with: event) + } + + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if !flags.contains(.command) { + let result = ghosttyView.performKeyEquivalent(with: event) +#if DEBUG + dlog(" → ghostty direct: \(result)") +#endif + return result + } + + // Preserve Ghostty's terminal font-size shortcuts (Cmd +/−/0) when + // the terminal is focused. Otherwise our browser menu shortcuts can + // consume the event even when no browser panel is focused. + if shouldRouteTerminalFontZoomShortcutToGhostty( + firstResponderIsGhostty: true, + flags: event.modifierFlags, + chars: event.charactersIgnoringModifiers ?? "", + keyCode: event.keyCode, + literalChars: event.characters + ) { + ghosttyView.keyDown(with: event) +#if DEBUG + dlog("zoom.shortcut stage=window.ghosttyKeyDownDirect event=\(Self.keyDescription(event)) handled=1") +#endif + return true + } + } + + // Web forms rely on Return/Enter flowing through keyDown. If the original + // NSWindow.performKeyEquivalent consumes Enter first, submission never reaches + // WebKit. Route Return/Enter directly to the current first responder and + // mark handled to avoid the AppKit alert sound path. + if shouldDispatchBrowserReturnViaFirstResponderKeyDown( + keyCode: event.keyCode, + firstResponderIsBrowser: firstResponderWebView != nil, + firstResponderHasMarkedText: firstResponderHasMarkedText, + flags: event.modifierFlags + ) { + // Forwarding keyDown can re-enter performKeyEquivalent in WebKit/AppKit internals. + // On re-entry, fall back to normal dispatch to avoid an infinite loop. + if programaBrowserReturnForwardingDepth > 0 { +#if DEBUG + dlog(" → browser Return/Enter reentry; using normal dispatch") +#endif + return false + } + programaBrowserReturnForwardingDepth += 1 + defer { programaBrowserReturnForwardingDepth = max(0, programaBrowserReturnForwardingDepth - 1) } +#if DEBUG + dlog(" → browser Return/Enter routed to firstResponder.keyDown") +#endif + self.firstResponder?.keyDown(with: event) + return true + } + + if let firstResponderWebView, + shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst( + event, + responder: self.firstResponder, + owningWebView: firstResponderWebView + ) { + let result = firstResponderWebView.performKeyEquivalent(with: event) +#if DEBUG + if result { + dlog(" → browser find command resolved before window menu path") + } else { + dlog(" → browser find command preflight left unclaimed; suppressing replay") + } +#endif + // The focused web view has already received this Find-family shortcut once. + // Do not fall through into the original NSWindow.performKeyEquivalent path, + // or WebKit can observe the same key equivalent a second time before AppKit + // reaches keyDown/menu fallback. + return true + } + + if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true { +#if DEBUG + dlog(" → consumed by handleBrowserSurfaceKeyEquivalent") +#endif + return true + } + + // When the terminal is focused, skip the full NSWindow.performKeyEquivalent + // (which walks the SwiftUI content view hierarchy) and dispatch Command-key + // events directly to the main menu. This avoids the broken SwiftUI focus path. + if firstResponderGhosttyView != nil, + shouldRouteCommandEquivalentDirectlyToMainMenu(event), + let mainMenu = NSApp.mainMenu { + let consumedByMenu = mainMenu.performKeyEquivalent(with: event) +#if DEBUG + if browserZoomShortcutTraceCandidate( + flags: event.modifierFlags, + chars: event.charactersIgnoringModifiers ?? "", + keyCode: event.keyCode, + literalChars: event.characters + ) { + dlog( + "zoom.shortcut stage=window.mainMenuBypass event=\(Self.keyDescription(event)) " + + "consumed=\(consumedByMenu ? 1 : 0) fr=GhosttyNSView" + ) + } +#endif + if !consumedByMenu { + // Fall through to the original performKeyEquivalent path below. + } else { +#if DEBUG + dlog(" → consumed by mainMenu (bypassed SwiftUI)") +#endif + return true + } + } + + let result = programa_performKeyEquivalent(with: event) +#if DEBUG + if result { dlog(" → consumed by original performKeyEquivalent") } +#endif + return result + } + + static func keyDescription(_ event: NSEvent) -> String { + var parts: [String] = [] + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags.contains(.command) { parts.append("Cmd") } + if flags.contains(.shift) { parts.append("Shift") } + if flags.contains(.option) { parts.append("Opt") } + if flags.contains(.control) { parts.append("Ctrl") } + let chars = event.charactersIgnoringModifiers ?? "?" + parts.append("'\(chars)'(\(event.keyCode))") + return parts.joined(separator: "+") + } + + private static func programaOwningWebView(for responder: NSResponder) -> ProgramaWebView? { + if let webView = responder as? ProgramaWebView { + return webView + } + + if let view = responder as? NSView, + let webView = programaOwningWebView(for: view) { + return webView + } + + // NSTextView.delegate is unsafe-unretained in AppKit. Reading it here while + // a responder chain is tearing down can trap with "unowned reference". + var current = responder.nextResponder + while let next = current { + if let webView = next as? ProgramaWebView { + return webView + } + if let view = next as? NSView, + let webView = programaOwningWebView(for: view) { + return webView + } + current = next.nextResponder + } + + return nil + } + + private static func programaOwningWebView( + for responder: NSResponder, + in window: NSWindow, + event: NSEvent? + ) -> ProgramaWebView? { + // Browser find runs in the portal slot alongside the hosted WKWebView. + // Treat its native field editor chain as browser chrome, not as web content, + // so Cmd+F can move first responder into the find field while web focus is suppressed. + if BrowserWindowPortalRegistry.searchOverlayPanelId(for: responder, in: window) != nil { + return nil + } + + if let webView = programaOwningWebView(for: responder) { + return webView + } + + guard let textView = responder as? NSTextView, textView.isFieldEditor else { + return nil + } + + if let event, + let hitWebView = programaPointerHitWebView(in: window, event: event) { + programaTrackFieldEditor(textView, owningWebView: hitWebView) + return hitWebView + } + + return programaTrackedOwningWebView(for: textView) + } + + private static func programaOwningWebView(for view: NSView) -> ProgramaWebView? { + if let webView = view as? ProgramaWebView { + return webView + } + + var current: NSView? = view.superview + while let candidate = current { + if let webView = candidate as? ProgramaWebView { + return webView + } + if String(describing: type(of: candidate)).contains("WindowBrowserSlotView"), + let portalWebView = programaUniqueBrowserWebView(in: candidate) { + // Portal-hosted browser chrome (for example the Cmd+F overlay) is a + // sibling of the hosted WKWebView inside WindowBrowserSlotView, not a + // descendant of it. Allow native text-entry controls in that slot to + // acquire first responder directly, but keep generic sibling views + // associated with the hosted web view so blocked browser focus policy + // still protects inspector/overlay chrome from stray focus changes. + if view === portalWebView || view.isDescendant(of: portalWebView) { + return portalWebView + } + if programaAllowsPortalSlotTextEntryFocus(view) { + return nil + } + return portalWebView + } + current = candidate.superview + } + + return nil + } + + private static func programaAllowsPortalSlotTextEntryFocus(_ view: NSView) -> Bool { + var current: NSView? = view + while let candidate = current { + if let textField = candidate as? NSTextField { + return textField.isEditable || textField.acceptsFirstResponder + } + if let textView = candidate as? NSTextView { + return textView.isEditable || textView.isSelectable || textView.isFieldEditor + } + current = candidate.superview + } + return false + } + + private static func programaUniqueBrowserWebView(in root: NSView) -> ProgramaWebView? { + var stack: [NSView] = [root] + var found: ProgramaWebView? + while let current = stack.popLast() { + if let webView = current as? ProgramaWebView { + if found == nil { + found = webView + } else if found !== webView { + return nil + } + } + stack.append(contentsOf: current.subviews) + } + return found + } + + private static func programaCurrentEvent(for window: NSWindow) -> NSEvent? { +#if DEBUG + if let override = programaFirstResponderGuardCurrentEventOverride { + return override + } +#endif + if programaFirstResponderGuardContextWindowNumber == window.windowNumber { + return programaFirstResponderGuardCurrentEventContext + } + return NSApp.currentEvent + } + + private static func programaHitViewInThemeFrame(in window: NSWindow, event: NSEvent) -> NSView? { + guard let contentView = window.contentView, + let themeFrame = contentView.superview else { + return nil + } + let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil) + return themeFrame.hitTest(pointInTheme) + } + + private static func programaHitViewInContentView(in window: NSWindow, event: NSEvent) -> NSView? { + guard let contentView = window.contentView else { + return nil + } + let pointInContent = contentView.convert(event.locationInWindow, from: nil) + return contentView.hitTest(pointInContent) + } + + private static func programaTopHitViewForEvent(in window: NSWindow, event: NSEvent) -> NSView? { + if let hitInThemeFrame = programaHitViewInThemeFrame(in: window, event: event) { + return hitInThemeFrame + } + return programaHitViewInContentView(in: window, event: event) + } + + private static func programaHitViewForEventDispatch(in window: NSWindow, event: NSEvent) -> NSView? { + if event.windowNumber != 0, event.windowNumber != window.windowNumber { + return nil + } + if let eventWindow = event.window, eventWindow !== window { + return nil + } + return programaTopHitViewForEvent(in: window, event: event) + } + + private static func programaHitViewForCurrentEvent(in window: NSWindow, event: NSEvent) -> NSView? { +#if DEBUG + if let override = programaFirstResponderGuardHitViewOverride { + return override + } +#endif + if programaFirstResponderGuardContextWindowNumber == window.windowNumber, + let contextHitView = programaFirstResponderGuardHitViewContext { + return contextHitView + } + return programaTopHitViewForEvent(in: window, event: event) + } + + private static func programaTrackFieldEditor(_ fieldEditor: NSTextView, owningWebView webView: ProgramaWebView?) { + if let webView { + objc_setAssociatedObject( + fieldEditor, + &programaFieldEditorOwningWebViewAssociationKey, + ProgramaFieldEditorOwningWebViewBox(webView: webView), + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } else { + objc_setAssociatedObject( + fieldEditor, + &programaFieldEditorOwningWebViewAssociationKey, + nil, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + private static func programaTrackedOwningWebView(for fieldEditor: NSTextView) -> ProgramaWebView? { + guard let box = objc_getAssociatedObject( + fieldEditor, + &programaFieldEditorOwningWebViewAssociationKey + ) as? ProgramaFieldEditorOwningWebViewBox else { + return nil + } + guard let webView = box.webView else { + programaTrackFieldEditor(fieldEditor, owningWebView: nil) + return nil + } + return webView + } + + private static func programaIsPointerDownEvent(_ event: NSEvent) -> Bool { + switch event.type { + case .leftMouseDown, .rightMouseDown, .otherMouseDown: + return true + default: + return false + } + } + + private static func programaPointerHitWebView(in window: NSWindow, event: NSEvent) -> ProgramaWebView? { + guard programaIsPointerDownEvent(event) else { return nil } + if event.windowNumber != 0, event.windowNumber != window.windowNumber { + return nil + } + if let eventWindow = event.window, eventWindow !== window { + return nil + } + if let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint( + event.locationInWindow, + in: window + ) as? ProgramaWebView { + return portalWebView + } + guard let hitView = programaHitViewForCurrentEvent(in: window, event: event) else { + return nil + } + return programaOwningWebView(for: hitView) + } + + private static func programaShouldAllowPointerInitiatedWebViewFocus( + window: NSWindow, + webView: ProgramaWebView, + event: NSEvent? + ) -> Bool { + guard let event, + let hitWebView = programaPointerHitWebView(in: window, event: event) else { + return false + } + return hitWebView === webView + } + +} diff --git a/Sources/WorkspaceShortcutMapper.swift b/Sources/WorkspaceShortcutMapper.swift new file mode 100644 index 00000000000..22ee7da2769 --- /dev/null +++ b/Sources/WorkspaceShortcutMapper.swift @@ -0,0 +1,37 @@ +import AppKit +import SwiftUI +import Bonsplit +import CoreServices +import UserNotifications +import WebKit +import Combine +import ObjectiveC.runtime +import Darwin + +enum WorkspaceShortcutMapper { + /// Maps numbered workspace shortcuts to a zero-based workspace index. + /// 1...8 target fixed indices; 9 always targets the last workspace. + static func workspaceIndex(forDigit digit: Int, workspaceCount: Int) -> Int? { + guard workspaceCount > 0 else { return nil } + guard (1...9).contains(digit) else { return nil } + + if digit == 9 { + return workspaceCount - 1 + } + + let index = digit - 1 + return index < workspaceCount ? index : nil + } + + /// Returns the primary digit badge to display for a workspace row. + /// Picks the lowest digit that maps to that row index. + static func digitForWorkspace(at index: Int, workspaceCount: Int) -> Int? { + guard index >= 0 && index < workspaceCount else { return nil } + for digit in 1...9 { + if workspaceIndex(forDigit: digit, workspaceCount: workspaceCount) == index { + return digit + } + } + return nil + } +} From 32e8c204ee9e0d78922fdd69a7aeb15842c01e20 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:01:56 -0300 Subject: [PATCH 5/9] refactor: extract handleCustomShortcut app-shortcut dispatch table handleCustomShortcut(event:) was a single 966-line function with ~55 sequential if-checks. Documents the discovered precedence in a comment (palette > browser/terminal pre-checks > app-shortcut) and extracts the lowest-precedence phase -- the flat table of ~55 matchConfiguredShortcut / numberedConfiguredShortcutDigit / matchConfiguredDirectionalShortcut / matchTabShortcut checks -- into handleConfiguredAppShortcutActions(event: commandPaletteTargetWindow:hasFocusedAddressBarInShortcutContext:). This is pure tail-call code motion: the extracted function is byte-identical to the original tail of handleCustomShortcut (same statements, same order, same early returns), so evaluation order and behavior are unchanged. The higher-precedence palette and browser/terminal pre-check phases are left grouped in place per the conservative fallback (group into sub-functions without changing evaluation order) rather than risking a full reorder of a typing-latency-sensitive, tightly state-coupled dispatch chain. Refs #95. --- Sources/AppDelegate.swift | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0f6bb677d90..de59729e1da 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5981,6 +5981,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + // Precedence encoded by this function's evaluation order (each phase is checked + // strictly after the previous one; the first phase that returns wins). Refs #95. + // 1. Setup: chord-prefix bookkeeping, Ctrl+D debug probe, close-confirmation-alert + // passthrough, modal/sheet passthrough, command-palette window/state computation. + // 2. Palette (highest real precedence): Escape-key routing (palette dismiss / + // suppressed-escape grace window), palette selection-navigation (arrow keys), + // palette interactive Return/dismiss handling, stale browser-address-bar-focus + // clear, palette "effective" actions (open palette / go-to-workspace + their + // chord arming), shouldConsumeShortcutWhileCommandPaletteVisible catch-all. + // 3. Browser/terminal pre-checks: terminal IME marked-text passthrough, notifications + // popover escape/typing consumption, shortcut-routing-context sync guard, Ctrl+D + // terminal-focus reconcile bypass, browser omnibar Cmd/Ctrl+N/P and arrow-key + // selection, empty-flags fast-path passthrough, browser-address-bar Emacs-nav + // bypass. + // 4. App-shortcut (lowest precedence, only reached once nothing above claimed the + // event): the flat table of ~55 `matchConfiguredShortcut`/digit/directional/tab + // checks, extracted verbatim into handleConfiguredAppShortcutActions(event:...). private func handleCustomShortcut(event: NSEvent) -> Bool { // `charactersIgnoringModifiers` can be nil for some synthetic NSEvents and certain special keys. // Treat nil as "" and rely on keyCode/layout-aware fallback logic where needed. @@ -6389,6 +6406,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } + return handleConfiguredAppShortcutActions( + event: event, + commandPaletteTargetWindow: commandPaletteTargetWindow, + hasFocusedAddressBarInShortcutContext: hasFocusedAddressBarInShortcutContext + ) + } + + // Extracted verbatim from the tail of handleCustomShortcut(event:) -- the flat table of + // ~55 app-level shortcut checks (lowest precedence phase; only reached once the palette + // and browser/terminal pre-checks above have declined the event). Evaluation order is + // preserved exactly: this is pure code motion, not a reordering. Refs #95. + private func handleConfiguredAppShortcutActions( + event: NSEvent, + commandPaletteTargetWindow: NSWindow?, + hasFocusedAddressBarInShortcutContext: Bool + ) -> Bool { if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, armConfiguredShortcutChordIfNeeded(event: event) { return true From d161e9f5b945590fd689db024f8171be10089332 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:03:37 -0300 Subject: [PATCH 6/9] fix(contentview): widen access for symbols split across the #94 file moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TabItemView.swift, FileDropOverlayView.swift, and CommandPaletteSearchEngine.swift now live in their own files, so file-private symbols they depend on (or that depend on them) that remain in ContentView.swift need at least internal visibility: - SidebarDragAutoScrollController, SidebarTabItemSettingsSnapshot, SidebarTabDragPayload, BonsplitTabDragPayload, SidebarBonsplitTabDropDelegate, SidebarTabDropDelegate, MiddleClickCapture, MiddleClickCaptureView — used by the extracted TabItemView. - coloredCircleImage, debugCommandPaletteTextPreview, the Color(hex:) initializer — top-level helpers used by the extracted TabItemView. FileDropOverlayView.swift needs `import Bonsplit` for dlog() (per CLAUDE.md's dlog-needs-bonsplit-import note). No behavior changes — access-level widening only. Refs #94. --- Sources/ContentView.swift | 22 +++++++++++----------- Sources/FileDropOverlayView.swift | 1 + 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 656147c7d8c..4fb7fd19783 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7,7 +7,7 @@ import ObjectiveC import UniformTypeIdentifiers import WebKit -private extension Color { +extension Color { init?(hex: String) { let hex = hex.trimmingCharacters(in: .init(charactersIn: "#")) guard hex.count == 6, let value = UInt64(hex, radix: 16) else { return nil } @@ -19,7 +19,7 @@ private extension Color { } } -private func coloredCircleImage(color: NSColor) -> NSImage { +func coloredCircleImage(color: NSColor) -> NSImage { let size = NSSize(width: 14, height: 14) let image = NSImage(size: size, flipped: false) { rect in color.setFill() @@ -536,7 +536,7 @@ private func debugCommandPaletteKeyEventSummary(_ event: NSEvent) -> String { "chars=\(chars) charsIgnoring=\(charsIgnoring)" } -private func debugCommandPaletteTextPreview(_ text: String, limit: Int = 120) -> String { +func debugCommandPaletteTextPreview(_ text: String, limit: Int = 120) -> String { let escaped = text .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\n", with: "\\n") @@ -7262,7 +7262,7 @@ private struct SidebarResizerAccessibilityModifier: ViewModifier { } } -private struct SidebarTabItemSettingsSnapshot: Equatable { +struct SidebarTabItemSettingsSnapshot: Equatable { let sidebarShortcutHintXOffset: Double let sidebarShortcutHintYOffset: Double let alwaysShowShortcutHints: Bool @@ -8962,7 +8962,7 @@ enum SidebarDragAutoScrollPlanner { } @MainActor -private final class SidebarDragAutoScrollController: ObservableObject { +final class SidebarDragAutoScrollController: ObservableObject { private weak var scrollView: NSScrollView? private var timer: Timer? private var activePlan: SidebarAutoScrollPlan? @@ -9095,7 +9095,7 @@ private final class SidebarDragAutoScrollController: ObservableObject { } } -private enum SidebarTabDragPayload { +enum SidebarTabDragPayload { static let typeIdentifier = "com.darkroom.programa.sidebar-tab-reorder" static let dropContentType = UTType(exportedAs: typeIdentifier) static let dropContentTypes: [UTType] = [dropContentType] @@ -9112,7 +9112,7 @@ private enum SidebarTabDragPayload { } } -private enum BonsplitTabDragPayload { +enum BonsplitTabDragPayload { static let typeIdentifier = "com.splittabbar.tabtransfer" static let dropContentType = UTType(exportedAs: typeIdentifier) static let dropContentTypes: [UTType] = [dropContentType] @@ -9167,7 +9167,7 @@ private enum BonsplitTabDragPayload { } } -private struct SidebarBonsplitTabDropDelegate: DropDelegate { +struct SidebarBonsplitTabDropDelegate: DropDelegate { let targetWorkspaceId: UUID let tabManager: TabManager @Binding var selectedTabIds: Set @@ -9219,7 +9219,7 @@ private struct SidebarBonsplitTabDropDelegate: DropDelegate { } } -private struct SidebarTabDropDelegate: DropDelegate { +struct SidebarTabDropDelegate: DropDelegate { let targetTabId: UUID? let tabManager: TabManager @Binding var draggedTabId: UUID? @@ -9358,7 +9358,7 @@ private struct SidebarTabDropDelegate: DropDelegate { } } -private struct MiddleClickCapture: NSViewRepresentable { +struct MiddleClickCapture: NSViewRepresentable { let onMiddleClick: () -> Void func makeNSView(context: Context) -> MiddleClickCaptureView { @@ -9372,7 +9372,7 @@ private struct MiddleClickCapture: NSViewRepresentable { } } -private final class MiddleClickCaptureView: NSView { +final class MiddleClickCaptureView: NSView { var onMiddleClick: (() -> Void)? override func hitTest(_ point: NSPoint) -> NSView? { diff --git a/Sources/FileDropOverlayView.swift b/Sources/FileDropOverlayView.swift index 6d403707d14..dcff59c5369 100644 --- a/Sources/FileDropOverlayView.swift +++ b/Sources/FileDropOverlayView.swift @@ -2,6 +2,7 @@ // Pure move — behavior-identical relocation. import AppKit +import Bonsplit import WebKit From 5294e0c89708d8d50c3fc35f57e0ce2bac26414d Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:06:00 -0300 Subject: [PATCH 7/9] refactor: consolidate command-palette per-window state into one dictionary Replace 7 parallel [UUID: _] dictionaries (visibility, pendingOpen, recentRequestAt, escapeSuppression set, escapeSuppressionStartedAt, selection, snapshot) with a single CommandPaletteWindowState struct stored in one commandPaletteStateByWindowId: [UUID: CommandPaletteWindowState] dictionary. Adds three small helpers (commandPaletteState(for:), updateCommandPaletteState(for:_:), teardownCommandPaletteState(for:)) so the two previously-duplicated 7-line teardown blocks (window context discard, window close) collapse to a single teardownCommandPaletteState(for:) call each. Every read/write site is a mechanical per-field translation with no behavior change (removeValue(forKey:) on a field-specific dictionary maps to setting that field back to its default; Set.contains maps to an Optional Bool comparison, etc). Refs #95. --- Sources/AppDelegate.swift | 148 ++++++++++++++++++++++---------------- 1 file changed, 86 insertions(+), 62 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index de59729e1da..779cc7268c3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -336,6 +336,20 @@ struct CommandPaletteDebugSnapshot { static let empty = CommandPaletteDebugSnapshot(query: "", mode: "commands", results: []) } +/// Per-window command-palette state. Consolidates what used to be 7 parallel +/// `[UUID: _]` dictionaries (one dictionary keyed by main-window UUID instead), +/// so window teardown is a single `removeValue(forKey:)` instead of 7 duplicated +/// call sites. Refs #95. +struct CommandPaletteWindowState { + var isVisible = false + var pendingOpen = false + var recentRequestAt: TimeInterval? + var escapeSuppressed = false + var escapeSuppressionStartedAt: TimeInterval? + var selectionIndex = 0 + var snapshot: CommandPaletteDebugSnapshot = .empty +} + func browserZoomShortcutAction( flags: NSEvent.ModifierFlags, chars: String, @@ -969,13 +983,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var isQuitWarningConfirmed = false private var didInstallLifecycleSnapshotObservers = false private var didDisableSuddenTermination = false - private var commandPaletteVisibilityByWindowId: [UUID: Bool] = [:] - private var commandPalettePendingOpenByWindowId: [UUID: Bool] = [:] - private var commandPaletteRecentRequestAtByWindowId: [UUID: TimeInterval] = [:] - private var commandPaletteEscapeSuppressionByWindowId: Set = [] - private var commandPaletteEscapeSuppressionStartedAtByWindowId: [UUID: TimeInterval] = [:] - private var commandPaletteSelectionByWindowId: [UUID: Int] = [:] - private var commandPaletteSnapshotByWindowId: [UUID: CommandPaletteDebugSnapshot] = [:] + private var commandPaletteStateByWindowId: [UUID: CommandPaletteWindowState] = [:] private static let commandPaletteRequestGraceInterval: TimeInterval = 1.25 private static let commandPalettePendingOpenMaxAge: TimeInterval = 8.0 private static let sessionAutosaveTypingQuietPeriod: TimeInterval = 0.65 @@ -2584,9 +2592,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.unregisterMainWindow(closing) } } - commandPaletteVisibilityByWindowId[windowId] = false - commandPaletteSelectionByWindowId[windowId] = 0 - commandPaletteSnapshotByWindowId[windowId] = .empty + updateCommandPaletteState(for: windowId) { state in + state.isVisible = false + state.selectionIndex = 0 + state.snapshot = .empty + } #if DEBUG dlog( @@ -3150,11 +3160,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return workspace.id } + private func commandPaletteState(for windowId: UUID) -> CommandPaletteWindowState { + commandPaletteStateByWindowId[windowId] ?? CommandPaletteWindowState() + } + + private func updateCommandPaletteState(for windowId: UUID, _ mutate: (inout CommandPaletteWindowState) -> Void) { + var state = commandPaletteState(for: windowId) + mutate(&state) + commandPaletteStateByWindowId[windowId] = state + } + + private func teardownCommandPaletteState(for windowId: UUID) { + commandPaletteStateByWindowId.removeValue(forKey: windowId) + } + private func markCommandPaletteOpenRequested(for window: NSWindow?) { guard let window, let windowId = mainWindowId(for: window) else { return } - commandPalettePendingOpenByWindowId[windowId] = true - commandPaletteRecentRequestAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime + updateCommandPaletteState(for: windowId) { state in + state.pendingOpen = true + state.recentRequestAt = ProcessInfo.processInfo.systemUptime + } } private func postCommandPaletteRequest( @@ -3231,17 +3257,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func clearCommandPalettePendingOpen(for window: NSWindow?) { guard let window, let windowId = mainWindowId(for: window) else { return } - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + updateCommandPaletteState(for: windowId) { state in + state.pendingOpen = false + state.recentRequestAt = nil + } } private func pruneExpiredCommandPalettePendingOpenStates( now: TimeInterval = ProcessInfo.processInfo.systemUptime ) { - for windowId in Array(commandPalettePendingOpenByWindowId.keys) { - guard commandPalettePendingOpenByWindowId[windowId] == true else { continue } - guard let requestedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + for windowId in Array(commandPaletteStateByWindowId.keys) { + guard commandPaletteStateByWindowId[windowId]?.pendingOpen == true else { continue } + guard let requestedAt = commandPaletteStateByWindowId[windowId]?.recentRequestAt else { + commandPaletteStateByWindowId[windowId]?.pendingOpen = false #if DEBUG dlog("shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) reason=missingTimestamp") #endif @@ -3249,8 +3277,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } let age = now - requestedAt guard age > Self.commandPalettePendingOpenMaxAge else { continue } - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + commandPaletteStateByWindowId[windowId]?.pendingOpen = false + commandPaletteStateByWindowId[windowId]?.recentRequestAt = nil #if DEBUG dlog( "shortcut.palette.pendingPrune windowId=\(windowId.uuidString.prefix(8)) " + @@ -3263,30 +3291,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func isCommandPalettePendingOpen(for window: NSWindow) -> Bool { guard let windowId = mainWindowId(for: window) else { return false } pruneExpiredCommandPalettePendingOpenStates() - return commandPalettePendingOpenByWindowId[windowId] == true + return commandPaletteStateByWindowId[windowId]?.pendingOpen == true } private func beginCommandPaletteEscapeSuppression(for window: NSWindow?) { guard let window, let windowId = mainWindowId(for: window) else { return } - commandPaletteEscapeSuppressionByWindowId.insert(windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime + updateCommandPaletteState(for: windowId) { state in + state.escapeSuppressed = true + state.escapeSuppressionStartedAt = ProcessInfo.processInfo.systemUptime + } } private func endCommandPaletteEscapeSuppression(for window: NSWindow?) { guard let window, let windowId = mainWindowId(for: window) else { return } - commandPaletteEscapeSuppressionByWindowId.remove(windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: windowId) + updateCommandPaletteState(for: windowId) { state in + state.escapeSuppressed = false + state.escapeSuppressionStartedAt = nil + } } private func shouldConsumeSuppressedEscape(event: NSEvent, window: NSWindow?) -> Bool { guard let window, let windowId = mainWindowId(for: window), - commandPaletteEscapeSuppressionByWindowId.contains(windowId) else { + commandPaletteStateByWindowId[windowId]?.escapeSuppressed == true else { return false } - let startedAt = commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] ?? 0 + let startedAt = commandPaletteStateByWindowId[windowId]?.escapeSuppressionStartedAt ?? 0 if ProcessInfo.processInfo.systemUptime - startedAt <= 0.35 { return true } @@ -3302,12 +3334,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } let now = ProcessInfo.processInfo.systemUptime pruneExpiredCommandPalettePendingOpenStates(now: now) - guard commandPalettePendingOpenByWindowId[windowId] == true else { - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + guard commandPaletteStateByWindowId[windowId]?.pendingOpen == true else { + commandPaletteStateByWindowId[windowId]?.recentRequestAt = nil return nil } - guard let startedAt = commandPaletteRecentRequestAtByWindowId[windowId] else { - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) + guard let startedAt = commandPaletteStateByWindowId[windowId]?.recentRequestAt else { + commandPaletteStateByWindowId[windowId]?.pendingOpen = false return nil } let age = now - startedAt @@ -3336,8 +3368,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif return didConsume } - commandPaletteEscapeSuppressionByWindowId.removeAll() - commandPaletteEscapeSuppressionStartedAtByWindowId.removeAll() + for windowId in commandPaletteStateByWindowId.keys { + commandPaletteStateByWindowId[windowId]?.escapeSuppressed = false + commandPaletteStateByWindowId[windowId]?.escapeSuppressionStartedAt = nil + } #if DEBUG dlog("shortcut.escape suppressionClear target={nil} clearedAll=1 keyUpConsumed=\(didConsume ? 1 : 0)") #endif @@ -3346,19 +3380,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func setCommandPaletteVisible(_ visible: Bool, for window: NSWindow) { guard let windowId = mainWindowId(for: window) else { return } - let wasVisible = commandPaletteVisibilityByWindowId[windowId] ?? false - commandPaletteVisibilityByWindowId[windowId] = visible + let wasVisible = commandPaletteState(for: windowId).isVisible + updateCommandPaletteState(for: windowId) { $0.isVisible = visible } // Opening (false -> true) always resolves pending-open. // Closing (true -> false) also clears stale pending state. // Ignore repeated false updates so a stale sync cannot erase an in-flight open request. if visible || wasVisible { - commandPalettePendingOpenByWindowId.removeValue(forKey: windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: windowId) + commandPaletteStateByWindowId[windowId]?.pendingOpen = false + commandPaletteStateByWindowId[windowId]?.recentRequestAt = nil } #if DEBUG if !visible, !wasVisible, - commandPalettePendingOpenByWindowId[windowId] == true { + commandPaletteStateByWindowId[windowId]?.pendingOpen == true { dlog( "palette.visibility.retainPending " + "window={\(debugWindowToken(window))} visible=0 wasVisible=0 pending=1" @@ -3368,30 +3402,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } func isCommandPaletteVisible(windowId: UUID) -> Bool { - commandPaletteVisibilityByWindowId[windowId] ?? false + commandPaletteStateByWindowId[windowId]?.isVisible ?? false } func setCommandPaletteSelectionIndex(_ index: Int, for window: NSWindow) { guard let windowId = mainWindowId(for: window) else { return } - commandPaletteSelectionByWindowId[windowId] = max(0, index) + updateCommandPaletteState(for: windowId) { $0.selectionIndex = max(0, index) } } func commandPaletteSelectionIndex(windowId: UUID) -> Int { - commandPaletteSelectionByWindowId[windowId] ?? 0 + commandPaletteStateByWindowId[windowId]?.selectionIndex ?? 0 } func setCommandPaletteSnapshot(_ snapshot: CommandPaletteDebugSnapshot, for window: NSWindow) { guard let windowId = mainWindowId(for: window) else { return } - commandPaletteSnapshotByWindowId[windowId] = snapshot + updateCommandPaletteState(for: windowId) { $0.snapshot = snapshot } } func commandPaletteSnapshot(windowId: UUID) -> CommandPaletteDebugSnapshot { - commandPaletteSnapshotByWindowId[windowId] ?? .empty + commandPaletteStateByWindowId[windowId]?.snapshot ?? .empty } func isCommandPaletteVisible(for window: NSWindow) -> Bool { guard let windowId = mainWindowId(for: window) else { return false } - return commandPaletteVisibilityByWindowId[windowId] ?? false + return commandPaletteStateByWindowId[windowId]?.isVisible ?? false } func isCommandPaletteEffectivelyVisible(for window: NSWindow) -> Bool { @@ -3901,13 +3935,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } notifyMainWindowContextsDidChange() - commandPaletteVisibilityByWindowId.removeValue(forKey: context.windowId) - commandPalettePendingOpenByWindowId.removeValue(forKey: context.windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: context.windowId) - commandPaletteEscapeSuppressionByWindowId.remove(context.windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: context.windowId) - commandPaletteSelectionByWindowId.removeValue(forKey: context.windowId) - commandPaletteSnapshotByWindowId.removeValue(forKey: context.windowId) + teardownCommandPaletteState(for: context.windowId) if tabManager === context.tabManager { if let nextContext = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) { @@ -4018,10 +4046,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent }) { return orderedWindow } - if let visibleWindowId = commandPaletteVisibilityByWindowId.first(where: { $0.value })?.key { + if let visibleWindowId = commandPaletteStateByWindowId.first(where: { $0.value.isVisible })?.key { return windowForMainWindowId(visibleWindowId) } - if let pendingWindowId = commandPalettePendingOpenByWindowId.first(where: { $0.value })?.key { + if let pendingWindowId = commandPaletteStateByWindowId.first(where: { $0.value.pendingOpen })?.key { return windowForMainWindowId(pendingWindowId) } return nil @@ -7648,8 +7676,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func debugSetCommandPalettePendingOpenAge(window: NSWindow, age: TimeInterval) -> Bool { guard let windowId = mainWindowId(for: window) else { return false } - commandPalettePendingOpenByWindowId[windowId] = true - commandPaletteRecentRequestAtByWindowId[windowId] = ProcessInfo.processInfo.systemUptime - max(age, 0) + updateCommandPaletteState(for: windowId) { state in + state.pendingOpen = true + state.recentRequestAt = ProcessInfo.processInfo.systemUptime - max(age, 0) + } return true } @@ -8449,13 +8479,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // is removed when the last window closes. persistWindowGeometry(from: window) guard let removed = unregisterMainWindowContext(for: window) else { return } - commandPaletteVisibilityByWindowId.removeValue(forKey: removed.windowId) - commandPalettePendingOpenByWindowId.removeValue(forKey: removed.windowId) - commandPaletteRecentRequestAtByWindowId.removeValue(forKey: removed.windowId) - commandPaletteEscapeSuppressionByWindowId.remove(removed.windowId) - commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: removed.windowId) - commandPaletteSelectionByWindowId.removeValue(forKey: removed.windowId) - commandPaletteSnapshotByWindowId.removeValue(forKey: removed.windowId) + teardownCommandPaletteState(for: removed.windowId) // Avoid stale notifications that can no longer be opened once the owning window is gone. if let store = notificationStore { From 5f44a87a26030f505d54be48e38a11ab80be674d Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:09:03 -0300 Subject: [PATCH 8/9] refactor(contentview): extract SidebarDragDrop.swift and SidebarVisuals.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the drag-and-drop domain (DragOverlayRoutingPolicy, drag payload enums, drop delegates, drop edge/indicator/planner, auto-scroll planner, drag lifecycle/failsafe types) and the sidebar visual chrome (footer/help-menu/ scrim/blur/material/tint/preset enums, visual-effect/backdrop views, NSColor extension), both previously fragmented across several non-contiguous locations in ContentView.swift, into two new files. Done together in one pass since both extractions needed the same verification cycle. Access-level widening (dropped `private`, no other behavior change): - SidebarDragFailsafeMonitor, SidebarExternalDropOverlay — constructed from VerticalTabsSidebar / ContentView, which stay in ContentView.swift. - SidebarFooter, SidebarTopScrim, SidebarScrollViewResolver, SidebarScrollViewResolverView, SidebarEmptyArea, ClearScrollBackground, DraggableFolderIcon, TitlebarLeadingInsetReader, SidebarTrailingBorder, SidebarBackdrop — same reason. The fileDropOverlayKey/commandPaletteWindowOverlayKey/ tmuxWorkspacePaneWindowOverlayKey associated-object keys and their overlay container identifiers stay in ContentView.swift (they back WindowCommandPaletteOverlayController / WindowTmuxWorkspacePaneOverlayController, not the drag-drop domain) — they only transited SidebarDragDrop.swift briefly during the mechanical move and were moved back. Refs #94. --- GhosttyTabs.xcodeproj/project.pbxproj | 8 + Sources/ContentView.swift | 2427 ------------------------- Sources/SidebarDragDrop.swift | 926 ++++++++++ Sources/SidebarVisuals.swift | 1533 ++++++++++++++++ 4 files changed, 2467 insertions(+), 2427 deletions(-) create mode 100644 Sources/SidebarDragDrop.swift create mode 100644 Sources/SidebarVisuals.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 009e2b37251..0ac220775fa 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ NRCV00000000000000000005 /* TabItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000006 /* TabItemView.swift */; }; NRCV00000000000000000003 /* FileDropOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000004 /* FileDropOverlayView.swift */; }; NRCV00000000000000000001 /* CommandPaletteSearchEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */; }; + NRCV00000000000000000007 /* SidebarDragDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000008 /* SidebarDragDrop.swift */; }; + NRCV00000000000000000009 /* SidebarVisuals.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000010 /* SidebarVisuals.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; }; A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; @@ -241,6 +243,8 @@ NRCV00000000000000000006 /* TabItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItemView.swift; sourceTree = ""; }; NRCV00000000000000000004 /* FileDropOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayView.swift; sourceTree = ""; }; NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngine.swift; sourceTree = ""; }; + NRCV00000000000000000008 /* SidebarDragDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarDragDrop.swift; sourceTree = ""; }; + NRCV00000000000000000010 /* SidebarVisuals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarVisuals.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; @@ -513,6 +517,8 @@ NRCV00000000000000000006 /* TabItemView.swift */, NRCV00000000000000000004 /* FileDropOverlayView.swift */, NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */, + NRCV00000000000000000008 /* SidebarDragDrop.swift */, + NRCV00000000000000000010 /* SidebarVisuals.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, @@ -846,6 +852,8 @@ NRCV00000000000000000005 /* TabItemView.swift in Sources */, NRCV00000000000000000003 /* FileDropOverlayView.swift in Sources */, NRCV00000000000000000001 /* CommandPaletteSearchEngine.swift in Sources */, + NRCV00000000000000000007 /* SidebarDragDrop.swift in Sources */, + NRCV00000000000000000009 /* SidebarVisuals.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 4fb7fd19783..87ca62e5b4a 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -370,107 +370,6 @@ enum SidebarResizeInteraction { } } -// MARK: - File Drop Overlay - -enum DragOverlayRoutingPolicy { - static let bonsplitTabTransferType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") - static let sidebarTabReorderType = NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) - - static func hasBonsplitTabTransfer(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { - guard let pasteboardTypes else { return false } - return pasteboardTypes.contains(bonsplitTabTransferType) - } - - static func hasSidebarTabReorder(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { - guard let pasteboardTypes else { return false } - return pasteboardTypes.contains(sidebarTabReorderType) - } - - static func hasFileURL(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { - guard let pasteboardTypes else { return false } - return pasteboardTypes.contains(.fileURL) - } - - static func shouldCaptureFileDropDestination( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - hasLocalDraggingSource: Bool - ) -> Bool { - // Local file drags (e.g. in-app draggable folder views) are valid drop - // inputs; rely on explicit non-file drag types below to avoid hijacking - // Bonsplit/sidebar drags. - _ = hasLocalDraggingSource - guard hasFileURL(pasteboardTypes) else { return false } - - // Prefer explicit non-file drag types so stale fileURL entries cannot hijack - // Bonsplit tab drags or sidebar tab reorder drags. - if hasBonsplitTabTransfer(pasteboardTypes) { return false } - if hasSidebarTabReorder(pasteboardTypes) { return false } - return true - } - - static func shouldCaptureFileDropDestination( - pasteboardTypes: [NSPasteboard.PasteboardType]? - ) -> Bool { - shouldCaptureFileDropDestination( - pasteboardTypes: pasteboardTypes, - hasLocalDraggingSource: false - ) - } - - static func shouldCaptureFileDropOverlay( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - eventType: NSEvent.EventType? - ) -> Bool { - guard shouldCaptureFileDropDestination(pasteboardTypes: pasteboardTypes) else { return false } - guard isDragMouseEvent(eventType) else { return false } - return true - } - - static func shouldCaptureSidebarExternalOverlay( - hasSidebarDragState: Bool, - pasteboardTypes: [NSPasteboard.PasteboardType]? - ) -> Bool { - guard hasSidebarDragState else { return false } - return hasSidebarTabReorder(pasteboardTypes) - } - - static func shouldCaptureSidebarExternalOverlay( - draggedTabId: UUID?, - pasteboardTypes: [NSPasteboard.PasteboardType]? - ) -> Bool { - shouldCaptureSidebarExternalOverlay( - hasSidebarDragState: draggedTabId != nil, - pasteboardTypes: pasteboardTypes - ) - } - - static func shouldPassThroughPortalHitTesting( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - eventType: NSEvent.EventType? - ) -> Bool { - guard isPortalDragEvent(eventType) else { return false } - return hasBonsplitTabTransfer(pasteboardTypes) || hasSidebarTabReorder(pasteboardTypes) - } - - private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { - eventType == .leftMouseDragged - || eventType == .rightMouseDragged - || eventType == .otherMouseDragged - } - - private static func isPortalDragEvent(_ eventType: NSEvent.EventType?) -> Bool { - // Restrict portal pass-through to explicit drag-motion events so stale - // NSPasteboard(name: .drag) types cannot hijack normal pointer input. - guard let eventType else { return false } - switch eventType { - case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: - return true - default: - return false - } - } -} - var fileDropOverlayKey: UInt8 = 0 private var commandPaletteWindowOverlayKey: UInt8 = 0 private var tmuxWorkspacePaneWindowOverlayKey: UInt8 = 0 @@ -7699,244 +7598,6 @@ enum DevBuildBannerDebugSettings { } } -enum SidebarDragLifecycleNotification { - static let stateDidChange = Notification.Name("programa.sidebarDragStateDidChange") - static let requestClear = Notification.Name("programa.sidebarDragRequestClear") - static let tabIdKey = "tabId" - static let reasonKey = "reason" - - static func postStateDidChange(tabId: UUID?, reason: String) { - var userInfo: [AnyHashable: Any] = [reasonKey: reason] - if let tabId { - userInfo[tabIdKey] = tabId - } - NotificationCenter.default.post( - name: stateDidChange, - object: nil, - userInfo: userInfo - ) - } - - static func postClearRequest(reason: String) { - NotificationCenter.default.post( - name: requestClear, - object: nil, - userInfo: [reasonKey: reason] - ) - } - - static func tabId(from notification: Notification) -> UUID? { - notification.userInfo?[tabIdKey] as? UUID - } - - static func reason(from notification: Notification) -> String { - notification.userInfo?[reasonKey] as? String ?? "unknown" - } -} - -enum SidebarOutsideDropResetPolicy { - static func shouldResetDrag(draggedTabId: UUID?, hasSidebarDragPayload: Bool) -> Bool { - draggedTabId != nil && hasSidebarDragPayload - } -} - -enum SidebarDragFailsafePolicy { - static let clearDelay: TimeInterval = 0.15 - - static func shouldRequestClear(isDragActive: Bool, isLeftMouseButtonDown: Bool) -> Bool { - isDragActive && !isLeftMouseButtonDown - } - - static func shouldRequestClearWhenMonitoringStarts(isLeftMouseButtonDown: Bool) -> Bool { - shouldRequestClear( - isDragActive: true, - isLeftMouseButtonDown: isLeftMouseButtonDown - ) - } - - static func shouldRequestClear(forMouseEventType eventType: NSEvent.EventType) -> Bool { - eventType == .leftMouseUp - } -} - -@MainActor -private final class SidebarDragFailsafeMonitor: ObservableObject { - private static let escapeKeyCode: UInt16 = 53 - private var pendingClearWorkItem: DispatchWorkItem? - private var appResignObserver: NSObjectProtocol? - private var keyDownMonitor: Any? - private var localMouseMonitor: Any? - private var globalMouseMonitor: Any? - private var onRequestClear: ((String) -> Void)? - - func start(onRequestClear: @escaping (String) -> Void) { - self.onRequestClear = onRequestClear - if SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts( - isLeftMouseButtonDown: CGEventSource.buttonState( - .combinedSessionState, - button: .left - ) - ) { - requestClearSoon(reason: "mouse_up_failsafe") - } - if appResignObserver == nil { - appResignObserver = NotificationCenter.default.addObserver( - forName: NSApplication.didResignActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.requestClearSoon(reason: "app_resign_active") - } - } - } - if keyDownMonitor == nil { - keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - if event.keyCode == Self.escapeKeyCode { - self?.requestClearSoon(reason: "escape_cancel") - } - return event - } - } - if localMouseMonitor == nil { - localMouseMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in - if SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) { - self?.requestClearSoon(reason: "mouse_up_failsafe") - } - return event - } - } - if globalMouseMonitor == nil { - globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in - guard SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) else { return } - Task { @MainActor [weak self] in - self?.requestClearSoon(reason: "mouse_up_failsafe") - } - } - } - } - - func stop() { - pendingClearWorkItem?.cancel() - pendingClearWorkItem = nil - if let appResignObserver { - NotificationCenter.default.removeObserver(appResignObserver) - self.appResignObserver = nil - } - if let keyDownMonitor { - NSEvent.removeMonitor(keyDownMonitor) - self.keyDownMonitor = nil - } - if let localMouseMonitor { - NSEvent.removeMonitor(localMouseMonitor) - self.localMouseMonitor = nil - } - if let globalMouseMonitor { - NSEvent.removeMonitor(globalMouseMonitor) - self.globalMouseMonitor = nil - } - onRequestClear = nil - } - - private func requestClearSoon(reason: String) { - guard pendingClearWorkItem == nil else { return } -#if DEBUG - dlog("sidebar.dragFailsafe.schedule reason=\(reason)") -#endif - let workItem = DispatchWorkItem { [weak self] in -#if DEBUG - dlog("sidebar.dragFailsafe.fire reason=\(reason)") -#endif - self?.pendingClearWorkItem = nil - self?.onRequestClear?(reason) - } - pendingClearWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + SidebarDragFailsafePolicy.clearDelay, execute: workItem) - } -} - -private struct SidebarExternalDropOverlay: View { - let draggedTabId: UUID? - - var body: some View { - let dragPasteboardTypes = NSPasteboard(name: .drag).types - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureSidebarExternalOverlay( - draggedTabId: draggedTabId, - pasteboardTypes: dragPasteboardTypes - ) - Group { - if shouldCapture { - Color.clear - .contentShape(Rectangle()) - .allowsHitTesting(true) - .onDrop( - of: SidebarTabDragPayload.dropContentTypes, - delegate: SidebarExternalDropDelegate(draggedTabId: draggedTabId) - ) - } else { - Color.clear - .contentShape(Rectangle()) - .allowsHitTesting(false) - } - } - } -} - -private struct SidebarExternalDropDelegate: DropDelegate { - let draggedTabId: UUID? - - func validateDrop(info: DropInfo) -> Bool { - let hasSidebarPayload = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) - let shouldReset = SidebarOutsideDropResetPolicy.shouldResetDrag( - draggedTabId: draggedTabId, - hasSidebarDragPayload: hasSidebarPayload - ) -#if DEBUG - dlog( - "sidebar.dropOutside.validate tab=\(debugShortSidebarTabId(draggedTabId)) " + - "hasType=\(hasSidebarPayload) allowed=\(shouldReset)" - ) -#endif - return shouldReset - } - - func dropEntered(info: DropInfo) { -#if DEBUG - dlog("sidebar.dropOutside.entered tab=\(debugShortSidebarTabId(draggedTabId))") -#endif - } - - func dropExited(info: DropInfo) { -#if DEBUG - dlog("sidebar.dropOutside.exited tab=\(debugShortSidebarTabId(draggedTabId))") -#endif - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - guard validateDrop(info: info) else { return nil } -#if DEBUG - dlog("sidebar.dropOutside.updated tab=\(debugShortSidebarTabId(draggedTabId)) op=move") -#endif - // Explicit move proposal avoids AppKit showing a copy (+) cursor. - return DropProposal(operation: .move) - } - - func performDrop(info: DropInfo) -> Bool { - guard validateDrop(info: info) else { return false } -#if DEBUG - dlog("sidebar.dropOutside.perform tab=\(debugShortSidebarTabId(draggedTabId))") -#endif - SidebarDragLifecycleNotification.postClearRequest(reason: "outside_sidebar_drop") - return true - } - - private func debugShortSidebarTabId(_ id: UUID?) -> String { - guard let id else { return "nil" } - return String(id.uuidString.prefix(5)) - } -} - -@MainActor private final class SidebarShortcutHintModifierMonitor: ObservableObject { @Published private(set) var isModifierPressed = false @@ -8096,2091 +7757,3 @@ private final class SidebarShortcutHintModifierMonitor: ObservableObject { } } -private struct SidebarFooter: View { - @ObservedObject var updateViewModel: UpdateViewModel - let onSendFeedback: () -> Void - - var body: some View { -#if DEBUG - SidebarDevFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) -#else - SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) - .padding(.leading, 6) - .padding(.trailing, 10) - .padding(.bottom, 6) -#endif - } -} - -private struct SidebarFooterButtons: View { - @ObservedObject var updateViewModel: UpdateViewModel - let onSendFeedback: () -> Void - - var body: some View { - HStack(spacing: 4) { - SidebarHelpMenuButton(onSendFeedback: onSendFeedback) - UpdatePill(model: updateViewModel) - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -private enum SidebarHelpMenuAction { - case importBrowserData - case keyboardShortcuts - case docs - case changelog - case github - case githubIssues - case discord - case checkForUpdates - case sendFeedback - case welcome -} - -private struct SidebarHelpMenuButton: View { - private let docsURL = URL(string: "https://github.com/darkroomengineering/programa/tree/main/docs") - private let changelogURL = URL(string: "https://github.com/darkroomengineering/programa/blob/main/CHANGELOG.md") - private let githubURL = URL(string: "https://github.com/darkroomengineering/programa") - private let githubIssuesURL = URL(string: "https://github.com/darkroomengineering/programa/issues") - private let discordURL = URL(string: "https://discord.gg/xsgFEVrWCZ") - private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help") - private let buttonSize: CGFloat = 22 - private let iconSize: CGFloat = 11 - @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared - - let onSendFeedback: () -> Void - - @State private var isPopoverPresented = false - - private var sendFeedbackShortcutHint: String { - let _ = keyboardShortcutSettingsObserver.revision - return KeyboardShortcutSettings.shortcut(for: .sendFeedback).displayString - } - - var body: some View { - Button { - isPopoverPresented.toggle() - } label: { - Image(systemName: "questionmark.circle") - .symbolRenderingMode(.monochrome) - .font(.system(size: iconSize, weight: .medium)) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) - .frame(width: buttonSize, height: buttonSize, alignment: .center) - } - .buttonStyle(SidebarFooterIconButtonStyle()) - .frame(width: buttonSize, height: buttonSize, alignment: .center) - .background(ArrowlessPopoverAnchor( - isPresented: $isPopoverPresented, - preferredEdge: .maxY, - detachedGap: 4 - ) { - helpPopover - }) - .accessibilityElement(children: .ignore) - .safeHelp(helpTitle) - .accessibilityLabel(helpTitle) - .accessibilityIdentifier("SidebarHelpMenuButton") - } - - private var helpPopover: some View { - VStack(alignment: .leading, spacing: 2) { - helpOptionButton( - title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome to Programa!"), - action: .welcome, - accessibilityIdentifier: "SidebarHelpMenuOptionWelcome", - isExternalLink: false - ) - helpOptionButton( - title: String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback"), - action: .sendFeedback, - accessibilityIdentifier: "SidebarHelpMenuOptionSendFeedback", - isExternalLink: false, - shortcutHint: sendFeedbackShortcutHint, - trailingSystemImage: "bubble.left.and.text.bubble.right" - ) - helpOptionButton( - title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"), - action: .keyboardShortcuts, - accessibilityIdentifier: "SidebarHelpMenuOptionKeyboardShortcuts", - isExternalLink: false - ) - helpOptionButton( - title: String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…"), - action: .importBrowserData, - accessibilityIdentifier: "SidebarHelpMenuOptionImportBrowserData", - isExternalLink: false - ) - if docsURL != nil { - helpOptionButton( - title: String(localized: "about.docs", defaultValue: "Docs"), - action: .docs, - accessibilityIdentifier: "SidebarHelpMenuOptionDocs", - isExternalLink: true - ) - } - if changelogURL != nil { - helpOptionButton( - title: String(localized: "sidebar.help.changelog", defaultValue: "Changelog"), - action: .changelog, - accessibilityIdentifier: "SidebarHelpMenuOptionChangelog", - isExternalLink: true - ) - } - if githubURL != nil { - helpOptionButton( - title: String(localized: "about.github", defaultValue: "GitHub"), - action: .github, - accessibilityIdentifier: "SidebarHelpMenuOptionGitHub", - isExternalLink: true - ) - } - if githubIssuesURL != nil { - helpOptionButton( - title: String(localized: "sidebar.help.githubIssues", defaultValue: "GitHub Issues"), - action: .githubIssues, - accessibilityIdentifier: "SidebarHelpMenuOptionGitHubIssues", - isExternalLink: true - ) - } - if discordURL != nil { - helpOptionButton( - title: String(localized: "sidebar.help.discord", defaultValue: "Discord"), - action: .discord, - accessibilityIdentifier: "SidebarHelpMenuOptionDiscord", - isExternalLink: true - ) - } - helpOptionButton( - title: String(localized: "command.checkForUpdates.title", defaultValue: "Check for Updates"), - action: .checkForUpdates, - accessibilityIdentifier: "SidebarHelpMenuOptionCheckForUpdates", - isExternalLink: false - ) - } - .padding(8) - .frame(minWidth: 200) - } - - private func helpOptionButton( - title: String, - action: SidebarHelpMenuAction, - accessibilityIdentifier: String, - isExternalLink: Bool, - shortcutHint: String? = nil, - trailingSystemImage: String? = nil - ) -> some View { - Button { - isPopoverPresented = false - perform(action) - } label: { - HStack(spacing: 8) { - Text(title) - .font(.system(size: 12)) - Spacer(minLength: 0) - if let shortcutHint { - helpOptionShortcutHint(text: shortcutHint) - } - if let trailingSystemImage { - helpOptionTrailingIcon(systemName: trailingSystemImage) - } - if isExternalLink { - helpOptionTrailingIcon(systemName: "arrow.up.right", size: 8) - } - } - .padding(.horizontal, 8) - .frame(height: 24) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityIdentifier(accessibilityIdentifier) - } - - private func helpOptionShortcutHint(text: String) -> some View { - Text(text) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - .font(.system(size: 10, weight: .regular, design: .rounded)) - .monospacedDigit() - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) - } - - private func helpOptionTrailingIcon(systemName: String, size: CGFloat = 13) -> some View { - Image(systemName: systemName) - .resizable() - .scaledToFit() - .frame(width: size, height: size) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) - } - - private func perform(_ action: SidebarHelpMenuAction) { - switch action { - case .importBrowserData: - isPopoverPresented = false - DispatchQueue.main.async { - BrowserDataImportCoordinator.shared.presentImportDialog() - } - case .keyboardShortcuts: - isPopoverPresented = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { - Task { @MainActor in - if let appDelegate = AppDelegate.shared { - appDelegate.openPreferencesWindow( - debugSource: "sidebarHelpMenu.keyboardShortcuts", - navigationTarget: .keyboardShortcuts - ) - } else { - AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts) - } - } - } - case .docs: - guard let docsURL else { return } - NSWorkspace.shared.open(docsURL) - case .changelog: - guard let changelogURL else { return } - NSWorkspace.shared.open(changelogURL) - case .github: - guard let githubURL else { return } - NSWorkspace.shared.open(githubURL) - case .githubIssues: - guard let githubIssuesURL else { return } - NSWorkspace.shared.open(githubIssuesURL) - case .discord: - guard let discordURL else { return } - NSWorkspace.shared.open(discordURL) - case .checkForUpdates: - Task { @MainActor in - AppDelegate.shared?.checkForUpdates(nil) - } - case .sendFeedback: - isPopoverPresented = false - onSendFeedback() - case .welcome: - isPopoverPresented = false - Task { @MainActor in - if let appDelegate = AppDelegate.shared { - appDelegate.openWelcomeWorkspace() - } - } - } - } - -} - -private struct ArrowlessPopoverAnchor: NSViewRepresentable { - @Binding var isPresented: Bool - let preferredEdge: NSRectEdge - let detachedGap: CGFloat - @ViewBuilder let content: () -> PopoverContent - - func makeNSView(context: Context) -> NSView { - let view = NSView() - context.coordinator.anchorView = view - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - context.coordinator.anchorView = nsView - context.coordinator.updateRootView(AnyView(content())) - - if isPresented { - context.coordinator.present( - preferredEdge: preferredEdge, - detachedGap: detachedGap - ) - } else { - context.coordinator.dismiss() - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(isPresented: $isPresented) - } - - final class Coordinator: NSObject, NSPopoverDelegate { - @Binding var isPresented: Bool - - weak var anchorView: NSView? - private let hostingController = NSHostingController(rootView: AnyView(EmptyView())) - private var popover: NSPopover? - - init(isPresented: Binding) { - _isPresented = isPresented - } - - func updateRootView(_ rootView: AnyView) { - hostingController.rootView = AnyView(rootView.fixedSize()) - hostingController.view.invalidateIntrinsicContentSize() - hostingController.view.layoutSubtreeIfNeeded() - } - - func present(preferredEdge: NSRectEdge, detachedGap: CGFloat) { - guard let anchorView else { - isPresented = false - dismiss() - return - } - - let popover = popover ?? makePopover() - if popover.isShown { - return - } - - hostingController.view.invalidateIntrinsicContentSize() - hostingController.view.layoutSubtreeIfNeeded() - let fittingSize = hostingController.view.fittingSize - if fittingSize.width > 0, fittingSize.height > 0 { - popover.contentSize = NSSize( - width: ceil(fittingSize.width), - height: ceil(fittingSize.height) - ) - } - - popover.show( - relativeTo: positioningRect( - for: anchorView.bounds, - preferredEdge: preferredEdge, - detachedGap: detachedGap - ), - of: anchorView, - preferredEdge: preferredEdge - ) - } - - func dismiss() { - popover?.performClose(nil) - popover = nil - } - - func popoverDidClose(_ notification: Notification) { - popover = nil - if isPresented { - isPresented = false - } - } - - private func makePopover() -> NSPopover { - let popover = NSPopover() - popover.behavior = .semitransient - popover.animates = true - popover.setValue(true, forKeyPath: "shouldHideAnchor") - popover.contentViewController = hostingController - popover.delegate = self - self.popover = popover - return popover - } - - private func positioningRect( - for bounds: CGRect, - preferredEdge: NSRectEdge, - detachedGap: CGFloat - ) -> CGRect { - let hiddenArrowInset: CGFloat = 13 - let compensation = max(hiddenArrowInset - detachedGap, 0) - - switch preferredEdge { - case .maxY: - return NSRect( - x: bounds.minX, - y: bounds.maxY - compensation, - width: bounds.width, - height: compensation - ) - case .minY: - return NSRect( - x: bounds.minX, - y: bounds.minY, - width: bounds.width, - height: compensation - ) - case .maxX: - return NSRect( - x: bounds.maxX - compensation, - y: bounds.minY, - width: compensation, - height: bounds.height - ) - case .minX: - return NSRect( - x: bounds.minX, - y: bounds.minY, - width: compensation, - height: bounds.height - ) - @unknown default: - return bounds - } - } - } -} - -private struct SidebarFooterIconButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - SidebarFooterIconButtonStyleBody(configuration: configuration) - } -} - -private struct SidebarFooterIconButtonStyleBody: View { - let configuration: SidebarFooterIconButtonStyle.Configuration - - @Environment(\.isEnabled) private var isEnabled - @State private var isHovered = false - - private var backgroundOpacity: Double { - guard isEnabled else { return 0.0 } - if configuration.isPressed { return 0.16 } - if isHovered { return 0.08 } - return 0.0 - } - - var body: some View { - configuration.label - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.primary.opacity(backgroundOpacity)) - ) - .onHover { hovering in - isHovered = hovering - } - .animation(.easeOut(duration: 0.12), value: isHovered) - .animation(.easeOut(duration: 0.08), value: configuration.isPressed) - } -} - -#if DEBUG -private struct SidebarDevFooter: View { - @ObservedObject var updateViewModel: UpdateViewModel - let onSendFeedback: () -> Void - @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) - private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) - if showSidebarDevBuildBanner { - Text(String(localized: "debug.devBuildBanner.title", defaultValue: "THIS IS A DEV BUILD")) - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(.red) - } - } - .padding(.leading, 6) - .padding(.trailing, 10) - .padding(.bottom, 6) - } -} -#endif - -private struct SidebarTopScrim: View { - let height: CGFloat - - var body: some View { - SidebarTopBlurEffect() - .frame(height: height) - .mask( - LinearGradient( - colors: [ - Color.black.opacity(0.95), - Color.black.opacity(0.75), - Color.black.opacity(0.35), - Color.clear - ], - startPoint: .top, - endPoint: .bottom - ) - ) - } -} - -private struct SidebarTopBlurEffect: NSViewRepresentable { - func makeNSView(context: Context) -> NSVisualEffectView { - let view = NSVisualEffectView() - view.blendingMode = .withinWindow - view.material = .underWindowBackground - view.state = .active - view.isEmphasized = false - return view - } - - func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} -} - -private struct SidebarScrollViewResolver: NSViewRepresentable { - let onResolve: (NSScrollView?) -> Void - - func makeNSView(context: Context) -> SidebarScrollViewResolverView { - let view = SidebarScrollViewResolverView() - view.onResolve = onResolve - return view - } - - func updateNSView(_ nsView: SidebarScrollViewResolverView, context: Context) { - nsView.onResolve = onResolve - nsView.resolveScrollView() - } -} - -private final class SidebarScrollViewResolverView: NSView { - var onResolve: ((NSScrollView?) -> Void)? - - override func viewDidMoveToSuperview() { - super.viewDidMoveToSuperview() - resolveScrollView() - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - resolveScrollView() - } - - func resolveScrollView() { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - onResolve?(self.enclosingScrollView) - } - } -} - -private struct SidebarEmptyArea: View { - @EnvironmentObject var tabManager: TabManager - let rowSpacing: CGFloat - @Binding var selection: SidebarSelection - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - let dragAutoScrollController: SidebarDragAutoScrollController - @Binding var draggedTabId: UUID? - @Binding var dropIndicator: SidebarDropIndicator? - - var body: some View { - Color.clear - .contentShape(Rectangle()) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onTapGesture(count: 2) { - tabManager.addWorkspace(placementOverride: .end) - if let selectedId = tabManager.selectedTabId { - selectedTabIds = [selectedId] - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } - selection = .tabs - } - .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( - targetTabId: nil, - tabManager: tabManager, - draggedTabId: $draggedTabId, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - targetRowHeight: nil, - dragAutoScrollController: dragAutoScrollController, - dropIndicator: $dropIndicator - )) - .overlay(alignment: .top) { - if shouldShowTopDropIndicator { - Rectangle() - .fill(programaAccentColor()) - .frame(height: 2) - .padding(.horizontal, 8) - .offset(y: -(rowSpacing / 2)) - } - } - } - - private var shouldShowTopDropIndicator: Bool { - guard draggedTabId != nil, let indicator = dropIndicator else { return false } - if indicator.tabId == nil { - return true - } - guard indicator.edge == .bottom, let lastTabId = tabManager.tabs.last?.id else { return false } - return indicator.tabId == lastTabId - } -} - -enum SidebarPathFormatter { - static let homeDirectoryPath: String = FileManager.default.homeDirectoryForCurrentUser.path - - static func shortenedPath( - _ path: String, - homeDirectoryPath: String = Self.homeDirectoryPath - ) -> String { - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return path } - if trimmed == homeDirectoryPath { - return "~" - } - if trimmed.hasPrefix(homeDirectoryPath + "/") { - return "~" + trimmed.dropFirst(homeDirectoryPath.count) - } - return trimmed - } -} - -enum SidebarWorkspaceShortcutHintMetrics { - private static let measurementFont = NSFont.systemFont(ofSize: 10, weight: .semibold) - private static let minimumSlotWidth: CGFloat = 28 - private static let horizontalPadding: CGFloat = 12 - private static let lock = NSLock() - private static var cachedHintWidths: [String: CGFloat] = [:] - #if DEBUG - private static var measurementCount = 0 - #endif - - static func slotWidth(label: String?, debugXOffset: Double) -> CGFloat { - guard let label else { return minimumSlotWidth } - let positiveDebugInset = max(0, CGFloat(ShortcutHintDebugSettings.clamped(debugXOffset))) + 2 - return max(minimumSlotWidth, hintWidth(for: label) + positiveDebugInset) - } - - static func hintWidth(for label: String) -> CGFloat { - lock.lock() - if let cached = cachedHintWidths[label] { - lock.unlock() - return cached - } - lock.unlock() - - let textWidth = (label as NSString).size(withAttributes: [.font: measurementFont]).width - let measuredWidth = ceil(textWidth) + horizontalPadding - - lock.lock() - cachedHintWidths[label] = measuredWidth - #if DEBUG - measurementCount += 1 - #endif - lock.unlock() - return measuredWidth - } - - #if DEBUG - static func resetCacheForTesting() { - lock.lock() - cachedHintWidths.removeAll() - measurementCount = 0 - lock.unlock() - } - - static func measurementCountForTesting() -> Int { - lock.lock() - let count = measurementCount - lock.unlock() - return count - } - #endif -} - -enum SidebarTrailingAccessoryWidthPolicy { - static let closeButtonWidth: CGFloat = 16 - - static func width( - canCloseWorkspace: Bool, - showsWorkspaceShortcutHint: Bool, - workspaceShortcutLabel: String?, - debugXOffset: Double - ) -> CGFloat { - if showsWorkspaceShortcutHint, let workspaceShortcutLabel { - return SidebarWorkspaceShortcutHintMetrics.slotWidth( - label: workspaceShortcutLabel, - debugXOffset: debugXOffset - ) - } - - return canCloseWorkspace ? closeButtonWidth : 0 - } -} - -enum SidebarDropEdge: Equatable { - case top - case bottom -} - -struct SidebarDropIndicator: Equatable { - let tabId: UUID? - let edge: SidebarDropEdge -} - -enum SidebarDropPlanner { - static func indicator( - draggedTabId: UUID?, - targetTabId: UUID?, - tabIds: [UUID], - pinnedTabIds: Set, - pointerY: CGFloat? = nil, - targetHeight: CGFloat? = nil - ) -> SidebarDropIndicator? { - guard tabIds.count > 1, let draggedTabId else { return nil } - guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } - - let insertionPosition: Int - if let targetTabId { - guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil } - let edge: SidebarDropEdge - if let pointerY, let targetHeight { - edge = edgeForPointer(locationY: pointerY, targetHeight: targetHeight) - } else { - edge = preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds) - } - insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex - } else { - insertionPosition = tabIds.count - } - - let legalInsertionPosition = legalInsertionPosition( - draggedTabId: draggedTabId, - proposedInsertionPosition: insertionPosition, - tabIds: tabIds, - pinnedTabIds: pinnedTabIds - ) - let legalTargetIndex = resolvedTargetIndex( - from: fromIndex, - insertionPosition: legalInsertionPosition, - totalCount: tabIds.count - ) - guard legalTargetIndex != fromIndex else { return nil } - return indicatorForInsertionPosition(legalInsertionPosition, tabIds: tabIds) - } - - static func targetIndex( - draggedTabId: UUID, - targetTabId: UUID?, - indicator: SidebarDropIndicator?, - tabIds: [UUID], - pinnedTabIds: Set - ) -> Int? { - guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } - - let insertionPosition: Int - if let indicator, let indicatorInsertion = insertionPositionForIndicator(indicator, tabIds: tabIds) { - insertionPosition = indicatorInsertion - } else if let targetTabId { - guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil } - let edge = (indicator?.tabId == targetTabId) - ? (indicator?.edge ?? preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds)) - : preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds) - insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex - } else { - insertionPosition = tabIds.count - } - - let legalInsertionPosition = legalInsertionPosition( - draggedTabId: draggedTabId, - proposedInsertionPosition: insertionPosition, - tabIds: tabIds, - pinnedTabIds: pinnedTabIds - ) - return resolvedTargetIndex(from: fromIndex, insertionPosition: legalInsertionPosition, totalCount: tabIds.count) - } - - private static func indicatorForInsertionPosition(_ insertionPosition: Int, tabIds: [UUID]) -> SidebarDropIndicator { - let clampedInsertion = max(0, min(insertionPosition, tabIds.count)) - if clampedInsertion >= tabIds.count { - return SidebarDropIndicator(tabId: nil, edge: .bottom) - } - return SidebarDropIndicator(tabId: tabIds[clampedInsertion], edge: .top) - } - - private static func insertionPositionForIndicator(_ indicator: SidebarDropIndicator, tabIds: [UUID]) -> Int? { - if let tabId = indicator.tabId { - guard let targetTabIndex = tabIds.firstIndex(of: tabId) else { return nil } - return indicator.edge == .bottom ? targetTabIndex + 1 : targetTabIndex - } - return tabIds.count - } - - private static func preferredEdge(fromIndex: Int, targetTabId: UUID, tabIds: [UUID]) -> SidebarDropEdge { - guard let targetIndex = tabIds.firstIndex(of: targetTabId) else { return .top } - return fromIndex < targetIndex ? .bottom : .top - } - - private static func legalInsertionPosition( - draggedTabId: UUID, - proposedInsertionPosition: Int, - tabIds: [UUID], - pinnedTabIds: Set - ) -> Int { - let clampedInsertion = max(0, min(proposedInsertionPosition, tabIds.count)) - guard !pinnedTabIds.isEmpty else { return clampedInsertion } - - let pinnedCount = tabIds.reduce(into: 0) { count, tabId in - if pinnedTabIds.contains(tabId) { - count += 1 - } - } - guard pinnedCount > 0 else { return clampedInsertion } - - if pinnedTabIds.contains(draggedTabId) { - return min(clampedInsertion, pinnedCount) - } - return max(clampedInsertion, pinnedCount) - } - - static func edgeForPointer(locationY: CGFloat, targetHeight: CGFloat) -> SidebarDropEdge { - guard targetHeight > 0 else { return .top } - let clampedY = min(max(locationY, 0), targetHeight) - return clampedY < (targetHeight / 2) ? .top : .bottom - } - - private static func resolvedTargetIndex(from sourceIndex: Int, insertionPosition: Int, totalCount: Int) -> Int { - let clampedInsertion = max(0, min(insertionPosition, totalCount)) - let adjusted = clampedInsertion > sourceIndex ? clampedInsertion - 1 : clampedInsertion - return max(0, min(adjusted, max(0, totalCount - 1))) - } -} - -enum SidebarAutoScrollDirection: Equatable { - case up - case down -} - -struct SidebarAutoScrollPlan: Equatable { - let direction: SidebarAutoScrollDirection - let pointsPerTick: CGFloat -} - -enum SidebarDragAutoScrollPlanner { - static let edgeInset: CGFloat = 44 - static let minStep: CGFloat = 2 - static let maxStep: CGFloat = 12 - - static func plan( - distanceToTop: CGFloat, - distanceToBottom: CGFloat, - edgeInset: CGFloat = SidebarDragAutoScrollPlanner.edgeInset, - minStep: CGFloat = SidebarDragAutoScrollPlanner.minStep, - maxStep: CGFloat = SidebarDragAutoScrollPlanner.maxStep - ) -> SidebarAutoScrollPlan? { - guard edgeInset > 0, maxStep >= minStep else { return nil } - if distanceToTop <= edgeInset { - let normalized = max(0, min(1, (edgeInset - distanceToTop) / edgeInset)) - let step = minStep + ((maxStep - minStep) * normalized) - return SidebarAutoScrollPlan(direction: .up, pointsPerTick: step) - } - if distanceToBottom <= edgeInset { - let normalized = max(0, min(1, (edgeInset - distanceToBottom) / edgeInset)) - let step = minStep + ((maxStep - minStep) * normalized) - return SidebarAutoScrollPlan(direction: .down, pointsPerTick: step) - } - return nil - } -} - -@MainActor -final class SidebarDragAutoScrollController: ObservableObject { - private weak var scrollView: NSScrollView? - private var timer: Timer? - private var activePlan: SidebarAutoScrollPlan? - - func attach(scrollView: NSScrollView?) { - self.scrollView = scrollView - } - - func updateFromDragLocation() { - guard let scrollView else { - stop() - return - } - guard let plan = plan(for: scrollView) else { - stop() - return - } - activePlan = plan - startTimerIfNeeded() - } - - func stop() { - timer?.invalidate() - timer = nil - activePlan = nil - } - - private func startTimerIfNeeded() { - guard timer == nil else { return } - let timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - self?.tick() - } - } - self.timer = timer - RunLoop.main.add(timer, forMode: .eventTracking) - } - - private func tick() { - guard NSEvent.pressedMouseButtons != 0 else { - stop() - return - } - guard let scrollView else { - stop() - return - } - - // AppKit drag/drop autoscroll guidance recommends autoscroll(with:) - // when periodic drag updates are available; use it first. - if applyNativeAutoscroll(to: scrollView) { - activePlan = plan(for: scrollView) - if activePlan == nil { - stop() - } - return - } - - activePlan = self.plan(for: scrollView) - guard let plan = activePlan else { - stop() - return - } - _ = apply(plan: plan, to: scrollView) - } - - private func applyNativeAutoscroll(to scrollView: NSScrollView) -> Bool { - guard let event = NSApp.currentEvent else { return false } - switch event.type { - case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: - break - default: - return false - } - - let clipView = scrollView.contentView - let didScroll = clipView.autoscroll(with: event) - if didScroll { - scrollView.reflectScrolledClipView(clipView) - } - return didScroll - } - - private func distancesToEdges(mousePoint: CGPoint, viewportHeight: CGFloat, isFlipped: Bool) -> (top: CGFloat, bottom: CGFloat) { - if isFlipped { - return (top: mousePoint.y, bottom: viewportHeight - mousePoint.y) - } - return (top: viewportHeight - mousePoint.y, bottom: mousePoint.y) - } - - private func planForMousePoint(_ mousePoint: CGPoint, in clipView: NSClipView) -> SidebarAutoScrollPlan? { - let viewportHeight = clipView.bounds.height - guard viewportHeight > 0 else { return nil } - - let distances = distancesToEdges(mousePoint: mousePoint, viewportHeight: viewportHeight, isFlipped: clipView.isFlipped) - return SidebarDragAutoScrollPlanner.plan(distanceToTop: distances.top, distanceToBottom: distances.bottom) - } - - private func mousePoint(in clipView: NSClipView) -> CGPoint { - let mouseInWindow = clipView.window?.convertPoint(fromScreen: NSEvent.mouseLocation) ?? .zero - return clipView.convert(mouseInWindow, from: nil) - } - - private func currentPlan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { - let clipView = scrollView.contentView - let mouse = mousePoint(in: clipView) - return planForMousePoint(mouse, in: clipView) - } - - private func plan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { - currentPlan(for: scrollView) - } - - private func apply(plan: SidebarAutoScrollPlan, to scrollView: NSScrollView) -> Bool { - guard let documentView = scrollView.documentView else { return false } - let clipView = scrollView.contentView - let maxOriginY = max(0, documentView.bounds.height - clipView.bounds.height) - guard maxOriginY > 0 else { return false } - - let directionMultiplier: CGFloat = (plan.direction == .down) ? 1 : -1 - let flippedMultiplier: CGFloat = documentView.isFlipped ? 1 : -1 - let delta = directionMultiplier * flippedMultiplier * plan.pointsPerTick - let currentY = clipView.bounds.origin.y - let targetY = min(max(currentY + delta, 0), maxOriginY) - guard abs(targetY - currentY) > 0.01 else { return false } - - clipView.scroll(to: CGPoint(x: clipView.bounds.origin.x, y: targetY)) - scrollView.reflectScrolledClipView(clipView) - return true - } -} - -enum SidebarTabDragPayload { - static let typeIdentifier = "com.darkroom.programa.sidebar-tab-reorder" - static let dropContentType = UTType(exportedAs: typeIdentifier) - static let dropContentTypes: [UTType] = [dropContentType] - private static let prefix = "programa.sidebar-tab." - - static func provider(for tabId: UUID) -> NSItemProvider { - let provider = NSItemProvider() - let payload = "\(prefix)\(tabId.uuidString)" - provider.registerDataRepresentation(forTypeIdentifier: typeIdentifier, visibility: .ownProcess) { completion in - completion(payload.data(using: .utf8), nil) - return nil - } - return provider - } -} - -enum BonsplitTabDragPayload { - static let typeIdentifier = "com.splittabbar.tabtransfer" - static let dropContentType = UTType(exportedAs: typeIdentifier) - static let dropContentTypes: [UTType] = [dropContentType] - private static let currentProcessId = Int32(ProcessInfo.processInfo.processIdentifier) - - struct Transfer: Decodable { - struct TabInfo: Decodable { - let id: UUID - } - - let tab: TabInfo - let sourcePaneId: UUID - let sourceProcessId: Int32 - - private enum CodingKeys: String, CodingKey { - case tab - case sourcePaneId - case sourceProcessId - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.tab = try container.decode(TabInfo.self, forKey: .tab) - self.sourcePaneId = try container.decode(UUID.self, forKey: .sourcePaneId) - // Legacy payloads won't include this field. Treat as foreign process. - self.sourceProcessId = try container.decodeIfPresent(Int32.self, forKey: .sourceProcessId) ?? -1 - } - } - - private static func isCurrentProcessTransfer(_ transfer: Transfer) -> Bool { - transfer.sourceProcessId == currentProcessId - } - - static func currentTransfer() -> Transfer? { - let pasteboard = NSPasteboard(name: .drag) - let type = NSPasteboard.PasteboardType(typeIdentifier) - - if let data = pasteboard.data(forType: type), - let transfer = try? JSONDecoder().decode(Transfer.self, from: data), - isCurrentProcessTransfer(transfer) { - return transfer - } - - if let raw = pasteboard.string(forType: type), - let data = raw.data(using: .utf8), - let transfer = try? JSONDecoder().decode(Transfer.self, from: data), - isCurrentProcessTransfer(transfer) { - return transfer - } - - return nil - } -} - -struct SidebarBonsplitTabDropDelegate: DropDelegate { - let targetWorkspaceId: UUID - let tabManager: TabManager - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - - func validateDrop(info: DropInfo) -> Bool { - guard info.hasItemsConforming(to: [BonsplitTabDragPayload.typeIdentifier]) else { return false } - return BonsplitTabDragPayload.currentTransfer() != nil - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - guard validateDrop(info: info) else { return nil } - return DropProposal(operation: .move) - } - - func performDrop(info: DropInfo) -> Bool { - guard validateDrop(info: info), - let transfer = BonsplitTabDragPayload.currentTransfer(), - let app = AppDelegate.shared else { - return false - } - - if let source = app.locateBonsplitSurface(tabId: transfer.tab.id), - source.workspaceId == targetWorkspaceId { - syncSidebarSelection() - return true - } - - guard app.moveBonsplitTab( - tabId: transfer.tab.id, - toWorkspace: targetWorkspaceId, - focus: true, - focusWindow: true - ) else { - return false - } - - selectedTabIds = [targetWorkspaceId] - syncSidebarSelection() - return true - } - - private func syncSidebarSelection() { - if let selectedId = tabManager.selectedTabId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } else { - lastSidebarSelectionIndex = nil - } - } -} - -struct SidebarTabDropDelegate: DropDelegate { - let targetTabId: UUID? - let tabManager: TabManager - @Binding var draggedTabId: UUID? - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - let targetRowHeight: CGFloat? - let dragAutoScrollController: SidebarDragAutoScrollController - @Binding var dropIndicator: SidebarDropIndicator? - - func validateDrop(info: DropInfo) -> Bool { - let hasType = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) - let hasDrag = draggedTabId != nil - #if DEBUG - dlog("sidebar.validateDrop target=\(targetTabId?.uuidString.prefix(5) ?? "end") hasType=\(hasType) hasDrag=\(hasDrag)") - #endif - return hasType && hasDrag - } - - func dropEntered(info: DropInfo) { - #if DEBUG - dlog("sidebar.dropEntered target=\(targetTabId?.uuidString.prefix(5) ?? "end")") - #endif - dragAutoScrollController.updateFromDragLocation() - updateDropIndicator(for: info) - } - - func dropExited(info: DropInfo) { -#if DEBUG - dlog("sidebar.dropExited target=\(targetTabId?.uuidString.prefix(5) ?? "end")") -#endif - if dropIndicator?.tabId == targetTabId { - dropIndicator = nil - } - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - dragAutoScrollController.updateFromDragLocation() - updateDropIndicator(for: info) -#if DEBUG - dlog( - "sidebar.dropUpdated target=\(targetTabId?.uuidString.prefix(5) ?? "end") " + - "indicator=\(debugIndicator(dropIndicator))" - ) -#endif - return DropProposal(operation: .move) - } - - func performDrop(info: DropInfo) -> Bool { - defer { - draggedTabId = nil - dropIndicator = nil - dragAutoScrollController.stop() - } - #if DEBUG - dlog("sidebar.drop target=\(targetTabId?.uuidString.prefix(5) ?? "end")") - #endif - guard let draggedTabId else { -#if DEBUG - dlog("sidebar.drop.abort reason=missingDraggedTab") -#endif - return false - } - guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { -#if DEBUG - dlog("sidebar.drop.abort reason=draggedTabMissing tab=\(draggedTabId.uuidString.prefix(5))") -#endif - return false - } - let tabIds = tabManager.tabs.map(\.id) - guard let targetIndex = SidebarDropPlanner.targetIndex( - draggedTabId: draggedTabId, - targetTabId: targetTabId, - indicator: dropIndicator, - tabIds: tabIds, - pinnedTabIds: Set(tabManager.tabs.filter(\.isPinned).map(\.id)) - ) else { -#if DEBUG - dlog( - "sidebar.drop.abort reason=noTargetIndex tab=\(draggedTabId.uuidString.prefix(5)) " + - "target=\(targetTabId?.uuidString.prefix(5) ?? "end") indicator=\(debugIndicator(dropIndicator))" - ) -#endif - return false - } - - guard fromIndex != targetIndex else { -#if DEBUG - dlog("sidebar.drop.noop from=\(fromIndex) to=\(targetIndex)") -#endif - syncSidebarSelection() - return true - } - -#if DEBUG - dlog("sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) from=\(fromIndex) to=\(targetIndex)") -#endif - _ = tabManager.reorderWorkspace(tabId: draggedTabId, toIndex: targetIndex) - if let selectedId = tabManager.selectedTabId { - selectedTabIds = [selectedId] - syncSidebarSelection(preferredSelectedTabId: selectedId) - } else { - selectedTabIds = [] - syncSidebarSelection() - } - return true - } - - private func updateDropIndicator(for info: DropInfo) { - let tabIds = tabManager.tabs.map(\.id) - let pinnedTabIds = Set(tabManager.tabs.filter(\.isPinned).map(\.id)) - let nextIndicator = SidebarDropPlanner.indicator( - draggedTabId: draggedTabId, - targetTabId: targetTabId, - tabIds: tabIds, - pinnedTabIds: pinnedTabIds, - pointerY: targetTabId == nil ? nil : info.location.y, - targetHeight: targetRowHeight - ) - guard dropIndicator != nextIndicator else { return } - dropIndicator = nextIndicator - } - - private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { - let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId - if let selectedId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } else { - lastSidebarSelectionIndex = nil - } - } - - private func debugIndicator(_ indicator: SidebarDropIndicator?) -> String { - guard let indicator else { return "nil" } - let tabText = indicator.tabId.map { String($0.uuidString.prefix(5)) } ?? "end" - return "\(tabText):\(indicator.edge == .top ? "top" : "bottom")" - } -} - -struct MiddleClickCapture: NSViewRepresentable { - let onMiddleClick: () -> Void - - func makeNSView(context: Context) -> MiddleClickCaptureView { - let view = MiddleClickCaptureView() - view.onMiddleClick = onMiddleClick - return view - } - - func updateNSView(_ nsView: MiddleClickCaptureView, context: Context) { - nsView.onMiddleClick = onMiddleClick - } -} - -final class MiddleClickCaptureView: NSView { - var onMiddleClick: (() -> Void)? - - override func hitTest(_ point: NSPoint) -> NSView? { - // Only intercept middle-click so left-click selection and right-click context menus - // continue to hit-test through to SwiftUI/AppKit normally. - guard let event = NSApp.currentEvent, - event.type == .otherMouseDown, - event.buttonNumber == 2 else { - return nil - } - return self - } - - override func otherMouseDown(with event: NSEvent) { - guard event.buttonNumber == 2 else { - super.otherMouseDown(with: event) - return - } - onMiddleClick?() - } -} - -enum SidebarSelection { - case tabs - case notifications -} - -private struct ClearScrollBackground: ViewModifier { - func body(content: Content) -> some View { - content - .scrollContentBackground(.hidden) - .background(ScrollBackgroundClearer()) - } -} - -private struct ScrollBackgroundClearer: NSViewRepresentable { - func makeNSView(context: Context) -> NSView { - NSView() - } - - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.async { - guard let scrollView = findScrollView(startingAt: nsView) else { return } - // Clear all backgrounds and mark as non-opaque for transparency - scrollView.drawsBackground = false - scrollView.backgroundColor = .clear - scrollView.wantsLayer = true - scrollView.layer?.backgroundColor = NSColor.clear.cgColor - scrollView.layer?.isOpaque = false - - scrollView.contentView.drawsBackground = false - scrollView.contentView.backgroundColor = .clear - scrollView.contentView.wantsLayer = true - scrollView.contentView.layer?.backgroundColor = NSColor.clear.cgColor - scrollView.contentView.layer?.isOpaque = false - - if let docView = scrollView.documentView { - docView.wantsLayer = true - docView.layer?.backgroundColor = NSColor.clear.cgColor - docView.layer?.isOpaque = false - } - } - } - - private func findScrollView(startingAt view: NSView) -> NSScrollView? { - var current: NSView? = view - while let candidate = current { - if let scrollView = candidate as? NSScrollView { - return scrollView - } - current = candidate.superview - } - return nil - } -} - -private struct DraggableFolderIcon: View { - let directory: String - - var body: some View { - DraggableFolderIconRepresentable(directory: directory) - .frame(width: 16, height: 16) - .safeHelp(String(localized: "sidebar.folderIcon.dragHint", defaultValue: "Drag to open in Finder or another app")) - .onTapGesture(count: 2) { - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directory) - } - } -} - -private struct DraggableFolderIconRepresentable: NSViewRepresentable { - let directory: String - - func makeNSView(context: Context) -> DraggableFolderNSView { - DraggableFolderNSView(directory: directory) - } - - func updateNSView(_ nsView: DraggableFolderNSView, context: Context) { - nsView.directory = directory - nsView.updateIcon() - } -} - -final class DraggableFolderNSView: NSView, NSDraggingSource { - private final class FolderIconImageView: NSImageView { - override var mouseDownCanMoveWindow: Bool { false } - } - - var directory: String - private var imageView: FolderIconImageView! - private var previousWindowMovableState: Bool? - private weak var suppressedWindow: NSWindow? - private var hasActiveDragSession = false - private var didArmWindowDragSuppression = false - - private func formatPoint(_ point: NSPoint) -> String { - String(format: "(%.1f,%.1f)", point.x, point.y) - } - - init(directory: String) { - self.directory = directory - super.init(frame: .zero) - setupImageView() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var intrinsicContentSize: NSSize { - NSSize(width: 16, height: 16) - } - - override var mouseDownCanMoveWindow: Bool { false } - - private func setupImageView() { - imageView = FolderIconImageView() - imageView.imageScaling = .scaleProportionallyDown - imageView.translatesAutoresizingMaskIntoConstraints = false - addSubview(imageView) - NSLayoutConstraint.activate([ - imageView.leadingAnchor.constraint(equalTo: leadingAnchor), - imageView.trailingAnchor.constraint(equalTo: trailingAnchor), - imageView.topAnchor.constraint(equalTo: topAnchor), - imageView.bottomAnchor.constraint(equalTo: bottomAnchor), - imageView.widthAnchor.constraint(equalToConstant: 16), - imageView.heightAnchor.constraint(equalToConstant: 16), - ]) - updateIcon() - } - - func updateIcon() { - let icon = NSWorkspace.shared.icon(forFile: directory) - icon.size = NSSize(width: 16, height: 16) - imageView.image = icon - } - - func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { - return context == .outsideApplication ? [.copy, .link] : .copy - } - - func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { - hasActiveDragSession = false - restoreWindowMovableStateIfNeeded() - #if DEBUG - let nowMovable = window.map { String($0.isMovable) } ?? "nil" - let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" - dlog("folder.dragEnd dir=\(directory) operation=\(operation.rawValue) screen=\(formatPoint(screenPoint)) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") - #endif - } - - override func hitTest(_ point: NSPoint) -> NSView? { - guard bounds.contains(point) else { return nil } - let hit = super.hitTest(point) - #if DEBUG - let hitDesc = hit.map { String(describing: type(of: $0)) } ?? "nil" - let imageHit = (hit === imageView) - let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" - let nowMovable = window.map { String($0.isMovable) } ?? "nil" - dlog("folder.hitTest point=\(formatPoint(point)) hit=\(hitDesc) imageViewHit=\(imageHit) returning=DraggableFolderNSView wasMovable=\(wasMovable) nowMovable=\(nowMovable)") - #endif - return self - } - - override func mouseDown(with event: NSEvent) { - maybeDisableWindowDraggingEarly(trigger: "mouseDown") - hasActiveDragSession = false - #if DEBUG - let localPoint = convert(event.locationInWindow, from: nil) - let responderDesc = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" - let nowMovable = window.map { String($0.isMovable) } ?? "nil" - let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" - dlog("folder.mouseDown dir=\(directory) point=\(formatPoint(localPoint)) firstResponder=\(responderDesc) wasMovable=\(wasMovable) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") - #endif - let fileURL = URL(fileURLWithPath: directory) - let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL) - - let iconImage = NSWorkspace.shared.icon(forFile: directory) - iconImage.size = NSSize(width: 32, height: 32) - draggingItem.setDraggingFrame(bounds, contents: iconImage) - - let session = beginDraggingSession(with: [draggingItem], event: event, source: self) - hasActiveDragSession = true - #if DEBUG - let itemCount = session.draggingPasteboard.pasteboardItems?.count ?? 0 - dlog("folder.dragStart dir=\(directory) pasteboardItems=\(itemCount)") - #endif - } - - override func mouseUp(with event: NSEvent) { - super.mouseUp(with: event) - // Always restore suppression on mouse-up; drag-session callbacks can be - // skipped for non-started drags, which would otherwise leave suppression stuck. - restoreWindowMovableStateIfNeeded() - } - - override func rightMouseDown(with event: NSEvent) { - let menu = buildPathMenu() - // Pop up menu at bottom-left of icon (like native proxy icon) - let menuLocation = NSPoint(x: 0, y: bounds.height) - menu.popUp(positioning: nil, at: menuLocation, in: self) - } - - private func buildPathMenu() -> NSMenu { - let menu = NSMenu() - let url = URL(fileURLWithPath: directory).standardized - var pathComponents: [URL] = [] - - // Build path from current directory up to root - var current = url - while current.path != "/" { - pathComponents.append(current) - current = current.deletingLastPathComponent() - } - pathComponents.append(URL(fileURLWithPath: "/")) - - // Add path components (current dir at top, root at bottom - matches native macOS) - for pathURL in pathComponents { - let icon = NSWorkspace.shared.icon(forFile: pathURL.path) - icon.size = NSSize(width: 16, height: 16) - - let displayName: String - if pathURL.path == "/" { - // Use the volume name for root - if let volumeName = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeNameKey]).volumeName { - displayName = volumeName - } else { - displayName = String(localized: "sidebar.pathMenu.macintoshHD", defaultValue: "Macintosh HD") - } - } else { - displayName = FileManager.default.displayName(atPath: pathURL.path) - } - - let item = NSMenuItem(title: displayName, action: #selector(openPathComponent(_:)), keyEquivalent: "") - item.target = self - item.image = icon - item.representedObject = pathURL - menu.addItem(item) - } - - // Add computer name at the bottom (like native proxy icon) - let computerName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName - let computerIcon = NSImage(named: NSImage.computerName) ?? NSImage() - computerIcon.size = NSSize(width: 16, height: 16) - - let computerItem = NSMenuItem(title: computerName, action: #selector(openComputer(_:)), keyEquivalent: "") - computerItem.target = self - computerItem.image = computerIcon - menu.addItem(computerItem) - - return menu - } - - @objc private func openPathComponent(_ sender: NSMenuItem) { - guard let url = sender.representedObject as? URL else { return } - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path) - } - - @objc private func openComputer(_ sender: NSMenuItem) { - // Open "Computer" view in Finder (shows all volumes) - NSWorkspace.shared.open(URL(fileURLWithPath: "/", isDirectory: true)) - } - - private func restoreWindowMovableStateIfNeeded() { - guard didArmWindowDragSuppression || previousWindowMovableState != nil else { return } - let targetWindow = suppressedWindow ?? window - let depthAfter = endWindowDragSuppression(window: targetWindow) - restoreWindowDragging(window: targetWindow, previousMovableState: previousWindowMovableState) - self.previousWindowMovableState = nil - self.suppressedWindow = nil - self.didArmWindowDragSuppression = false - #if DEBUG - let nowMovable = targetWindow.map { String($0.isMovable) } ?? "nil" - dlog("folder.dragSuppression restore depth=\(depthAfter) nowMovable=\(nowMovable)") - #endif - } - - private func maybeDisableWindowDraggingEarly(trigger: String) { - guard !didArmWindowDragSuppression else { return } - guard let eventType = NSApp.currentEvent?.type, - eventType == .leftMouseDown || eventType == .leftMouseDragged else { - return - } - guard let currentWindow = window else { return } - - didArmWindowDragSuppression = true - suppressedWindow = currentWindow - let suppressionDepth = beginWindowDragSuppression(window: currentWindow) ?? 0 - if currentWindow.isMovable { - previousWindowMovableState = temporarilyDisableWindowDragging(window: currentWindow) - } else { - previousWindowMovableState = nil - } - #if DEBUG - let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" - let nowMovable = String(currentWindow.isMovable) - dlog( - "folder.dragSuppression trigger=\(trigger) event=\(eventType) depth=\(suppressionDepth) wasMovable=\(wasMovable) nowMovable=\(nowMovable)" - ) - #endif - } -} - -func temporarilyDisableWindowDragging(window: NSWindow?) -> Bool? { - guard let window else { return nil } - let wasMovable = window.isMovable - if wasMovable { - window.isMovable = false - } - return wasMovable -} - -func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) { - guard let window, let previousMovableState else { return } - window.isMovable = previousMovableState -} - -/// Wrapper view that tries NSGlassEffectView (macOS 26+) when available or requested -private struct SidebarVisualEffectBackground: NSViewRepresentable { - let material: NSVisualEffectView.Material - let blendingMode: NSVisualEffectView.BlendingMode - let state: NSVisualEffectView.State - let opacity: Double - let tintColor: NSColor? - let cornerRadius: CGFloat - let preferLiquidGlass: Bool - - init( - material: NSVisualEffectView.Material = .hudWindow, - blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, - state: NSVisualEffectView.State = .active, - opacity: Double = 1.0, - tintColor: NSColor? = nil, - cornerRadius: CGFloat = 0, - preferLiquidGlass: Bool = false - ) { - self.material = material - self.blendingMode = blendingMode - self.state = state - self.opacity = opacity - self.tintColor = tintColor - self.cornerRadius = cornerRadius - self.preferLiquidGlass = preferLiquidGlass - } - - static var liquidGlassAvailable: Bool { - NSClassFromString("NSGlassEffectView") != nil - } - - func makeNSView(context: Context) -> NSView { - // Try NSGlassEffectView if preferred or if we want to test availability - if preferLiquidGlass, let glassClass = NSClassFromString("NSGlassEffectView") as? NSView.Type { - let glass = glassClass.init(frame: .zero) - glass.autoresizingMask = [.width, .height] - glass.wantsLayer = true - return glass - } - - // Use NSVisualEffectView - let view = NSVisualEffectView() - view.autoresizingMask = [.width, .height] - view.wantsLayer = true - view.layerContentsRedrawPolicy = .onSetNeedsDisplay - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - // Configure based on view type - if nsView.className == "NSGlassEffectView" { - // NSGlassEffectView configuration via private API - nsView.alphaValue = max(0.0, min(1.0, opacity)) - nsView.layer?.cornerRadius = cornerRadius - nsView.layer?.masksToBounds = cornerRadius > 0 - - // Try to set tint color via private selector - if let color = tintColor { - let selector = NSSelectorFromString("setTintColor:") - if nsView.responds(to: selector) { - nsView.perform(selector, with: color) - } - } - } else if let visualEffect = nsView as? NSVisualEffectView { - // NSVisualEffectView configuration - visualEffect.material = material - visualEffect.blendingMode = blendingMode - visualEffect.state = state - visualEffect.alphaValue = max(0.0, min(1.0, opacity)) - visualEffect.layer?.cornerRadius = cornerRadius - visualEffect.layer?.masksToBounds = cornerRadius > 0 - visualEffect.needsDisplay = true - } - } -} - -/// Reads the leading inset required to clear traffic lights + left titlebar accessories. -final class TitlebarLeadingInsetPassthroughView: NSView { - override var mouseDownCanMoveWindow: Bool { false } - override func hitTest(_ point: NSPoint) -> NSView? { nil } -} - -private struct TitlebarLeadingInsetReader: NSViewRepresentable { - @Binding var inset: CGFloat - - func makeNSView(context: Context) -> NSView { - let view = TitlebarLeadingInsetPassthroughView() - view.setFrameSize(.zero) - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.async { - guard let window = nsView.window else { return } - // Start past the traffic lights - var leading: CGFloat = 78 - // Add width of all left-aligned titlebar accessories - for accessory in window.titlebarAccessoryViewControllers - where accessory.layoutAttribute == .leading || accessory.layoutAttribute == .left { - leading += accessory.view.frame.width - } - leading += 0 - if leading != inset { - inset = leading - } - } - } -} - -/// 1px trailing border on the sidebar, derived from the terminal chrome background -/// using the same logic as bonsplit's TabBarColors.nsColorSeparator: -/// dark bg → lighten RGB by 0.16 at 0.36 alpha; light bg → darken by 0.12 at 0.26 alpha. -private struct SidebarTrailingBorder: View { - @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false - @State private var separatorColor: NSColor = chromeSeparatorColor() - - var body: some View { - if matchTerminalBackground { - Rectangle() - .fill(Color(nsColor: separatorColor)) - .frame(width: 1) - .ignoresSafeArea() - .onAppear { - separatorColor = Self.chromeSeparatorColor() - } - .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in - separatorColor = Self.chromeSeparatorColor() - } - } - } - - /// Replicates bonsplit TabBarColors.nsColorSeparator derivation from chrome background. - private static func chromeSeparatorColor() -> NSColor { - let chrome = GhosttyBackgroundTheme.currentColor() - let srgb = chrome.usingColorSpace(.sRGB) ?? chrome - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - srgb.getRed(&r, green: &g, blue: &b, alpha: &a) - let luminance = 0.299 * r + 0.587 * g + 0.114 * b - let isLight = luminance > 0.5 - let amount: CGFloat = isLight ? -0.12 : 0.16 - let alpha: CGFloat = isLight ? 0.26 : 0.36 - return NSColor( - red: min(1.0, max(0.0, r + amount)), - green: min(1.0, max(0.0, g + amount)), - blue: min(1.0, max(0.0, b + amount)), - alpha: alpha - ) - } -} - -/// Sidebar background that uses the same technique as TitlebarLayerBackground: -/// fully opaque layer color + layer-level opacity. This matches how the terminal's -/// Metal surface composites its background. -private struct SidebarTerminalBackgroundView: NSViewRepresentable { - let backgroundColor: NSColor - let opacity: CGFloat - - func makeNSView(context: Context) -> NSView { - let view = NSView() - view.wantsLayer = true - view.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor - view.layer?.opacity = Float(opacity) - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - nsView.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor - nsView.layer?.opacity = Float(opacity) - } -} - -private struct SidebarBackdrop: View { - @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false - @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity - @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex - @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? - @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? - @AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue - @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue - @AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue - @AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0 - @AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0 - @Environment(\.colorScheme) private var colorScheme - @State private var terminalBackgroundColor: NSColor = GhosttyBackgroundTheme.currentColor() - - var body: some View { - let cornerRadius = CGFloat(max(0, sidebarCornerRadius)) - - if matchTerminalBackground { - // The terminal background is provided by a single CALayer, so - // the sidebar uses the configured opacity directly. - let alpha = CGFloat(GhosttyApp.shared.defaultBackgroundOpacity) - return AnyView( - SidebarTerminalBackgroundView( - backgroundColor: GhosttyApp.shared.defaultBackgroundColor, - opacity: alpha - ) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in - terminalBackgroundColor = GhosttyBackgroundTheme.currentColor() - } - ) - } - - let materialOption = SidebarMaterialOption(rawValue: sidebarMaterial) - let blendingMode = SidebarBlendModeOption(rawValue: sidebarBlendMode)?.mode ?? .behindWindow - let state = SidebarStateOption(rawValue: sidebarState)?.state ?? .active - let resolvedHex: String = { - if colorScheme == .dark, let dark = sidebarTintHexDark { - return dark - } else if colorScheme == .light, let light = sidebarTintHexLight { - return light - } - return sidebarTintHex - }() - let tintColor = (NSColor(hex: resolvedHex) ?? NSColor(hex: sidebarTintHex) ?? .black).withAlphaComponent(sidebarTintOpacity) - let useLiquidGlass = materialOption?.usesLiquidGlass ?? false - let useWindowLevelGlass = useLiquidGlass && blendingMode == .behindWindow - - return AnyView( - ZStack { - if let material = materialOption?.material { - // When using liquidGlass + behindWindow, window handles glass + tint - // Sidebar is fully transparent - if !useWindowLevelGlass { - SidebarVisualEffectBackground( - material: material, - blendingMode: blendingMode, - state: state, - opacity: sidebarBlurOpacity, - tintColor: tintColor, - cornerRadius: cornerRadius, - preferLiquidGlass: useLiquidGlass - ) - // Tint overlay for NSVisualEffectView fallback - if !useLiquidGlass { - Color(nsColor: tintColor) - } - } - } - // When material is none or useWindowLevelGlass, render nothing - } - .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - ) - } -} - -enum SidebarMaterialOption: String, CaseIterable, Identifiable { - case none - case liquidGlass // macOS 26+ NSGlassEffectView - case sidebar - case hudWindow - case menu - case popover - case underWindowBackground - case windowBackground - case contentBackground - case fullScreenUI - case sheet - case headerView - case toolTip - - var id: String { rawValue } - - var title: String { - switch self { - case .none: return String(localized: "settings.material.none", defaultValue: "None") - case .liquidGlass: return String(localized: "settings.material.liquidGlass", defaultValue: "Liquid Glass (macOS 26+)") - case .sidebar: return String(localized: "settings.material.sidebar", defaultValue: "Sidebar") - case .hudWindow: return String(localized: "settings.material.hudWindow", defaultValue: "HUD Window") - case .menu: return String(localized: "settings.material.menu", defaultValue: "Menu") - case .popover: return String(localized: "settings.material.popover", defaultValue: "Popover") - case .underWindowBackground: return String(localized: "settings.material.underWindow", defaultValue: "Under Window") - case .windowBackground: return String(localized: "settings.material.windowBackground", defaultValue: "Window Background") - case .contentBackground: return String(localized: "settings.material.contentBackground", defaultValue: "Content Background") - case .fullScreenUI: return String(localized: "settings.material.fullScreenUI", defaultValue: "Full Screen UI") - case .sheet: return String(localized: "settings.material.sheet", defaultValue: "Sheet") - case .headerView: return String(localized: "settings.material.headerView", defaultValue: "Header View") - case .toolTip: return String(localized: "settings.material.toolTip", defaultValue: "Tool Tip") - } - } - - /// Returns true if this option should use NSGlassEffectView (macOS 26+) - var usesLiquidGlass: Bool { - self == .liquidGlass - } - - var material: NSVisualEffectView.Material? { - switch self { - case .none: return nil - case .liquidGlass: return .underWindowBackground // Fallback material - case .sidebar: return .sidebar - case .hudWindow: return .hudWindow - case .menu: return .menu - case .popover: return .popover - case .underWindowBackground: return .underWindowBackground - case .windowBackground: return .windowBackground - case .contentBackground: return .contentBackground - case .fullScreenUI: return .fullScreenUI - case .sheet: return .sheet - case .headerView: return .headerView - case .toolTip: return .toolTip - } - } -} - -enum SidebarBlendModeOption: String, CaseIterable, Identifiable { - case behindWindow - case withinWindow - - var id: String { rawValue } - - var title: String { - switch self { - case .behindWindow: return String(localized: "settings.blendMode.behindWindow", defaultValue: "Behind Window") - case .withinWindow: return String(localized: "settings.blendMode.withinWindow", defaultValue: "Within Window") - } - } - - var mode: NSVisualEffectView.BlendingMode { - switch self { - case .behindWindow: return .behindWindow - case .withinWindow: return .withinWindow - } - } -} - -enum SidebarStateOption: String, CaseIterable, Identifiable { - case active - case inactive - case followWindow - - var id: String { rawValue } - - var title: String { - switch self { - case .active: return String(localized: "settings.state.active", defaultValue: "Active") - case .inactive: return String(localized: "settings.state.inactive", defaultValue: "Inactive") - case .followWindow: return String(localized: "settings.state.followWindow", defaultValue: "Follow Window") - } - } - - var state: NSVisualEffectView.State { - switch self { - case .active: return .active - case .inactive: return .inactive - case .followWindow: return .followsWindowActiveState - } - } -} - -enum SidebarTintDefaults { - static let hex = "#000000" - static let opacity = 0.18 -} - -enum SidebarPresetOption: String, CaseIterable, Identifiable { - case nativeSidebar - case glassBehind - case softBlur - case popoverGlass - case hudGlass - case underWindow - - var id: String { rawValue } - - var title: String { - switch self { - case .nativeSidebar: return String(localized: "settings.preset.nativeSidebar", defaultValue: "Native Sidebar") - case .glassBehind: return String(localized: "settings.preset.raycastGray", defaultValue: "Raycast Gray") - case .softBlur: return String(localized: "settings.preset.softBlur", defaultValue: "Soft Blur") - case .popoverGlass: return String(localized: "settings.preset.popoverGlass", defaultValue: "Popover Glass") - case .hudGlass: return String(localized: "settings.preset.hudGlass", defaultValue: "HUD Glass") - case .underWindow: return String(localized: "settings.preset.underWindow", defaultValue: "Under Window") - } - } - - var material: SidebarMaterialOption { - switch self { - case .nativeSidebar: return .sidebar - case .glassBehind: return .sidebar - case .softBlur: return .sidebar - case .popoverGlass: return .popover - case .hudGlass: return .hudWindow - case .underWindow: return .underWindowBackground - } - } - - var blendMode: SidebarBlendModeOption { - switch self { - case .nativeSidebar: return .withinWindow - case .glassBehind: return .behindWindow - case .softBlur: return .behindWindow - case .popoverGlass: return .behindWindow - case .hudGlass: return .withinWindow - case .underWindow: return .withinWindow - } - } - - var state: SidebarStateOption { - switch self { - case .nativeSidebar: return .followWindow - case .glassBehind: return .active - case .softBlur: return .active - case .popoverGlass: return .active - case .hudGlass: return .active - case .underWindow: return .followWindow - } - } - - var tintHex: String { - switch self { - case .nativeSidebar: return "#000000" - case .glassBehind: return "#000000" - case .softBlur: return "#000000" - case .popoverGlass: return "#000000" - case .hudGlass: return "#000000" - case .underWindow: return "#000000" - } - } - - var tintOpacity: Double { - switch self { - case .nativeSidebar: return 0.18 - case .glassBehind: return 0.36 - case .softBlur: return 0.28 - case .popoverGlass: return 0.10 - case .hudGlass: return 0.62 - case .underWindow: return 0.14 - } - } - - var cornerRadius: Double { - switch self { - case .nativeSidebar: return 0.0 - case .glassBehind: return 0.0 - case .softBlur: return 0.0 - case .popoverGlass: return 10.0 - case .hudGlass: return 10.0 - case .underWindow: return 6.0 - } - } - - var blurOpacity: Double { - switch self { - case .nativeSidebar: return 1.0 - case .glassBehind: return 0.6 - case .softBlur: return 0.45 - case .popoverGlass: return 0.9 - case .hudGlass: return 0.98 - case .underWindow: return 0.9 - } - } -} - -extension NSColor { - func hexString(includeAlpha: Bool = false) -> String { - let color = usingColorSpace(.sRGB) ?? self - var red: CGFloat = 0 - var green: CGFloat = 0 - var blue: CGFloat = 0 - var alpha: CGFloat = 0 - color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - let redByte = min(255, max(0, Int((red * 255).rounded()))) - let greenByte = min(255, max(0, Int((green * 255).rounded()))) - let blueByte = min(255, max(0, Int((blue * 255).rounded()))) - if includeAlpha { - let alphaByte = min(255, max(0, Int((alpha * 255).rounded()))) - return String(format: "#%02X%02X%02X%02X", redByte, greenByte, blueByte, alphaByte) - } - return String(format: "#%02X%02X%02X", redByte, greenByte, blueByte) - } -} diff --git a/Sources/SidebarDragDrop.swift b/Sources/SidebarDragDrop.swift new file mode 100644 index 00000000000..5413e57ab83 --- /dev/null +++ b/Sources/SidebarDragDrop.swift @@ -0,0 +1,926 @@ +// Sidebar drag-and-drop domain, extracted from ContentView.swift (nuclear-review #94.4). +// Pure move: DragOverlayRoutingPolicy, drag payload enums, drop delegates, +// drop edge/indicator/planner, auto-scroll planner, and drag lifecycle/failsafe +// types, consolidated from several non-contiguous locations in ContentView.swift. +// +// Access-level widening: SidebarDragFailsafeMonitor and SidebarExternalDropOverlay +// were file-private to the old ContentView.swift; widened to internal (dropped +// the `private` modifier) because VerticalTabsSidebar and ContentView, which stay +// in ContentView.swift, still construct/reference them. No other behavior change. + +import AppKit +import Bonsplit +import Combine +import SwiftUI +import UniformTypeIdentifiers + +enum DragOverlayRoutingPolicy { + static let bonsplitTabTransferType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") + static let sidebarTabReorderType = NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) + + static func hasBonsplitTabTransfer(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + guard let pasteboardTypes else { return false } + return pasteboardTypes.contains(bonsplitTabTransferType) + } + + static func hasSidebarTabReorder(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + guard let pasteboardTypes else { return false } + return pasteboardTypes.contains(sidebarTabReorderType) + } + + static func hasFileURL(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + guard let pasteboardTypes else { return false } + return pasteboardTypes.contains(.fileURL) + } + + static func shouldCaptureFileDropDestination( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + hasLocalDraggingSource: Bool + ) -> Bool { + // Local file drags (e.g. in-app draggable folder views) are valid drop + // inputs; rely on explicit non-file drag types below to avoid hijacking + // Bonsplit/sidebar drags. + _ = hasLocalDraggingSource + guard hasFileURL(pasteboardTypes) else { return false } + + // Prefer explicit non-file drag types so stale fileURL entries cannot hijack + // Bonsplit tab drags or sidebar tab reorder drags. + if hasBonsplitTabTransfer(pasteboardTypes) { return false } + if hasSidebarTabReorder(pasteboardTypes) { return false } + return true + } + + static func shouldCaptureFileDropDestination( + pasteboardTypes: [NSPasteboard.PasteboardType]? + ) -> Bool { + shouldCaptureFileDropDestination( + pasteboardTypes: pasteboardTypes, + hasLocalDraggingSource: false + ) + } + + static func shouldCaptureFileDropOverlay( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) -> Bool { + guard shouldCaptureFileDropDestination(pasteboardTypes: pasteboardTypes) else { return false } + guard isDragMouseEvent(eventType) else { return false } + return true + } + + static func shouldCaptureSidebarExternalOverlay( + hasSidebarDragState: Bool, + pasteboardTypes: [NSPasteboard.PasteboardType]? + ) -> Bool { + guard hasSidebarDragState else { return false } + return hasSidebarTabReorder(pasteboardTypes) + } + + static func shouldCaptureSidebarExternalOverlay( + draggedTabId: UUID?, + pasteboardTypes: [NSPasteboard.PasteboardType]? + ) -> Bool { + shouldCaptureSidebarExternalOverlay( + hasSidebarDragState: draggedTabId != nil, + pasteboardTypes: pasteboardTypes + ) + } + + static func shouldPassThroughPortalHitTesting( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) -> Bool { + guard isPortalDragEvent(eventType) else { return false } + return hasBonsplitTabTransfer(pasteboardTypes) || hasSidebarTabReorder(pasteboardTypes) + } + + private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { + eventType == .leftMouseDragged + || eventType == .rightMouseDragged + || eventType == .otherMouseDragged + } + + private static func isPortalDragEvent(_ eventType: NSEvent.EventType?) -> Bool { + // Restrict portal pass-through to explicit drag-motion events so stale + // NSPasteboard(name: .drag) types cannot hijack normal pointer input. + guard let eventType else { return false } + switch eventType { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + return true + default: + return false + } + } +} + +enum SidebarDragLifecycleNotification { + static let stateDidChange = Notification.Name("programa.sidebarDragStateDidChange") + static let requestClear = Notification.Name("programa.sidebarDragRequestClear") + static let tabIdKey = "tabId" + static let reasonKey = "reason" + + static func postStateDidChange(tabId: UUID?, reason: String) { + var userInfo: [AnyHashable: Any] = [reasonKey: reason] + if let tabId { + userInfo[tabIdKey] = tabId + } + NotificationCenter.default.post( + name: stateDidChange, + object: nil, + userInfo: userInfo + ) + } + + static func postClearRequest(reason: String) { + NotificationCenter.default.post( + name: requestClear, + object: nil, + userInfo: [reasonKey: reason] + ) + } + + static func tabId(from notification: Notification) -> UUID? { + notification.userInfo?[tabIdKey] as? UUID + } + + static func reason(from notification: Notification) -> String { + notification.userInfo?[reasonKey] as? String ?? "unknown" + } +} + +enum SidebarOutsideDropResetPolicy { + static func shouldResetDrag(draggedTabId: UUID?, hasSidebarDragPayload: Bool) -> Bool { + draggedTabId != nil && hasSidebarDragPayload + } +} + +enum SidebarDragFailsafePolicy { + static let clearDelay: TimeInterval = 0.15 + + static func shouldRequestClear(isDragActive: Bool, isLeftMouseButtonDown: Bool) -> Bool { + isDragActive && !isLeftMouseButtonDown + } + + static func shouldRequestClearWhenMonitoringStarts(isLeftMouseButtonDown: Bool) -> Bool { + shouldRequestClear( + isDragActive: true, + isLeftMouseButtonDown: isLeftMouseButtonDown + ) + } + + static func shouldRequestClear(forMouseEventType eventType: NSEvent.EventType) -> Bool { + eventType == .leftMouseUp + } +} + +@MainActor +final class SidebarDragFailsafeMonitor: ObservableObject { + private static let escapeKeyCode: UInt16 = 53 + private var pendingClearWorkItem: DispatchWorkItem? + private var appResignObserver: NSObjectProtocol? + private var keyDownMonitor: Any? + private var localMouseMonitor: Any? + private var globalMouseMonitor: Any? + private var onRequestClear: ((String) -> Void)? + + func start(onRequestClear: @escaping (String) -> Void) { + self.onRequestClear = onRequestClear + if SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts( + isLeftMouseButtonDown: CGEventSource.buttonState( + .combinedSessionState, + button: .left + ) + ) { + requestClearSoon(reason: "mouse_up_failsafe") + } + if appResignObserver == nil { + appResignObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.requestClearSoon(reason: "app_resign_active") + } + } + } + if keyDownMonitor == nil { + keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + if event.keyCode == Self.escapeKeyCode { + self?.requestClearSoon(reason: "escape_cancel") + } + return event + } + } + if localMouseMonitor == nil { + localMouseMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in + if SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) { + self?.requestClearSoon(reason: "mouse_up_failsafe") + } + return event + } + } + if globalMouseMonitor == nil { + globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in + guard SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) else { return } + Task { @MainActor [weak self] in + self?.requestClearSoon(reason: "mouse_up_failsafe") + } + } + } + } + + func stop() { + pendingClearWorkItem?.cancel() + pendingClearWorkItem = nil + if let appResignObserver { + NotificationCenter.default.removeObserver(appResignObserver) + self.appResignObserver = nil + } + if let keyDownMonitor { + NSEvent.removeMonitor(keyDownMonitor) + self.keyDownMonitor = nil + } + if let localMouseMonitor { + NSEvent.removeMonitor(localMouseMonitor) + self.localMouseMonitor = nil + } + if let globalMouseMonitor { + NSEvent.removeMonitor(globalMouseMonitor) + self.globalMouseMonitor = nil + } + onRequestClear = nil + } + + private func requestClearSoon(reason: String) { + guard pendingClearWorkItem == nil else { return } +#if DEBUG + dlog("sidebar.dragFailsafe.schedule reason=\(reason)") +#endif + let workItem = DispatchWorkItem { [weak self] in +#if DEBUG + dlog("sidebar.dragFailsafe.fire reason=\(reason)") +#endif + self?.pendingClearWorkItem = nil + self?.onRequestClear?(reason) + } + pendingClearWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + SidebarDragFailsafePolicy.clearDelay, execute: workItem) + } +} + +struct SidebarExternalDropOverlay: View { + let draggedTabId: UUID? + + var body: some View { + let dragPasteboardTypes = NSPasteboard(name: .drag).types + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureSidebarExternalOverlay( + draggedTabId: draggedTabId, + pasteboardTypes: dragPasteboardTypes + ) + Group { + if shouldCapture { + Color.clear + .contentShape(Rectangle()) + .allowsHitTesting(true) + .onDrop( + of: SidebarTabDragPayload.dropContentTypes, + delegate: SidebarExternalDropDelegate(draggedTabId: draggedTabId) + ) + } else { + Color.clear + .contentShape(Rectangle()) + .allowsHitTesting(false) + } + } + } +} + +private struct SidebarExternalDropDelegate: DropDelegate { + let draggedTabId: UUID? + + func validateDrop(info: DropInfo) -> Bool { + let hasSidebarPayload = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) + let shouldReset = SidebarOutsideDropResetPolicy.shouldResetDrag( + draggedTabId: draggedTabId, + hasSidebarDragPayload: hasSidebarPayload + ) +#if DEBUG + dlog( + "sidebar.dropOutside.validate tab=\(debugShortSidebarTabId(draggedTabId)) " + + "hasType=\(hasSidebarPayload) allowed=\(shouldReset)" + ) +#endif + return shouldReset + } + + func dropEntered(info: DropInfo) { +#if DEBUG + dlog("sidebar.dropOutside.entered tab=\(debugShortSidebarTabId(draggedTabId))") +#endif + } + + func dropExited(info: DropInfo) { +#if DEBUG + dlog("sidebar.dropOutside.exited tab=\(debugShortSidebarTabId(draggedTabId))") +#endif + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + guard validateDrop(info: info) else { return nil } +#if DEBUG + dlog("sidebar.dropOutside.updated tab=\(debugShortSidebarTabId(draggedTabId)) op=move") +#endif + // Explicit move proposal avoids AppKit showing a copy (+) cursor. + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + guard validateDrop(info: info) else { return false } +#if DEBUG + dlog("sidebar.dropOutside.perform tab=\(debugShortSidebarTabId(draggedTabId))") +#endif + SidebarDragLifecycleNotification.postClearRequest(reason: "outside_sidebar_drop") + return true + } + + private func debugShortSidebarTabId(_ id: UUID?) -> String { + guard let id else { return "nil" } + return String(id.uuidString.prefix(5)) + } +} + +@MainActor + +enum SidebarDropEdge: Equatable { + case top + case bottom +} + +struct SidebarDropIndicator: Equatable { + let tabId: UUID? + let edge: SidebarDropEdge +} + +enum SidebarDropPlanner { + static func indicator( + draggedTabId: UUID?, + targetTabId: UUID?, + tabIds: [UUID], + pinnedTabIds: Set, + pointerY: CGFloat? = nil, + targetHeight: CGFloat? = nil + ) -> SidebarDropIndicator? { + guard tabIds.count > 1, let draggedTabId else { return nil } + guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } + + let insertionPosition: Int + if let targetTabId { + guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil } + let edge: SidebarDropEdge + if let pointerY, let targetHeight { + edge = edgeForPointer(locationY: pointerY, targetHeight: targetHeight) + } else { + edge = preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds) + } + insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex + } else { + insertionPosition = tabIds.count + } + + let legalInsertionPosition = legalInsertionPosition( + draggedTabId: draggedTabId, + proposedInsertionPosition: insertionPosition, + tabIds: tabIds, + pinnedTabIds: pinnedTabIds + ) + let legalTargetIndex = resolvedTargetIndex( + from: fromIndex, + insertionPosition: legalInsertionPosition, + totalCount: tabIds.count + ) + guard legalTargetIndex != fromIndex else { return nil } + return indicatorForInsertionPosition(legalInsertionPosition, tabIds: tabIds) + } + + static func targetIndex( + draggedTabId: UUID, + targetTabId: UUID?, + indicator: SidebarDropIndicator?, + tabIds: [UUID], + pinnedTabIds: Set + ) -> Int? { + guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } + + let insertionPosition: Int + if let indicator, let indicatorInsertion = insertionPositionForIndicator(indicator, tabIds: tabIds) { + insertionPosition = indicatorInsertion + } else if let targetTabId { + guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil } + let edge = (indicator?.tabId == targetTabId) + ? (indicator?.edge ?? preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds)) + : preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds) + insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex + } else { + insertionPosition = tabIds.count + } + + let legalInsertionPosition = legalInsertionPosition( + draggedTabId: draggedTabId, + proposedInsertionPosition: insertionPosition, + tabIds: tabIds, + pinnedTabIds: pinnedTabIds + ) + return resolvedTargetIndex(from: fromIndex, insertionPosition: legalInsertionPosition, totalCount: tabIds.count) + } + + private static func indicatorForInsertionPosition(_ insertionPosition: Int, tabIds: [UUID]) -> SidebarDropIndicator { + let clampedInsertion = max(0, min(insertionPosition, tabIds.count)) + if clampedInsertion >= tabIds.count { + return SidebarDropIndicator(tabId: nil, edge: .bottom) + } + return SidebarDropIndicator(tabId: tabIds[clampedInsertion], edge: .top) + } + + private static func insertionPositionForIndicator(_ indicator: SidebarDropIndicator, tabIds: [UUID]) -> Int? { + if let tabId = indicator.tabId { + guard let targetTabIndex = tabIds.firstIndex(of: tabId) else { return nil } + return indicator.edge == .bottom ? targetTabIndex + 1 : targetTabIndex + } + return tabIds.count + } + + private static func preferredEdge(fromIndex: Int, targetTabId: UUID, tabIds: [UUID]) -> SidebarDropEdge { + guard let targetIndex = tabIds.firstIndex(of: targetTabId) else { return .top } + return fromIndex < targetIndex ? .bottom : .top + } + + private static func legalInsertionPosition( + draggedTabId: UUID, + proposedInsertionPosition: Int, + tabIds: [UUID], + pinnedTabIds: Set + ) -> Int { + let clampedInsertion = max(0, min(proposedInsertionPosition, tabIds.count)) + guard !pinnedTabIds.isEmpty else { return clampedInsertion } + + let pinnedCount = tabIds.reduce(into: 0) { count, tabId in + if pinnedTabIds.contains(tabId) { + count += 1 + } + } + guard pinnedCount > 0 else { return clampedInsertion } + + if pinnedTabIds.contains(draggedTabId) { + return min(clampedInsertion, pinnedCount) + } + return max(clampedInsertion, pinnedCount) + } + + static func edgeForPointer(locationY: CGFloat, targetHeight: CGFloat) -> SidebarDropEdge { + guard targetHeight > 0 else { return .top } + let clampedY = min(max(locationY, 0), targetHeight) + return clampedY < (targetHeight / 2) ? .top : .bottom + } + + private static func resolvedTargetIndex(from sourceIndex: Int, insertionPosition: Int, totalCount: Int) -> Int { + let clampedInsertion = max(0, min(insertionPosition, totalCount)) + let adjusted = clampedInsertion > sourceIndex ? clampedInsertion - 1 : clampedInsertion + return max(0, min(adjusted, max(0, totalCount - 1))) + } +} + +enum SidebarAutoScrollDirection: Equatable { + case up + case down +} + +struct SidebarAutoScrollPlan: Equatable { + let direction: SidebarAutoScrollDirection + let pointsPerTick: CGFloat +} + +enum SidebarDragAutoScrollPlanner { + static let edgeInset: CGFloat = 44 + static let minStep: CGFloat = 2 + static let maxStep: CGFloat = 12 + + static func plan( + distanceToTop: CGFloat, + distanceToBottom: CGFloat, + edgeInset: CGFloat = SidebarDragAutoScrollPlanner.edgeInset, + minStep: CGFloat = SidebarDragAutoScrollPlanner.minStep, + maxStep: CGFloat = SidebarDragAutoScrollPlanner.maxStep + ) -> SidebarAutoScrollPlan? { + guard edgeInset > 0, maxStep >= minStep else { return nil } + if distanceToTop <= edgeInset { + let normalized = max(0, min(1, (edgeInset - distanceToTop) / edgeInset)) + let step = minStep + ((maxStep - minStep) * normalized) + return SidebarAutoScrollPlan(direction: .up, pointsPerTick: step) + } + if distanceToBottom <= edgeInset { + let normalized = max(0, min(1, (edgeInset - distanceToBottom) / edgeInset)) + let step = minStep + ((maxStep - minStep) * normalized) + return SidebarAutoScrollPlan(direction: .down, pointsPerTick: step) + } + return nil + } +} + +@MainActor +final class SidebarDragAutoScrollController: ObservableObject { + private weak var scrollView: NSScrollView? + private var timer: Timer? + private var activePlan: SidebarAutoScrollPlan? + + func attach(scrollView: NSScrollView?) { + self.scrollView = scrollView + } + + func updateFromDragLocation() { + guard let scrollView else { + stop() + return + } + guard let plan = plan(for: scrollView) else { + stop() + return + } + activePlan = plan + startTimerIfNeeded() + } + + func stop() { + timer?.invalidate() + timer = nil + activePlan = nil + } + + private func startTimerIfNeeded() { + guard timer == nil else { return } + let timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.tick() + } + } + self.timer = timer + RunLoop.main.add(timer, forMode: .eventTracking) + } + + private func tick() { + guard NSEvent.pressedMouseButtons != 0 else { + stop() + return + } + guard let scrollView else { + stop() + return + } + + // AppKit drag/drop autoscroll guidance recommends autoscroll(with:) + // when periodic drag updates are available; use it first. + if applyNativeAutoscroll(to: scrollView) { + activePlan = plan(for: scrollView) + if activePlan == nil { + stop() + } + return + } + + activePlan = self.plan(for: scrollView) + guard let plan = activePlan else { + stop() + return + } + _ = apply(plan: plan, to: scrollView) + } + + private func applyNativeAutoscroll(to scrollView: NSScrollView) -> Bool { + guard let event = NSApp.currentEvent else { return false } + switch event.type { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + break + default: + return false + } + + let clipView = scrollView.contentView + let didScroll = clipView.autoscroll(with: event) + if didScroll { + scrollView.reflectScrolledClipView(clipView) + } + return didScroll + } + + private func distancesToEdges(mousePoint: CGPoint, viewportHeight: CGFloat, isFlipped: Bool) -> (top: CGFloat, bottom: CGFloat) { + if isFlipped { + return (top: mousePoint.y, bottom: viewportHeight - mousePoint.y) + } + return (top: viewportHeight - mousePoint.y, bottom: mousePoint.y) + } + + private func planForMousePoint(_ mousePoint: CGPoint, in clipView: NSClipView) -> SidebarAutoScrollPlan? { + let viewportHeight = clipView.bounds.height + guard viewportHeight > 0 else { return nil } + + let distances = distancesToEdges(mousePoint: mousePoint, viewportHeight: viewportHeight, isFlipped: clipView.isFlipped) + return SidebarDragAutoScrollPlanner.plan(distanceToTop: distances.top, distanceToBottom: distances.bottom) + } + + private func mousePoint(in clipView: NSClipView) -> CGPoint { + let mouseInWindow = clipView.window?.convertPoint(fromScreen: NSEvent.mouseLocation) ?? .zero + return clipView.convert(mouseInWindow, from: nil) + } + + private func currentPlan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { + let clipView = scrollView.contentView + let mouse = mousePoint(in: clipView) + return planForMousePoint(mouse, in: clipView) + } + + private func plan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { + currentPlan(for: scrollView) + } + + private func apply(plan: SidebarAutoScrollPlan, to scrollView: NSScrollView) -> Bool { + guard let documentView = scrollView.documentView else { return false } + let clipView = scrollView.contentView + let maxOriginY = max(0, documentView.bounds.height - clipView.bounds.height) + guard maxOriginY > 0 else { return false } + + let directionMultiplier: CGFloat = (plan.direction == .down) ? 1 : -1 + let flippedMultiplier: CGFloat = documentView.isFlipped ? 1 : -1 + let delta = directionMultiplier * flippedMultiplier * plan.pointsPerTick + let currentY = clipView.bounds.origin.y + let targetY = min(max(currentY + delta, 0), maxOriginY) + guard abs(targetY - currentY) > 0.01 else { return false } + + clipView.scroll(to: CGPoint(x: clipView.bounds.origin.x, y: targetY)) + scrollView.reflectScrolledClipView(clipView) + return true + } +} + +enum SidebarTabDragPayload { + static let typeIdentifier = "com.darkroom.programa.sidebar-tab-reorder" + static let dropContentType = UTType(exportedAs: typeIdentifier) + static let dropContentTypes: [UTType] = [dropContentType] + private static let prefix = "programa.sidebar-tab." + + static func provider(for tabId: UUID) -> NSItemProvider { + let provider = NSItemProvider() + let payload = "\(prefix)\(tabId.uuidString)" + provider.registerDataRepresentation(forTypeIdentifier: typeIdentifier, visibility: .ownProcess) { completion in + completion(payload.data(using: .utf8), nil) + return nil + } + return provider + } +} + +enum BonsplitTabDragPayload { + static let typeIdentifier = "com.splittabbar.tabtransfer" + static let dropContentType = UTType(exportedAs: typeIdentifier) + static let dropContentTypes: [UTType] = [dropContentType] + private static let currentProcessId = Int32(ProcessInfo.processInfo.processIdentifier) + + struct Transfer: Decodable { + struct TabInfo: Decodable { + let id: UUID + } + + let tab: TabInfo + let sourcePaneId: UUID + let sourceProcessId: Int32 + + private enum CodingKeys: String, CodingKey { + case tab + case sourcePaneId + case sourceProcessId + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.tab = try container.decode(TabInfo.self, forKey: .tab) + self.sourcePaneId = try container.decode(UUID.self, forKey: .sourcePaneId) + // Legacy payloads won't include this field. Treat as foreign process. + self.sourceProcessId = try container.decodeIfPresent(Int32.self, forKey: .sourceProcessId) ?? -1 + } + } + + private static func isCurrentProcessTransfer(_ transfer: Transfer) -> Bool { + transfer.sourceProcessId == currentProcessId + } + + static func currentTransfer() -> Transfer? { + let pasteboard = NSPasteboard(name: .drag) + let type = NSPasteboard.PasteboardType(typeIdentifier) + + if let data = pasteboard.data(forType: type), + let transfer = try? JSONDecoder().decode(Transfer.self, from: data), + isCurrentProcessTransfer(transfer) { + return transfer + } + + if let raw = pasteboard.string(forType: type), + let data = raw.data(using: .utf8), + let transfer = try? JSONDecoder().decode(Transfer.self, from: data), + isCurrentProcessTransfer(transfer) { + return transfer + } + + return nil + } +} + +struct SidebarBonsplitTabDropDelegate: DropDelegate { + let targetWorkspaceId: UUID + let tabManager: TabManager + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + + func validateDrop(info: DropInfo) -> Bool { + guard info.hasItemsConforming(to: [BonsplitTabDragPayload.typeIdentifier]) else { return false } + return BonsplitTabDragPayload.currentTransfer() != nil + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + guard validateDrop(info: info) else { return nil } + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + guard validateDrop(info: info), + let transfer = BonsplitTabDragPayload.currentTransfer(), + let app = AppDelegate.shared else { + return false + } + + if let source = app.locateBonsplitSurface(tabId: transfer.tab.id), + source.workspaceId == targetWorkspaceId { + syncSidebarSelection() + return true + } + + guard app.moveBonsplitTab( + tabId: transfer.tab.id, + toWorkspace: targetWorkspaceId, + focus: true, + focusWindow: true + ) else { + return false + } + + selectedTabIds = [targetWorkspaceId] + syncSidebarSelection() + return true + } + + private func syncSidebarSelection() { + if let selectedId = tabManager.selectedTabId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } else { + lastSidebarSelectionIndex = nil + } + } +} + +struct SidebarTabDropDelegate: DropDelegate { + let targetTabId: UUID? + let tabManager: TabManager + @Binding var draggedTabId: UUID? + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let targetRowHeight: CGFloat? + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var dropIndicator: SidebarDropIndicator? + + func validateDrop(info: DropInfo) -> Bool { + let hasType = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) + let hasDrag = draggedTabId != nil + #if DEBUG + dlog("sidebar.validateDrop target=\(targetTabId?.uuidString.prefix(5) ?? "end") hasType=\(hasType) hasDrag=\(hasDrag)") + #endif + return hasType && hasDrag + } + + func dropEntered(info: DropInfo) { + #if DEBUG + dlog("sidebar.dropEntered target=\(targetTabId?.uuidString.prefix(5) ?? "end")") + #endif + dragAutoScrollController.updateFromDragLocation() + updateDropIndicator(for: info) + } + + func dropExited(info: DropInfo) { +#if DEBUG + dlog("sidebar.dropExited target=\(targetTabId?.uuidString.prefix(5) ?? "end")") +#endif + if dropIndicator?.tabId == targetTabId { + dropIndicator = nil + } + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + dragAutoScrollController.updateFromDragLocation() + updateDropIndicator(for: info) +#if DEBUG + dlog( + "sidebar.dropUpdated target=\(targetTabId?.uuidString.prefix(5) ?? "end") " + + "indicator=\(debugIndicator(dropIndicator))" + ) +#endif + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + defer { + draggedTabId = nil + dropIndicator = nil + dragAutoScrollController.stop() + } + #if DEBUG + dlog("sidebar.drop target=\(targetTabId?.uuidString.prefix(5) ?? "end")") + #endif + guard let draggedTabId else { +#if DEBUG + dlog("sidebar.drop.abort reason=missingDraggedTab") +#endif + return false + } + guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { +#if DEBUG + dlog("sidebar.drop.abort reason=draggedTabMissing tab=\(draggedTabId.uuidString.prefix(5))") +#endif + return false + } + let tabIds = tabManager.tabs.map(\.id) + guard let targetIndex = SidebarDropPlanner.targetIndex( + draggedTabId: draggedTabId, + targetTabId: targetTabId, + indicator: dropIndicator, + tabIds: tabIds, + pinnedTabIds: Set(tabManager.tabs.filter(\.isPinned).map(\.id)) + ) else { +#if DEBUG + dlog( + "sidebar.drop.abort reason=noTargetIndex tab=\(draggedTabId.uuidString.prefix(5)) " + + "target=\(targetTabId?.uuidString.prefix(5) ?? "end") indicator=\(debugIndicator(dropIndicator))" + ) +#endif + return false + } + + guard fromIndex != targetIndex else { +#if DEBUG + dlog("sidebar.drop.noop from=\(fromIndex) to=\(targetIndex)") +#endif + syncSidebarSelection() + return true + } + +#if DEBUG + dlog("sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) from=\(fromIndex) to=\(targetIndex)") +#endif + _ = tabManager.reorderWorkspace(tabId: draggedTabId, toIndex: targetIndex) + if let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + syncSidebarSelection(preferredSelectedTabId: selectedId) + } else { + selectedTabIds = [] + syncSidebarSelection() + } + return true + } + + private func updateDropIndicator(for info: DropInfo) { + let tabIds = tabManager.tabs.map(\.id) + let pinnedTabIds = Set(tabManager.tabs.filter(\.isPinned).map(\.id)) + let nextIndicator = SidebarDropPlanner.indicator( + draggedTabId: draggedTabId, + targetTabId: targetTabId, + tabIds: tabIds, + pinnedTabIds: pinnedTabIds, + pointerY: targetTabId == nil ? nil : info.location.y, + targetHeight: targetRowHeight + ) + guard dropIndicator != nextIndicator else { return } + dropIndicator = nextIndicator + } + + private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { + let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId + if let selectedId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } else { + lastSidebarSelectionIndex = nil + } + } + + private func debugIndicator(_ indicator: SidebarDropIndicator?) -> String { + guard let indicator else { return "nil" } + let tabText = indicator.tabId.map { String($0.uuidString.prefix(5)) } ?? "end" + return "\(tabText):\(indicator.edge == .top ? "top" : "bottom")" + } +} + diff --git a/Sources/SidebarVisuals.swift b/Sources/SidebarVisuals.swift new file mode 100644 index 00000000000..7ce7bd54a65 --- /dev/null +++ b/Sources/SidebarVisuals.swift @@ -0,0 +1,1533 @@ +// Sidebar visual chrome, extracted from ContentView.swift (nuclear-review #94.5). +// Pure move: sidebar footer/help-menu/scrim/blur/material/tint/preset enums, +// visual-effect/backdrop views, and the NSColor extension, consolidated from two +// non-contiguous locations in ContentView.swift. +// +// Access-level widening: SidebarFooter, SidebarTopScrim, SidebarScrollViewResolver, +// SidebarEmptyArea, ClearScrollBackground, DraggableFolderIcon, +// TitlebarLeadingInsetReader, SidebarTrailingBorder, and SidebarBackdrop were +// file-private to the old ContentView.swift; widened to internal (dropped the +// `private` modifier) because VerticalTabsSidebar and ContentView, which stay in +// ContentView.swift, still construct/reference them. No other behavior change. + +import AppKit +import Bonsplit +import SwiftUI + +struct SidebarFooter: View { + @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + + var body: some View { +#if DEBUG + SidebarDevFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) +#else + SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + .padding(.leading, 6) + .padding(.trailing, 10) + .padding(.bottom, 6) +#endif + } +} + +private struct SidebarFooterButtons: View { + @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + + var body: some View { + HStack(spacing: 4) { + SidebarHelpMenuButton(onSendFeedback: onSendFeedback) + UpdatePill(model: updateViewModel) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private enum SidebarHelpMenuAction { + case importBrowserData + case keyboardShortcuts + case docs + case changelog + case github + case githubIssues + case discord + case checkForUpdates + case sendFeedback + case welcome +} + +private struct SidebarHelpMenuButton: View { + private let docsURL = URL(string: "https://github.com/darkroomengineering/programa/tree/main/docs") + private let changelogURL = URL(string: "https://github.com/darkroomengineering/programa/blob/main/CHANGELOG.md") + private let githubURL = URL(string: "https://github.com/darkroomengineering/programa") + private let githubIssuesURL = URL(string: "https://github.com/darkroomengineering/programa/issues") + private let discordURL = URL(string: "https://discord.gg/xsgFEVrWCZ") + private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help") + private let buttonSize: CGFloat = 22 + private let iconSize: CGFloat = 11 + @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared + + let onSendFeedback: () -> Void + + @State private var isPopoverPresented = false + + private var sendFeedbackShortcutHint: String { + let _ = keyboardShortcutSettingsObserver.revision + return KeyboardShortcutSettings.shortcut(for: .sendFeedback).displayString + } + + var body: some View { + Button { + isPopoverPresented.toggle() + } label: { + Image(systemName: "questionmark.circle") + .symbolRenderingMode(.monochrome) + .font(.system(size: iconSize, weight: .medium)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .frame(width: buttonSize, height: buttonSize, alignment: .center) + } + .buttonStyle(SidebarFooterIconButtonStyle()) + .frame(width: buttonSize, height: buttonSize, alignment: .center) + .background(ArrowlessPopoverAnchor( + isPresented: $isPopoverPresented, + preferredEdge: .maxY, + detachedGap: 4 + ) { + helpPopover + }) + .accessibilityElement(children: .ignore) + .safeHelp(helpTitle) + .accessibilityLabel(helpTitle) + .accessibilityIdentifier("SidebarHelpMenuButton") + } + + private var helpPopover: some View { + VStack(alignment: .leading, spacing: 2) { + helpOptionButton( + title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome to Programa!"), + action: .welcome, + accessibilityIdentifier: "SidebarHelpMenuOptionWelcome", + isExternalLink: false + ) + helpOptionButton( + title: String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback"), + action: .sendFeedback, + accessibilityIdentifier: "SidebarHelpMenuOptionSendFeedback", + isExternalLink: false, + shortcutHint: sendFeedbackShortcutHint, + trailingSystemImage: "bubble.left.and.text.bubble.right" + ) + helpOptionButton( + title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"), + action: .keyboardShortcuts, + accessibilityIdentifier: "SidebarHelpMenuOptionKeyboardShortcuts", + isExternalLink: false + ) + helpOptionButton( + title: String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…"), + action: .importBrowserData, + accessibilityIdentifier: "SidebarHelpMenuOptionImportBrowserData", + isExternalLink: false + ) + if docsURL != nil { + helpOptionButton( + title: String(localized: "about.docs", defaultValue: "Docs"), + action: .docs, + accessibilityIdentifier: "SidebarHelpMenuOptionDocs", + isExternalLink: true + ) + } + if changelogURL != nil { + helpOptionButton( + title: String(localized: "sidebar.help.changelog", defaultValue: "Changelog"), + action: .changelog, + accessibilityIdentifier: "SidebarHelpMenuOptionChangelog", + isExternalLink: true + ) + } + if githubURL != nil { + helpOptionButton( + title: String(localized: "about.github", defaultValue: "GitHub"), + action: .github, + accessibilityIdentifier: "SidebarHelpMenuOptionGitHub", + isExternalLink: true + ) + } + if githubIssuesURL != nil { + helpOptionButton( + title: String(localized: "sidebar.help.githubIssues", defaultValue: "GitHub Issues"), + action: .githubIssues, + accessibilityIdentifier: "SidebarHelpMenuOptionGitHubIssues", + isExternalLink: true + ) + } + if discordURL != nil { + helpOptionButton( + title: String(localized: "sidebar.help.discord", defaultValue: "Discord"), + action: .discord, + accessibilityIdentifier: "SidebarHelpMenuOptionDiscord", + isExternalLink: true + ) + } + helpOptionButton( + title: String(localized: "command.checkForUpdates.title", defaultValue: "Check for Updates"), + action: .checkForUpdates, + accessibilityIdentifier: "SidebarHelpMenuOptionCheckForUpdates", + isExternalLink: false + ) + } + .padding(8) + .frame(minWidth: 200) + } + + private func helpOptionButton( + title: String, + action: SidebarHelpMenuAction, + accessibilityIdentifier: String, + isExternalLink: Bool, + shortcutHint: String? = nil, + trailingSystemImage: String? = nil + ) -> some View { + Button { + isPopoverPresented = false + perform(action) + } label: { + HStack(spacing: 8) { + Text(title) + .font(.system(size: 12)) + Spacer(minLength: 0) + if let shortcutHint { + helpOptionShortcutHint(text: shortcutHint) + } + if let trailingSystemImage { + helpOptionTrailingIcon(systemName: trailingSystemImage) + } + if isExternalLink { + helpOptionTrailingIcon(systemName: "arrow.up.right", size: 8) + } + } + .padding(.horizontal, 8) + .frame(height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier(accessibilityIdentifier) + } + + private func helpOptionShortcutHint(text: String) -> some View { + Text(text) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .font(.system(size: 10, weight: .regular, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } + + private func helpOptionTrailingIcon(systemName: String, size: CGFloat = 13) -> some View { + Image(systemName: systemName) + .resizable() + .scaledToFit() + .frame(width: size, height: size) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } + + private func perform(_ action: SidebarHelpMenuAction) { + switch action { + case .importBrowserData: + isPopoverPresented = false + DispatchQueue.main.async { + BrowserDataImportCoordinator.shared.presentImportDialog() + } + case .keyboardShortcuts: + isPopoverPresented = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + Task { @MainActor in + if let appDelegate = AppDelegate.shared { + appDelegate.openPreferencesWindow( + debugSource: "sidebarHelpMenu.keyboardShortcuts", + navigationTarget: .keyboardShortcuts + ) + } else { + AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts) + } + } + } + case .docs: + guard let docsURL else { return } + NSWorkspace.shared.open(docsURL) + case .changelog: + guard let changelogURL else { return } + NSWorkspace.shared.open(changelogURL) + case .github: + guard let githubURL else { return } + NSWorkspace.shared.open(githubURL) + case .githubIssues: + guard let githubIssuesURL else { return } + NSWorkspace.shared.open(githubIssuesURL) + case .discord: + guard let discordURL else { return } + NSWorkspace.shared.open(discordURL) + case .checkForUpdates: + Task { @MainActor in + AppDelegate.shared?.checkForUpdates(nil) + } + case .sendFeedback: + isPopoverPresented = false + onSendFeedback() + case .welcome: + isPopoverPresented = false + Task { @MainActor in + if let appDelegate = AppDelegate.shared { + appDelegate.openWelcomeWorkspace() + } + } + } + } + +} + +private struct ArrowlessPopoverAnchor: NSViewRepresentable { + @Binding var isPresented: Bool + let preferredEdge: NSRectEdge + let detachedGap: CGFloat + @ViewBuilder let content: () -> PopoverContent + + func makeNSView(context: Context) -> NSView { + let view = NSView() + context.coordinator.anchorView = view + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.anchorView = nsView + context.coordinator.updateRootView(AnyView(content())) + + if isPresented { + context.coordinator.present( + preferredEdge: preferredEdge, + detachedGap: detachedGap + ) + } else { + context.coordinator.dismiss() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(isPresented: $isPresented) + } + + final class Coordinator: NSObject, NSPopoverDelegate { + @Binding var isPresented: Bool + + weak var anchorView: NSView? + private let hostingController = NSHostingController(rootView: AnyView(EmptyView())) + private var popover: NSPopover? + + init(isPresented: Binding) { + _isPresented = isPresented + } + + func updateRootView(_ rootView: AnyView) { + hostingController.rootView = AnyView(rootView.fixedSize()) + hostingController.view.invalidateIntrinsicContentSize() + hostingController.view.layoutSubtreeIfNeeded() + } + + func present(preferredEdge: NSRectEdge, detachedGap: CGFloat) { + guard let anchorView else { + isPresented = false + dismiss() + return + } + + let popover = popover ?? makePopover() + if popover.isShown { + return + } + + hostingController.view.invalidateIntrinsicContentSize() + hostingController.view.layoutSubtreeIfNeeded() + let fittingSize = hostingController.view.fittingSize + if fittingSize.width > 0, fittingSize.height > 0 { + popover.contentSize = NSSize( + width: ceil(fittingSize.width), + height: ceil(fittingSize.height) + ) + } + + popover.show( + relativeTo: positioningRect( + for: anchorView.bounds, + preferredEdge: preferredEdge, + detachedGap: detachedGap + ), + of: anchorView, + preferredEdge: preferredEdge + ) + } + + func dismiss() { + popover?.performClose(nil) + popover = nil + } + + func popoverDidClose(_ notification: Notification) { + popover = nil + if isPresented { + isPresented = false + } + } + + private func makePopover() -> NSPopover { + let popover = NSPopover() + popover.behavior = .semitransient + popover.animates = true + popover.setValue(true, forKeyPath: "shouldHideAnchor") + popover.contentViewController = hostingController + popover.delegate = self + self.popover = popover + return popover + } + + private func positioningRect( + for bounds: CGRect, + preferredEdge: NSRectEdge, + detachedGap: CGFloat + ) -> CGRect { + let hiddenArrowInset: CGFloat = 13 + let compensation = max(hiddenArrowInset - detachedGap, 0) + + switch preferredEdge { + case .maxY: + return NSRect( + x: bounds.minX, + y: bounds.maxY - compensation, + width: bounds.width, + height: compensation + ) + case .minY: + return NSRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width, + height: compensation + ) + case .maxX: + return NSRect( + x: bounds.maxX - compensation, + y: bounds.minY, + width: compensation, + height: bounds.height + ) + case .minX: + return NSRect( + x: bounds.minX, + y: bounds.minY, + width: compensation, + height: bounds.height + ) + @unknown default: + return bounds + } + } + } +} + +private struct SidebarFooterIconButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + SidebarFooterIconButtonStyleBody(configuration: configuration) + } +} + +private struct SidebarFooterIconButtonStyleBody: View { + let configuration: SidebarFooterIconButtonStyle.Configuration + + @Environment(\.isEnabled) private var isEnabled + @State private var isHovered = false + + private var backgroundOpacity: Double { + guard isEnabled else { return 0.0 } + if configuration.isPressed { return 0.16 } + if isHovered { return 0.08 } + return 0.0 + } + + var body: some View { + configuration.label + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.primary.opacity(backgroundOpacity)) + ) + .onHover { hovering in + isHovered = hovering + } + .animation(.easeOut(duration: 0.12), value: isHovered) + .animation(.easeOut(duration: 0.08), value: configuration.isPressed) + } +} + +#if DEBUG +private struct SidebarDevFooter: View { + @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + if showSidebarDevBuildBanner { + Text(String(localized: "debug.devBuildBanner.title", defaultValue: "THIS IS A DEV BUILD")) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.red) + } + } + .padding(.leading, 6) + .padding(.trailing, 10) + .padding(.bottom, 6) + } +} +#endif + +struct SidebarTopScrim: View { + let height: CGFloat + + var body: some View { + SidebarTopBlurEffect() + .frame(height: height) + .mask( + LinearGradient( + colors: [ + Color.black.opacity(0.95), + Color.black.opacity(0.75), + Color.black.opacity(0.35), + Color.clear + ], + startPoint: .top, + endPoint: .bottom + ) + ) + } +} + +private struct SidebarTopBlurEffect: NSViewRepresentable { + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.blendingMode = .withinWindow + view.material = .underWindowBackground + view.state = .active + view.isEmphasized = false + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} + +struct SidebarScrollViewResolver: NSViewRepresentable { + let onResolve: (NSScrollView?) -> Void + + func makeNSView(context: Context) -> SidebarScrollViewResolverView { + let view = SidebarScrollViewResolverView() + view.onResolve = onResolve + return view + } + + func updateNSView(_ nsView: SidebarScrollViewResolverView, context: Context) { + nsView.onResolve = onResolve + nsView.resolveScrollView() + } +} + +final class SidebarScrollViewResolverView: NSView { + var onResolve: ((NSScrollView?) -> Void)? + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + resolveScrollView() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + resolveScrollView() + } + + func resolveScrollView() { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + onResolve?(self.enclosingScrollView) + } + } +} + +struct SidebarEmptyArea: View { + @EnvironmentObject var tabManager: TabManager + let rowSpacing: CGFloat + @Binding var selection: SidebarSelection + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var draggedTabId: UUID? + @Binding var dropIndicator: SidebarDropIndicator? + + var body: some View { + Color.clear + .contentShape(Rectangle()) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onTapGesture(count: 2) { + tabManager.addWorkspace(placementOverride: .end) + if let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } + selection = .tabs + } + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( + targetTabId: nil, + tabManager: tabManager, + draggedTabId: $draggedTabId, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + targetRowHeight: nil, + dragAutoScrollController: dragAutoScrollController, + dropIndicator: $dropIndicator + )) + .overlay(alignment: .top) { + if shouldShowTopDropIndicator { + Rectangle() + .fill(programaAccentColor()) + .frame(height: 2) + .padding(.horizontal, 8) + .offset(y: -(rowSpacing / 2)) + } + } + } + + private var shouldShowTopDropIndicator: Bool { + guard draggedTabId != nil, let indicator = dropIndicator else { return false } + if indicator.tabId == nil { + return true + } + guard indicator.edge == .bottom, let lastTabId = tabManager.tabs.last?.id else { return false } + return indicator.tabId == lastTabId + } +} + +enum SidebarPathFormatter { + static let homeDirectoryPath: String = FileManager.default.homeDirectoryForCurrentUser.path + + static func shortenedPath( + _ path: String, + homeDirectoryPath: String = Self.homeDirectoryPath + ) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return path } + if trimmed == homeDirectoryPath { + return "~" + } + if trimmed.hasPrefix(homeDirectoryPath + "/") { + return "~" + trimmed.dropFirst(homeDirectoryPath.count) + } + return trimmed + } +} + +enum SidebarWorkspaceShortcutHintMetrics { + private static let measurementFont = NSFont.systemFont(ofSize: 10, weight: .semibold) + private static let minimumSlotWidth: CGFloat = 28 + private static let horizontalPadding: CGFloat = 12 + private static let lock = NSLock() + private static var cachedHintWidths: [String: CGFloat] = [:] + #if DEBUG + private static var measurementCount = 0 + #endif + + static func slotWidth(label: String?, debugXOffset: Double) -> CGFloat { + guard let label else { return minimumSlotWidth } + let positiveDebugInset = max(0, CGFloat(ShortcutHintDebugSettings.clamped(debugXOffset))) + 2 + return max(minimumSlotWidth, hintWidth(for: label) + positiveDebugInset) + } + + static func hintWidth(for label: String) -> CGFloat { + lock.lock() + if let cached = cachedHintWidths[label] { + lock.unlock() + return cached + } + lock.unlock() + + let textWidth = (label as NSString).size(withAttributes: [.font: measurementFont]).width + let measuredWidth = ceil(textWidth) + horizontalPadding + + lock.lock() + cachedHintWidths[label] = measuredWidth + #if DEBUG + measurementCount += 1 + #endif + lock.unlock() + return measuredWidth + } + + #if DEBUG + static func resetCacheForTesting() { + lock.lock() + cachedHintWidths.removeAll() + measurementCount = 0 + lock.unlock() + } + + static func measurementCountForTesting() -> Int { + lock.lock() + let count = measurementCount + lock.unlock() + return count + } + #endif +} + +enum SidebarTrailingAccessoryWidthPolicy { + static let closeButtonWidth: CGFloat = 16 + + static func width( + canCloseWorkspace: Bool, + showsWorkspaceShortcutHint: Bool, + workspaceShortcutLabel: String?, + debugXOffset: Double + ) -> CGFloat { + if showsWorkspaceShortcutHint, let workspaceShortcutLabel { + return SidebarWorkspaceShortcutHintMetrics.slotWidth( + label: workspaceShortcutLabel, + debugXOffset: debugXOffset + ) + } + + return canCloseWorkspace ? closeButtonWidth : 0 + } +} + + +struct MiddleClickCapture: NSViewRepresentable { + let onMiddleClick: () -> Void + + func makeNSView(context: Context) -> MiddleClickCaptureView { + let view = MiddleClickCaptureView() + view.onMiddleClick = onMiddleClick + return view + } + + func updateNSView(_ nsView: MiddleClickCaptureView, context: Context) { + nsView.onMiddleClick = onMiddleClick + } +} + +final class MiddleClickCaptureView: NSView { + var onMiddleClick: (() -> Void)? + + override func hitTest(_ point: NSPoint) -> NSView? { + // Only intercept middle-click so left-click selection and right-click context menus + // continue to hit-test through to SwiftUI/AppKit normally. + guard let event = NSApp.currentEvent, + event.type == .otherMouseDown, + event.buttonNumber == 2 else { + return nil + } + return self + } + + override func otherMouseDown(with event: NSEvent) { + guard event.buttonNumber == 2 else { + super.otherMouseDown(with: event) + return + } + onMiddleClick?() + } +} + +enum SidebarSelection { + case tabs + case notifications +} + +struct ClearScrollBackground: ViewModifier { + func body(content: Content) -> some View { + content + .scrollContentBackground(.hidden) + .background(ScrollBackgroundClearer()) + } +} + +private struct ScrollBackgroundClearer: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + NSView() + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { + guard let scrollView = findScrollView(startingAt: nsView) else { return } + // Clear all backgrounds and mark as non-opaque for transparency + scrollView.drawsBackground = false + scrollView.backgroundColor = .clear + scrollView.wantsLayer = true + scrollView.layer?.backgroundColor = NSColor.clear.cgColor + scrollView.layer?.isOpaque = false + + scrollView.contentView.drawsBackground = false + scrollView.contentView.backgroundColor = .clear + scrollView.contentView.wantsLayer = true + scrollView.contentView.layer?.backgroundColor = NSColor.clear.cgColor + scrollView.contentView.layer?.isOpaque = false + + if let docView = scrollView.documentView { + docView.wantsLayer = true + docView.layer?.backgroundColor = NSColor.clear.cgColor + docView.layer?.isOpaque = false + } + } + } + + private func findScrollView(startingAt view: NSView) -> NSScrollView? { + var current: NSView? = view + while let candidate = current { + if let scrollView = candidate as? NSScrollView { + return scrollView + } + current = candidate.superview + } + return nil + } +} + +struct DraggableFolderIcon: View { + let directory: String + + var body: some View { + DraggableFolderIconRepresentable(directory: directory) + .frame(width: 16, height: 16) + .safeHelp(String(localized: "sidebar.folderIcon.dragHint", defaultValue: "Drag to open in Finder or another app")) + .onTapGesture(count: 2) { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directory) + } + } +} + +private struct DraggableFolderIconRepresentable: NSViewRepresentable { + let directory: String + + func makeNSView(context: Context) -> DraggableFolderNSView { + DraggableFolderNSView(directory: directory) + } + + func updateNSView(_ nsView: DraggableFolderNSView, context: Context) { + nsView.directory = directory + nsView.updateIcon() + } +} + +final class DraggableFolderNSView: NSView, NSDraggingSource { + private final class FolderIconImageView: NSImageView { + override var mouseDownCanMoveWindow: Bool { false } + } + + var directory: String + private var imageView: FolderIconImageView! + private var previousWindowMovableState: Bool? + private weak var suppressedWindow: NSWindow? + private var hasActiveDragSession = false + private var didArmWindowDragSuppression = false + + private func formatPoint(_ point: NSPoint) -> String { + String(format: "(%.1f,%.1f)", point.x, point.y) + } + + init(directory: String) { + self.directory = directory + super.init(frame: .zero) + setupImageView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: NSSize { + NSSize(width: 16, height: 16) + } + + override var mouseDownCanMoveWindow: Bool { false } + + private func setupImageView() { + imageView = FolderIconImageView() + imageView.imageScaling = .scaleProportionallyDown + imageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: trailingAnchor), + imageView.topAnchor.constraint(equalTo: topAnchor), + imageView.bottomAnchor.constraint(equalTo: bottomAnchor), + imageView.widthAnchor.constraint(equalToConstant: 16), + imageView.heightAnchor.constraint(equalToConstant: 16), + ]) + updateIcon() + } + + func updateIcon() { + let icon = NSWorkspace.shared.icon(forFile: directory) + icon.size = NSSize(width: 16, height: 16) + imageView.image = icon + } + + func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { + return context == .outsideApplication ? [.copy, .link] : .copy + } + + func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + hasActiveDragSession = false + restoreWindowMovableStateIfNeeded() + #if DEBUG + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" + dlog("folder.dragEnd dir=\(directory) operation=\(operation.rawValue) screen=\(formatPoint(screenPoint)) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") + #endif + } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point) else { return nil } + let hit = super.hitTest(point) + #if DEBUG + let hitDesc = hit.map { String(describing: type(of: $0)) } ?? "nil" + let imageHit = (hit === imageView) + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + dlog("folder.hitTest point=\(formatPoint(point)) hit=\(hitDesc) imageViewHit=\(imageHit) returning=DraggableFolderNSView wasMovable=\(wasMovable) nowMovable=\(nowMovable)") + #endif + return self + } + + override func mouseDown(with event: NSEvent) { + maybeDisableWindowDraggingEarly(trigger: "mouseDown") + hasActiveDragSession = false + #if DEBUG + let localPoint = convert(event.locationInWindow, from: nil) + let responderDesc = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" + dlog("folder.mouseDown dir=\(directory) point=\(formatPoint(localPoint)) firstResponder=\(responderDesc) wasMovable=\(wasMovable) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") + #endif + let fileURL = URL(fileURLWithPath: directory) + let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL) + + let iconImage = NSWorkspace.shared.icon(forFile: directory) + iconImage.size = NSSize(width: 32, height: 32) + draggingItem.setDraggingFrame(bounds, contents: iconImage) + + let session = beginDraggingSession(with: [draggingItem], event: event, source: self) + hasActiveDragSession = true + #if DEBUG + let itemCount = session.draggingPasteboard.pasteboardItems?.count ?? 0 + dlog("folder.dragStart dir=\(directory) pasteboardItems=\(itemCount)") + #endif + } + + override func mouseUp(with event: NSEvent) { + super.mouseUp(with: event) + // Always restore suppression on mouse-up; drag-session callbacks can be + // skipped for non-started drags, which would otherwise leave suppression stuck. + restoreWindowMovableStateIfNeeded() + } + + override func rightMouseDown(with event: NSEvent) { + let menu = buildPathMenu() + // Pop up menu at bottom-left of icon (like native proxy icon) + let menuLocation = NSPoint(x: 0, y: bounds.height) + menu.popUp(positioning: nil, at: menuLocation, in: self) + } + + private func buildPathMenu() -> NSMenu { + let menu = NSMenu() + let url = URL(fileURLWithPath: directory).standardized + var pathComponents: [URL] = [] + + // Build path from current directory up to root + var current = url + while current.path != "/" { + pathComponents.append(current) + current = current.deletingLastPathComponent() + } + pathComponents.append(URL(fileURLWithPath: "/")) + + // Add path components (current dir at top, root at bottom - matches native macOS) + for pathURL in pathComponents { + let icon = NSWorkspace.shared.icon(forFile: pathURL.path) + icon.size = NSSize(width: 16, height: 16) + + let displayName: String + if pathURL.path == "/" { + // Use the volume name for root + if let volumeName = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeNameKey]).volumeName { + displayName = volumeName + } else { + displayName = String(localized: "sidebar.pathMenu.macintoshHD", defaultValue: "Macintosh HD") + } + } else { + displayName = FileManager.default.displayName(atPath: pathURL.path) + } + + let item = NSMenuItem(title: displayName, action: #selector(openPathComponent(_:)), keyEquivalent: "") + item.target = self + item.image = icon + item.representedObject = pathURL + menu.addItem(item) + } + + // Add computer name at the bottom (like native proxy icon) + let computerName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName + let computerIcon = NSImage(named: NSImage.computerName) ?? NSImage() + computerIcon.size = NSSize(width: 16, height: 16) + + let computerItem = NSMenuItem(title: computerName, action: #selector(openComputer(_:)), keyEquivalent: "") + computerItem.target = self + computerItem.image = computerIcon + menu.addItem(computerItem) + + return menu + } + + @objc private func openPathComponent(_ sender: NSMenuItem) { + guard let url = sender.representedObject as? URL else { return } + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path) + } + + @objc private func openComputer(_ sender: NSMenuItem) { + // Open "Computer" view in Finder (shows all volumes) + NSWorkspace.shared.open(URL(fileURLWithPath: "/", isDirectory: true)) + } + + private func restoreWindowMovableStateIfNeeded() { + guard didArmWindowDragSuppression || previousWindowMovableState != nil else { return } + let targetWindow = suppressedWindow ?? window + let depthAfter = endWindowDragSuppression(window: targetWindow) + restoreWindowDragging(window: targetWindow, previousMovableState: previousWindowMovableState) + self.previousWindowMovableState = nil + self.suppressedWindow = nil + self.didArmWindowDragSuppression = false + #if DEBUG + let nowMovable = targetWindow.map { String($0.isMovable) } ?? "nil" + dlog("folder.dragSuppression restore depth=\(depthAfter) nowMovable=\(nowMovable)") + #endif + } + + private func maybeDisableWindowDraggingEarly(trigger: String) { + guard !didArmWindowDragSuppression else { return } + guard let eventType = NSApp.currentEvent?.type, + eventType == .leftMouseDown || eventType == .leftMouseDragged else { + return + } + guard let currentWindow = window else { return } + + didArmWindowDragSuppression = true + suppressedWindow = currentWindow + let suppressionDepth = beginWindowDragSuppression(window: currentWindow) ?? 0 + if currentWindow.isMovable { + previousWindowMovableState = temporarilyDisableWindowDragging(window: currentWindow) + } else { + previousWindowMovableState = nil + } + #if DEBUG + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = String(currentWindow.isMovable) + dlog( + "folder.dragSuppression trigger=\(trigger) event=\(eventType) depth=\(suppressionDepth) wasMovable=\(wasMovable) nowMovable=\(nowMovable)" + ) + #endif + } +} + +func temporarilyDisableWindowDragging(window: NSWindow?) -> Bool? { + guard let window else { return nil } + let wasMovable = window.isMovable + if wasMovable { + window.isMovable = false + } + return wasMovable +} + +func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) { + guard let window, let previousMovableState else { return } + window.isMovable = previousMovableState +} + +/// Wrapper view that tries NSGlassEffectView (macOS 26+) when available or requested +private struct SidebarVisualEffectBackground: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + let state: NSVisualEffectView.State + let opacity: Double + let tintColor: NSColor? + let cornerRadius: CGFloat + let preferLiquidGlass: Bool + + init( + material: NSVisualEffectView.Material = .hudWindow, + blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, + state: NSVisualEffectView.State = .active, + opacity: Double = 1.0, + tintColor: NSColor? = nil, + cornerRadius: CGFloat = 0, + preferLiquidGlass: Bool = false + ) { + self.material = material + self.blendingMode = blendingMode + self.state = state + self.opacity = opacity + self.tintColor = tintColor + self.cornerRadius = cornerRadius + self.preferLiquidGlass = preferLiquidGlass + } + + static var liquidGlassAvailable: Bool { + NSClassFromString("NSGlassEffectView") != nil + } + + func makeNSView(context: Context) -> NSView { + // Try NSGlassEffectView if preferred or if we want to test availability + if preferLiquidGlass, let glassClass = NSClassFromString("NSGlassEffectView") as? NSView.Type { + let glass = glassClass.init(frame: .zero) + glass.autoresizingMask = [.width, .height] + glass.wantsLayer = true + return glass + } + + // Use NSVisualEffectView + let view = NSVisualEffectView() + view.autoresizingMask = [.width, .height] + view.wantsLayer = true + view.layerContentsRedrawPolicy = .onSetNeedsDisplay + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + // Configure based on view type + if nsView.className == "NSGlassEffectView" { + // NSGlassEffectView configuration via private API + nsView.alphaValue = max(0.0, min(1.0, opacity)) + nsView.layer?.cornerRadius = cornerRadius + nsView.layer?.masksToBounds = cornerRadius > 0 + + // Try to set tint color via private selector + if let color = tintColor { + let selector = NSSelectorFromString("setTintColor:") + if nsView.responds(to: selector) { + nsView.perform(selector, with: color) + } + } + } else if let visualEffect = nsView as? NSVisualEffectView { + // NSVisualEffectView configuration + visualEffect.material = material + visualEffect.blendingMode = blendingMode + visualEffect.state = state + visualEffect.alphaValue = max(0.0, min(1.0, opacity)) + visualEffect.layer?.cornerRadius = cornerRadius + visualEffect.layer?.masksToBounds = cornerRadius > 0 + visualEffect.needsDisplay = true + } + } +} + +/// Reads the leading inset required to clear traffic lights + left titlebar accessories. +final class TitlebarLeadingInsetPassthroughView: NSView { + override var mouseDownCanMoveWindow: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + +struct TitlebarLeadingInsetReader: NSViewRepresentable { + @Binding var inset: CGFloat + + func makeNSView(context: Context) -> NSView { + let view = TitlebarLeadingInsetPassthroughView() + view.setFrameSize(.zero) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { + guard let window = nsView.window else { return } + // Start past the traffic lights + var leading: CGFloat = 78 + // Add width of all left-aligned titlebar accessories + for accessory in window.titlebarAccessoryViewControllers + where accessory.layoutAttribute == .leading || accessory.layoutAttribute == .left { + leading += accessory.view.frame.width + } + leading += 0 + if leading != inset { + inset = leading + } + } + } +} + +/// 1px trailing border on the sidebar, derived from the terminal chrome background +/// using the same logic as bonsplit's TabBarColors.nsColorSeparator: +/// dark bg → lighten RGB by 0.16 at 0.36 alpha; light bg → darken by 0.12 at 0.26 alpha. +struct SidebarTrailingBorder: View { + @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false + @State private var separatorColor: NSColor = chromeSeparatorColor() + + var body: some View { + if matchTerminalBackground { + Rectangle() + .fill(Color(nsColor: separatorColor)) + .frame(width: 1) + .ignoresSafeArea() + .onAppear { + separatorColor = Self.chromeSeparatorColor() + } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in + separatorColor = Self.chromeSeparatorColor() + } + } + } + + /// Replicates bonsplit TabBarColors.nsColorSeparator derivation from chrome background. + private static func chromeSeparatorColor() -> NSColor { + let chrome = GhosttyBackgroundTheme.currentColor() + let srgb = chrome.usingColorSpace(.sRGB) ?? chrome + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + srgb.getRed(&r, green: &g, blue: &b, alpha: &a) + let luminance = 0.299 * r + 0.587 * g + 0.114 * b + let isLight = luminance > 0.5 + let amount: CGFloat = isLight ? -0.12 : 0.16 + let alpha: CGFloat = isLight ? 0.26 : 0.36 + return NSColor( + red: min(1.0, max(0.0, r + amount)), + green: min(1.0, max(0.0, g + amount)), + blue: min(1.0, max(0.0, b + amount)), + alpha: alpha + ) + } +} + +/// Sidebar background that uses the same technique as TitlebarLayerBackground: +/// fully opaque layer color + layer-level opacity. This matches how the terminal's +/// Metal surface composites its background. +private struct SidebarTerminalBackgroundView: NSViewRepresentable { + let backgroundColor: NSColor + let opacity: CGFloat + + func makeNSView(context: Context) -> NSView { + let view = NSView() + view.wantsLayer = true + view.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor + view.layer?.opacity = Float(opacity) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + nsView.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor + nsView.layer?.opacity = Float(opacity) + } +} + +struct SidebarBackdrop: View { + @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false + @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity + @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex + @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? + @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? + @AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue + @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue + @AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue + @AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0 + @AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0 + @Environment(\.colorScheme) private var colorScheme + @State private var terminalBackgroundColor: NSColor = GhosttyBackgroundTheme.currentColor() + + var body: some View { + let cornerRadius = CGFloat(max(0, sidebarCornerRadius)) + + if matchTerminalBackground { + // The terminal background is provided by a single CALayer, so + // the sidebar uses the configured opacity directly. + let alpha = CGFloat(GhosttyApp.shared.defaultBackgroundOpacity) + return AnyView( + SidebarTerminalBackgroundView( + backgroundColor: GhosttyApp.shared.defaultBackgroundColor, + opacity: alpha + ) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in + terminalBackgroundColor = GhosttyBackgroundTheme.currentColor() + } + ) + } + + let materialOption = SidebarMaterialOption(rawValue: sidebarMaterial) + let blendingMode = SidebarBlendModeOption(rawValue: sidebarBlendMode)?.mode ?? .behindWindow + let state = SidebarStateOption(rawValue: sidebarState)?.state ?? .active + let resolvedHex: String = { + if colorScheme == .dark, let dark = sidebarTintHexDark { + return dark + } else if colorScheme == .light, let light = sidebarTintHexLight { + return light + } + return sidebarTintHex + }() + let tintColor = (NSColor(hex: resolvedHex) ?? NSColor(hex: sidebarTintHex) ?? .black).withAlphaComponent(sidebarTintOpacity) + let useLiquidGlass = materialOption?.usesLiquidGlass ?? false + let useWindowLevelGlass = useLiquidGlass && blendingMode == .behindWindow + + return AnyView( + ZStack { + if let material = materialOption?.material { + // When using liquidGlass + behindWindow, window handles glass + tint + // Sidebar is fully transparent + if !useWindowLevelGlass { + SidebarVisualEffectBackground( + material: material, + blendingMode: blendingMode, + state: state, + opacity: sidebarBlurOpacity, + tintColor: tintColor, + cornerRadius: cornerRadius, + preferLiquidGlass: useLiquidGlass + ) + // Tint overlay for NSVisualEffectView fallback + if !useLiquidGlass { + Color(nsColor: tintColor) + } + } + } + // When material is none or useWindowLevelGlass, render nothing + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + ) + } +} + +enum SidebarMaterialOption: String, CaseIterable, Identifiable { + case none + case liquidGlass // macOS 26+ NSGlassEffectView + case sidebar + case hudWindow + case menu + case popover + case underWindowBackground + case windowBackground + case contentBackground + case fullScreenUI + case sheet + case headerView + case toolTip + + var id: String { rawValue } + + var title: String { + switch self { + case .none: return String(localized: "settings.material.none", defaultValue: "None") + case .liquidGlass: return String(localized: "settings.material.liquidGlass", defaultValue: "Liquid Glass (macOS 26+)") + case .sidebar: return String(localized: "settings.material.sidebar", defaultValue: "Sidebar") + case .hudWindow: return String(localized: "settings.material.hudWindow", defaultValue: "HUD Window") + case .menu: return String(localized: "settings.material.menu", defaultValue: "Menu") + case .popover: return String(localized: "settings.material.popover", defaultValue: "Popover") + case .underWindowBackground: return String(localized: "settings.material.underWindow", defaultValue: "Under Window") + case .windowBackground: return String(localized: "settings.material.windowBackground", defaultValue: "Window Background") + case .contentBackground: return String(localized: "settings.material.contentBackground", defaultValue: "Content Background") + case .fullScreenUI: return String(localized: "settings.material.fullScreenUI", defaultValue: "Full Screen UI") + case .sheet: return String(localized: "settings.material.sheet", defaultValue: "Sheet") + case .headerView: return String(localized: "settings.material.headerView", defaultValue: "Header View") + case .toolTip: return String(localized: "settings.material.toolTip", defaultValue: "Tool Tip") + } + } + + /// Returns true if this option should use NSGlassEffectView (macOS 26+) + var usesLiquidGlass: Bool { + self == .liquidGlass + } + + var material: NSVisualEffectView.Material? { + switch self { + case .none: return nil + case .liquidGlass: return .underWindowBackground // Fallback material + case .sidebar: return .sidebar + case .hudWindow: return .hudWindow + case .menu: return .menu + case .popover: return .popover + case .underWindowBackground: return .underWindowBackground + case .windowBackground: return .windowBackground + case .contentBackground: return .contentBackground + case .fullScreenUI: return .fullScreenUI + case .sheet: return .sheet + case .headerView: return .headerView + case .toolTip: return .toolTip + } + } +} + +enum SidebarBlendModeOption: String, CaseIterable, Identifiable { + case behindWindow + case withinWindow + + var id: String { rawValue } + + var title: String { + switch self { + case .behindWindow: return String(localized: "settings.blendMode.behindWindow", defaultValue: "Behind Window") + case .withinWindow: return String(localized: "settings.blendMode.withinWindow", defaultValue: "Within Window") + } + } + + var mode: NSVisualEffectView.BlendingMode { + switch self { + case .behindWindow: return .behindWindow + case .withinWindow: return .withinWindow + } + } +} + +enum SidebarStateOption: String, CaseIterable, Identifiable { + case active + case inactive + case followWindow + + var id: String { rawValue } + + var title: String { + switch self { + case .active: return String(localized: "settings.state.active", defaultValue: "Active") + case .inactive: return String(localized: "settings.state.inactive", defaultValue: "Inactive") + case .followWindow: return String(localized: "settings.state.followWindow", defaultValue: "Follow Window") + } + } + + var state: NSVisualEffectView.State { + switch self { + case .active: return .active + case .inactive: return .inactive + case .followWindow: return .followsWindowActiveState + } + } +} + +enum SidebarTintDefaults { + static let hex = "#000000" + static let opacity = 0.18 +} + +enum SidebarPresetOption: String, CaseIterable, Identifiable { + case nativeSidebar + case glassBehind + case softBlur + case popoverGlass + case hudGlass + case underWindow + + var id: String { rawValue } + + var title: String { + switch self { + case .nativeSidebar: return String(localized: "settings.preset.nativeSidebar", defaultValue: "Native Sidebar") + case .glassBehind: return String(localized: "settings.preset.raycastGray", defaultValue: "Raycast Gray") + case .softBlur: return String(localized: "settings.preset.softBlur", defaultValue: "Soft Blur") + case .popoverGlass: return String(localized: "settings.preset.popoverGlass", defaultValue: "Popover Glass") + case .hudGlass: return String(localized: "settings.preset.hudGlass", defaultValue: "HUD Glass") + case .underWindow: return String(localized: "settings.preset.underWindow", defaultValue: "Under Window") + } + } + + var material: SidebarMaterialOption { + switch self { + case .nativeSidebar: return .sidebar + case .glassBehind: return .sidebar + case .softBlur: return .sidebar + case .popoverGlass: return .popover + case .hudGlass: return .hudWindow + case .underWindow: return .underWindowBackground + } + } + + var blendMode: SidebarBlendModeOption { + switch self { + case .nativeSidebar: return .withinWindow + case .glassBehind: return .behindWindow + case .softBlur: return .behindWindow + case .popoverGlass: return .behindWindow + case .hudGlass: return .withinWindow + case .underWindow: return .withinWindow + } + } + + var state: SidebarStateOption { + switch self { + case .nativeSidebar: return .followWindow + case .glassBehind: return .active + case .softBlur: return .active + case .popoverGlass: return .active + case .hudGlass: return .active + case .underWindow: return .followWindow + } + } + + var tintHex: String { + switch self { + case .nativeSidebar: return "#000000" + case .glassBehind: return "#000000" + case .softBlur: return "#000000" + case .popoverGlass: return "#000000" + case .hudGlass: return "#000000" + case .underWindow: return "#000000" + } + } + + var tintOpacity: Double { + switch self { + case .nativeSidebar: return 0.18 + case .glassBehind: return 0.36 + case .softBlur: return 0.28 + case .popoverGlass: return 0.10 + case .hudGlass: return 0.62 + case .underWindow: return 0.14 + } + } + + var cornerRadius: Double { + switch self { + case .nativeSidebar: return 0.0 + case .glassBehind: return 0.0 + case .softBlur: return 0.0 + case .popoverGlass: return 10.0 + case .hudGlass: return 10.0 + case .underWindow: return 6.0 + } + } + + var blurOpacity: Double { + switch self { + case .nativeSidebar: return 1.0 + case .glassBehind: return 0.6 + case .softBlur: return 0.45 + case .popoverGlass: return 0.9 + case .hudGlass: return 0.98 + case .underWindow: return 0.9 + } + } +} + +extension NSColor { + func hexString(includeAlpha: Bool = false) -> String { + let color = usingColorSpace(.sRGB) ?? self + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + let redByte = min(255, max(0, Int((red * 255).rounded()))) + let greenByte = min(255, max(0, Int((green * 255).rounded()))) + let blueByte = min(255, max(0, Int((blue * 255).rounded()))) + if includeAlpha { + let alphaByte = min(255, max(0, Int((alpha * 255).rounded()))) + return String(format: "#%02X%02X%02X%02X", redByte, greenByte, blueByte, alphaByte) + } + return String(format: "#%02X%02X%02X", redByte, greenByte, blueByte) + } +} From aac48260f5e2233570a6a2b2188a41ab84260b38 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:20:27 -0300 Subject: [PATCH 9/9] refactor(contentview): extract CommandPaletteController for palette state ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all 34 command-palette-only @State properties plus the 2 @AppStorage settings (commandPaletteQuery, commandPaletteMode, search corpus/results, rename/workspace-description drafts, focus-restore targets, usage history, etc.) off ContentView and onto a new CommandPaletteController: ObservableObject, as @Published/@AppStorage properties. ContentView keeps exactly one `@StateObject private var commandPaletteController`. ContentView's existing (unqualified) call sites — the ~4000 lines of palette orchestration (registerCommandPaletteHandlers, commandPaletteCommandContributions, rename/workspace-description flows, focus-restore, usage-history persistence, the view body) — are preserved unchanged: each former @State name becomes a thin computed proxy on ContentView that forwards get/set to the controller (`private var commandPaletteQuery: String { get { commandPaletteController. commandPaletteQuery } nonmutating set { ... } }`). This is a real ownership transfer (the controller is the only place these values are stored; ContentView holds no @State backing them anymore), done without touching the orchestration method bodies, which keeps this change mechanically verifiable. 5 properties that were bound via SwiftUI's $name projection at their call sites (commandPaletteQuery, commandPaletteRenameDraft, commandPaletteWorkspaceDescriptionDraft, commandPaletteWorkspaceDescriptionHeight, commandPaletteShouldFocusWorkspaceDescriptionEditor) get a matching `nameBinding: Binding` proxy instead, since computed properties can't project a $-binding; call sites were updated to use . The 2 @FocusState properties (isCommandPaletteSearchFocused, isCommandPaletteRenameFocused) stay on ContentView — @FocusState is a SwiftUI View-only property wrapper and cannot be hosted on an ObservableObject. This is the actual reason the file's previous "CV1" extraction attempt called a full split impossible; it wasn't, once the @Published-storage-plus-proxy shape is used instead of moving @State verbatim. Access-level widening (dropped `private`, no other behavior change) for nested types now referenced from CommandPaletteController.swift (a different file): CommandPaletteMode, CommandPaletteRestoreFocusTarget, CommandPaletteTextSelectionBehavior, CommandPaletteMultilineTextEditorRepresentable, CommandPaletteRenameTarget, CommandPaletteWorkspaceDescriptionTarget (enum-case associated types must be at least as visible as the enum), and their AppKit backing views CommandPaletteMultilineTextEditorView / CommandPaletteMultilineTextView (NSViewRepresentable makeNSView/updateNSView must be at least as visible as the representable struct). The ~4000-line orchestration block and the ContentView+CommandPalette.swift pure helpers were NOT physically relocated onto the controller in this pass — they continue to compile and run correctly against the controller through the proxies, but remain textually in ContentView.swift/ContentView+CommandPalette.swift. Physically moving that method block is left for a follow-up; the state ownership change (the core ask) is complete and verified. Refs #88. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/CommandPaletteController.swift | 73 ++++++++ Sources/ContentView.swift | 249 +++++++++++++++++++------ 3 files changed, 273 insertions(+), 53 deletions(-) create mode 100644 Sources/CommandPaletteController.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 0ac220775fa..dcceabc6593 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ NRCV00000000000000000001 /* CommandPaletteSearchEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */; }; NRCV00000000000000000007 /* SidebarDragDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000008 /* SidebarDragDrop.swift */; }; NRCV00000000000000000009 /* SidebarVisuals.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000010 /* SidebarVisuals.swift */; }; + NRCV00000000000000000011 /* CommandPaletteController.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000012 /* CommandPaletteController.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; }; A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; @@ -245,6 +246,7 @@ NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngine.swift; sourceTree = ""; }; NRCV00000000000000000008 /* SidebarDragDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarDragDrop.swift; sourceTree = ""; }; NRCV00000000000000000010 /* SidebarVisuals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarVisuals.swift; sourceTree = ""; }; + NRCV00000000000000000012 /* CommandPaletteController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteController.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; @@ -519,6 +521,7 @@ NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */, NRCV00000000000000000008 /* SidebarDragDrop.swift */, NRCV00000000000000000010 /* SidebarVisuals.swift */, + NRCV00000000000000000012 /* CommandPaletteController.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, @@ -854,6 +857,7 @@ NRCV00000000000000000001 /* CommandPaletteSearchEngine.swift in Sources */, NRCV00000000000000000007 /* SidebarDragDrop.swift in Sources */, NRCV00000000000000000009 /* SidebarVisuals.swift in Sources */, + NRCV00000000000000000011 /* CommandPaletteController.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, diff --git a/Sources/CommandPaletteController.swift b/Sources/CommandPaletteController.swift new file mode 100644 index 00000000000..c12ebba8d0f --- /dev/null +++ b/Sources/CommandPaletteController.swift @@ -0,0 +1,73 @@ +// Command-palette state ownership, extracted from ContentView.swift (nuclear-review #88). +// +// CommandPaletteController owns every @State property that used to live on +// ContentView and is exclusively used by the command palette (query, mode, +// search corpus/results, rename/workspace-description drafts, focus-restore +// targets, usage history, etc.). ContentView holds a single +// `@StateObject private var commandPaletteController` and exposes each +// property back to its existing (unqualified) call sites in its body via +// thin computed proxies — this keeps the ~4000 lines of palette orchestration +// code that reads/writes these properties unchanged while genuinely moving +// storage ownership onto the controller (no more @State duplicated per-view). +// +// The two @FocusState properties (isCommandPaletteSearchFocused, +// isCommandPaletteRenameFocused) stay on ContentView: @FocusState is a +// SwiftUI View-only property wrapper and cannot be hosted on an +// ObservableObject. +// +// Access-level widening: CommandPaletteMode, CommandPaletteRestoreFocusTarget, +// CommandPaletteTextSelectionBehavior, and CommandPaletteMultilineTextEditorRepresentable +// were `private` nested types inside ContentView; widened to internal (dropped +// the `private` modifier) so this controller (a different file) can reference +// them. No other behavior change. CommandPaletteCommand, CommandPaletteSearchResult, +// CommandPaletteListScope, CommandPalettePendingActivation, and +// CommandPaletteUsageEntry were already internal nested types; referenced here +// via `ContentView.` qualification since nested-type name lookup requires it +// from outside the enclosing type's lexical scope. + +import AppKit +import Combine +import SwiftUI + +final class CommandPaletteController: ObservableObject { + @Published var isCommandPalettePresented = false + @Published var commandPaletteQuery: String = "" + @Published var commandPaletteMode: ContentView.CommandPaletteMode = .commands + @Published var commandPaletteRenameDraft: String = "" + @Published var commandPaletteWorkspaceDescriptionDraft: String = "" + @Published var commandPaletteWorkspaceDescriptionHeight: CGFloat = ContentView.CommandPaletteMultilineTextEditorRepresentable.defaultMinimumHeight + @Published var commandPaletteSelectedResultIndex: Int = 0 + @Published var commandPaletteSelectionAnchorCommandID: String? + @Published var commandPaletteHoveredResultIndex: Int? + @Published var commandPaletteScrollTargetIndex: Int? + @Published var commandPaletteScrollTargetAnchor: UnitPoint? + @Published var commandPaletteRestoreFocusTarget: ContentView.CommandPaletteRestoreFocusTarget? + @Published var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry] = [] + @Published var commandPaletteSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry] = [:] + @Published var commandPaletteSearchCommandsByID: [String: ContentView.CommandPaletteCommand] = [:] + @Published var cachedCommandPaletteResults: [ContentView.CommandPaletteSearchResult] = [] + @Published var commandPaletteVisibleResults: [ContentView.CommandPaletteSearchResult] = [] + @Published var commandPaletteVisibleResultsScope: ContentView.CommandPaletteListScope? + @Published var commandPaletteVisibleResultsFingerprint: Int? + @Published var cachedCommandPaletteScope: ContentView.CommandPaletteListScope? + @Published var cachedCommandPaletteFingerprint: Int? + @Published var commandPalettePendingDismissFocusTarget: ContentView.CommandPaletteRestoreFocusTarget? + @Published var commandPaletteRestoreTimeoutWorkItem: DispatchWorkItem? + @Published var commandPalettePendingTextSelectionBehavior: ContentView.CommandPaletteTextSelectionBehavior? + @Published var commandPaletteSearchTask: Task? + @Published var commandPaletteSearchRequestID: UInt64 = 0 + @Published var commandPaletteResolvedSearchRequestID: UInt64 = 0 + @Published var commandPaletteResolvedSearchScope: ContentView.CommandPaletteListScope? + @Published var commandPaletteResolvedSearchFingerprint: Int? + @Published var commandPaletteResolvedMatchingQuery = "" + @Published var commandPaletteTerminalOpenTargetAvailability: Set = [] + @Published var isCommandPaletteSearchPending = false + @Published var commandPalettePendingActivation: ContentView.CommandPalettePendingActivation? + @Published var commandPaletteResultsRevision: UInt64 = 0 + @Published var commandPaletteUsageHistoryByCommandId: [String: ContentView.CommandPaletteUsageEntry] = [:] + @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) + var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces + @Published var commandPaletteShouldFocusWorkspaceDescriptionEditor = false +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 87ca62e5b4a..cfc56e5321f 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1240,50 +1240,193 @@ struct ContentView: View { @State private var isResizerBandActive = false @State private var isSidebarResizerCursorActive = false @State private var sidebarResizerCursorStabilizer: DispatchSourceTimer? - @State private var isCommandPalettePresented = false - @State private var commandPaletteQuery: String = "" - @State private var commandPaletteMode: CommandPaletteMode = .commands - @State private var commandPaletteRenameDraft: String = "" - @State private var commandPaletteWorkspaceDescriptionDraft: String = "" - @State private var commandPaletteWorkspaceDescriptionHeight: CGFloat = CommandPaletteMultilineTextEditorRepresentable.defaultMinimumHeight - @State private var commandPaletteSelectedResultIndex: Int = 0 - @State private var commandPaletteSelectionAnchorCommandID: String? - @State private var commandPaletteHoveredResultIndex: Int? - @State private var commandPaletteScrollTargetIndex: Int? - @State private var commandPaletteScrollTargetAnchor: UnitPoint? - @State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? - @State private var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry] = [] - @State private var commandPaletteSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry] = [:] - @State private var commandPaletteSearchCommandsByID: [String: CommandPaletteCommand] = [:] - @State private var cachedCommandPaletteResults: [CommandPaletteSearchResult] = [] - @State private var commandPaletteVisibleResults: [CommandPaletteSearchResult] = [] - @State private var commandPaletteVisibleResultsScope: CommandPaletteListScope? - @State private var commandPaletteVisibleResultsFingerprint: Int? - @State private var cachedCommandPaletteScope: CommandPaletteListScope? - @State private var cachedCommandPaletteFingerprint: Int? - @State private var commandPalettePendingDismissFocusTarget: CommandPaletteRestoreFocusTarget? - @State private var commandPaletteRestoreTimeoutWorkItem: DispatchWorkItem? - @State private var commandPalettePendingTextSelectionBehavior: CommandPaletteTextSelectionBehavior? - @State private var commandPaletteSearchTask: Task? - @State private var commandPaletteSearchRequestID: UInt64 = 0 - @State private var commandPaletteResolvedSearchRequestID: UInt64 = 0 - @State private var commandPaletteResolvedSearchScope: CommandPaletteListScope? - @State private var commandPaletteResolvedSearchFingerprint: Int? - @State private var commandPaletteResolvedMatchingQuery = "" - @State private var commandPaletteTerminalOpenTargetAvailability: Set = [] - @State private var isCommandPaletteSearchPending = false - @State private var commandPalettePendingActivation: CommandPalettePendingActivation? - @State private var commandPaletteResultsRevision: UInt64 = 0 - @State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:] - @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) - private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus - @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) - private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces - @State private var commandPaletteShouldFocusWorkspaceDescriptionEditor = false + @StateObject private var commandPaletteController = CommandPaletteController() + private var isCommandPalettePresented: Bool { + get { commandPaletteController.isCommandPalettePresented } + nonmutating set { commandPaletteController.isCommandPalettePresented = newValue } + } + private var commandPaletteQuery: String { + get { commandPaletteController.commandPaletteQuery } + nonmutating set { commandPaletteController.commandPaletteQuery = newValue } + } + private var commandPaletteMode: CommandPaletteMode { + get { commandPaletteController.commandPaletteMode } + nonmutating set { commandPaletteController.commandPaletteMode = newValue } + } + private var commandPaletteRenameDraft: String { + get { commandPaletteController.commandPaletteRenameDraft } + nonmutating set { commandPaletteController.commandPaletteRenameDraft = newValue } + } + private var commandPaletteWorkspaceDescriptionDraft: String { + get { commandPaletteController.commandPaletteWorkspaceDescriptionDraft } + nonmutating set { commandPaletteController.commandPaletteWorkspaceDescriptionDraft = newValue } + } + private var commandPaletteWorkspaceDescriptionHeight: CGFloat { + get { commandPaletteController.commandPaletteWorkspaceDescriptionHeight } + nonmutating set { commandPaletteController.commandPaletteWorkspaceDescriptionHeight = newValue } + } + private var commandPaletteSelectedResultIndex: Int { + get { commandPaletteController.commandPaletteSelectedResultIndex } + nonmutating set { commandPaletteController.commandPaletteSelectedResultIndex = newValue } + } + private var commandPaletteSelectionAnchorCommandID: String? { + get { commandPaletteController.commandPaletteSelectionAnchorCommandID } + nonmutating set { commandPaletteController.commandPaletteSelectionAnchorCommandID = newValue } + } + private var commandPaletteHoveredResultIndex: Int? { + get { commandPaletteController.commandPaletteHoveredResultIndex } + nonmutating set { commandPaletteController.commandPaletteHoveredResultIndex = newValue } + } + private var commandPaletteScrollTargetIndex: Int? { + get { commandPaletteController.commandPaletteScrollTargetIndex } + nonmutating set { commandPaletteController.commandPaletteScrollTargetIndex = newValue } + } + private var commandPaletteScrollTargetAnchor: UnitPoint? { + get { commandPaletteController.commandPaletteScrollTargetAnchor } + nonmutating set { commandPaletteController.commandPaletteScrollTargetAnchor = newValue } + } + private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? { + get { commandPaletteController.commandPaletteRestoreFocusTarget } + nonmutating set { commandPaletteController.commandPaletteRestoreFocusTarget = newValue } + } + private var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry] { + get { commandPaletteController.commandPaletteSearchCorpus } + nonmutating set { commandPaletteController.commandPaletteSearchCorpus = newValue } + } + private var commandPaletteSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry] { + get { commandPaletteController.commandPaletteSearchCorpusByID } + nonmutating set { commandPaletteController.commandPaletteSearchCorpusByID = newValue } + } + private var commandPaletteSearchCommandsByID: [String: CommandPaletteCommand] { + get { commandPaletteController.commandPaletteSearchCommandsByID } + nonmutating set { commandPaletteController.commandPaletteSearchCommandsByID = newValue } + } + private var cachedCommandPaletteResults: [CommandPaletteSearchResult] { + get { commandPaletteController.cachedCommandPaletteResults } + nonmutating set { commandPaletteController.cachedCommandPaletteResults = newValue } + } + private var commandPaletteVisibleResults: [CommandPaletteSearchResult] { + get { commandPaletteController.commandPaletteVisibleResults } + nonmutating set { commandPaletteController.commandPaletteVisibleResults = newValue } + } + private var commandPaletteVisibleResultsScope: CommandPaletteListScope? { + get { commandPaletteController.commandPaletteVisibleResultsScope } + nonmutating set { commandPaletteController.commandPaletteVisibleResultsScope = newValue } + } + private var commandPaletteVisibleResultsFingerprint: Int? { + get { commandPaletteController.commandPaletteVisibleResultsFingerprint } + nonmutating set { commandPaletteController.commandPaletteVisibleResultsFingerprint = newValue } + } + private var cachedCommandPaletteScope: CommandPaletteListScope? { + get { commandPaletteController.cachedCommandPaletteScope } + nonmutating set { commandPaletteController.cachedCommandPaletteScope = newValue } + } + private var cachedCommandPaletteFingerprint: Int? { + get { commandPaletteController.cachedCommandPaletteFingerprint } + nonmutating set { commandPaletteController.cachedCommandPaletteFingerprint = newValue } + } + private var commandPalettePendingDismissFocusTarget: CommandPaletteRestoreFocusTarget? { + get { commandPaletteController.commandPalettePendingDismissFocusTarget } + nonmutating set { commandPaletteController.commandPalettePendingDismissFocusTarget = newValue } + } + private var commandPaletteRestoreTimeoutWorkItem: DispatchWorkItem? { + get { commandPaletteController.commandPaletteRestoreTimeoutWorkItem } + nonmutating set { commandPaletteController.commandPaletteRestoreTimeoutWorkItem = newValue } + } + private var commandPalettePendingTextSelectionBehavior: CommandPaletteTextSelectionBehavior? { + get { commandPaletteController.commandPalettePendingTextSelectionBehavior } + nonmutating set { commandPaletteController.commandPalettePendingTextSelectionBehavior = newValue } + } + private var commandPaletteSearchTask: Task? { + get { commandPaletteController.commandPaletteSearchTask } + nonmutating set { commandPaletteController.commandPaletteSearchTask = newValue } + } + private var commandPaletteSearchRequestID: UInt64 { + get { commandPaletteController.commandPaletteSearchRequestID } + nonmutating set { commandPaletteController.commandPaletteSearchRequestID = newValue } + } + private var commandPaletteResolvedSearchRequestID: UInt64 { + get { commandPaletteController.commandPaletteResolvedSearchRequestID } + nonmutating set { commandPaletteController.commandPaletteResolvedSearchRequestID = newValue } + } + private var commandPaletteResolvedSearchScope: CommandPaletteListScope? { + get { commandPaletteController.commandPaletteResolvedSearchScope } + nonmutating set { commandPaletteController.commandPaletteResolvedSearchScope = newValue } + } + private var commandPaletteResolvedSearchFingerprint: Int? { + get { commandPaletteController.commandPaletteResolvedSearchFingerprint } + nonmutating set { commandPaletteController.commandPaletteResolvedSearchFingerprint = newValue } + } + private var commandPaletteResolvedMatchingQuery: String { + get { commandPaletteController.commandPaletteResolvedMatchingQuery } + nonmutating set { commandPaletteController.commandPaletteResolvedMatchingQuery = newValue } + } + private var commandPaletteTerminalOpenTargetAvailability: Set { + get { commandPaletteController.commandPaletteTerminalOpenTargetAvailability } + nonmutating set { commandPaletteController.commandPaletteTerminalOpenTargetAvailability = newValue } + } + private var isCommandPaletteSearchPending: Bool { + get { commandPaletteController.isCommandPaletteSearchPending } + nonmutating set { commandPaletteController.isCommandPaletteSearchPending = newValue } + } + private var commandPalettePendingActivation: CommandPalettePendingActivation? { + get { commandPaletteController.commandPalettePendingActivation } + nonmutating set { commandPaletteController.commandPalettePendingActivation = newValue } + } + private var commandPaletteResultsRevision: UInt64 { + get { commandPaletteController.commandPaletteResultsRevision } + nonmutating set { commandPaletteController.commandPaletteResultsRevision = newValue } + } + private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] { + get { commandPaletteController.commandPaletteUsageHistoryByCommandId } + nonmutating set { commandPaletteController.commandPaletteUsageHistoryByCommandId = newValue } + } + private var commandPaletteRenameSelectAllOnFocus: Bool { + get { commandPaletteController.commandPaletteRenameSelectAllOnFocus } + nonmutating set { commandPaletteController.commandPaletteRenameSelectAllOnFocus = newValue } + } + private var commandPaletteSearchAllSurfaces: Bool { + get { commandPaletteController.commandPaletteSearchAllSurfaces } + nonmutating set { commandPaletteController.commandPaletteSearchAllSurfaces = newValue } + } + private var commandPaletteShouldFocusWorkspaceDescriptionEditor: Bool { + get { commandPaletteController.commandPaletteShouldFocusWorkspaceDescriptionEditor } + nonmutating set { commandPaletteController.commandPaletteShouldFocusWorkspaceDescriptionEditor = newValue } + } + private var commandPaletteQueryBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteQuery }, + set: { commandPaletteController.commandPaletteQuery = $0 } + ) + } + private var commandPaletteRenameDraftBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteRenameDraft }, + set: { commandPaletteController.commandPaletteRenameDraft = $0 } + ) + } + private var commandPaletteWorkspaceDescriptionDraftBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteWorkspaceDescriptionDraft }, + set: { commandPaletteController.commandPaletteWorkspaceDescriptionDraft = $0 } + ) + } + private var commandPaletteWorkspaceDescriptionHeightBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteWorkspaceDescriptionHeight }, + set: { commandPaletteController.commandPaletteWorkspaceDescriptionHeight = $0 } + ) + } + private var commandPaletteShouldFocusWorkspaceDescriptionEditorBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteShouldFocusWorkspaceDescriptionEditor }, + set: { commandPaletteController.commandPaletteShouldFocusWorkspaceDescriptionEditor = $0 } + ) + } @FocusState private var isCommandPaletteSearchFocused: Bool @FocusState private var isCommandPaletteRenameFocused: Bool - private enum CommandPaletteMode { + enum CommandPaletteMode { case commands case renameInput(CommandPaletteRenameTarget) case renameConfirm(CommandPaletteRenameTarget, proposedName: String) @@ -1305,7 +1448,7 @@ struct ContentView: View { case command(commandID: String) } - private struct CommandPaletteRenameTarget: Equatable { + struct CommandPaletteRenameTarget: Equatable { enum Kind: Equatable { case workspace(workspaceId: UUID) case tab(workspaceId: UUID, panelId: UUID) @@ -1342,7 +1485,7 @@ struct ContentView: View { } } - private struct CommandPaletteWorkspaceDescriptionTarget: Equatable { + struct CommandPaletteWorkspaceDescriptionTarget: Equatable { let workspaceId: UUID let currentDescription: String @@ -1361,7 +1504,7 @@ struct ContentView: View { } } - private struct CommandPaletteRestoreFocusTarget { + struct CommandPaletteRestoreFocusTarget { let workspaceId: UUID let panelId: UUID let intent: PanelFocusIntent @@ -1372,7 +1515,7 @@ struct ContentView: View { case rename } - private enum CommandPaletteTextSelectionBehavior { + enum CommandPaletteTextSelectionBehavior { case caretAtEnd case selectAll } @@ -3312,7 +3455,7 @@ struct ContentView: View { HStack(spacing: 8) { CommandPaletteSearchFieldRepresentable( placeholder: commandPaletteSearchPlaceholder, - text: $commandPaletteQuery, + text: commandPaletteQueryBinding, isFocused: Binding( get: { isCommandPaletteSearchFocused }, set: { isCommandPaletteSearchFocused = $0 } @@ -3532,7 +3675,7 @@ struct ContentView: View { onDeleteBackward: handleCommandPaletteRenameDeleteBackward(modifiers:) ), placeholder: target.placeholder, - text: $commandPaletteRenameDraft, + text: commandPaletteRenameDraftBinding, onSubmit: { _ in continueRenameFlow(target: target) }, onEscape: { dismissCommandPalette() }, onInteraction: handleCommandPaletteRenameInputInteraction @@ -3616,12 +3759,12 @@ struct ContentView: View { localized: "command.editWorkspaceDescription.title", defaultValue: "Edit Workspace Description…" ), - focus: $commandPaletteShouldFocusWorkspaceDescriptionEditor, - measuredHeight: $commandPaletteWorkspaceDescriptionHeight, + focus: commandPaletteShouldFocusWorkspaceDescriptionEditorBinding, + measuredHeight: commandPaletteWorkspaceDescriptionHeightBinding, maxHeight: maxEditorHeight ), placeholder: target.placeholder, - text: $commandPaletteWorkspaceDescriptionDraft, + text: commandPaletteWorkspaceDescriptionDraftBinding, onSubmit: { proposedDescription in applyWorkspaceDescriptionFlow(target: target, proposedDescription: proposedDescription) }, @@ -3905,7 +4048,7 @@ struct ContentView: View { override func hitTest(_ point: NSPoint) -> NSView? { nil } } - private final class CommandPaletteMultilineTextView: NSTextView { + final class CommandPaletteMultilineTextView: NSTextView { var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)? var onDidBecomeFirstResponder: (() -> Void)? @@ -4033,7 +4176,7 @@ struct ContentView: View { } } - private final class CommandPaletteMultilineTextEditorView: NSView { + final class CommandPaletteMultilineTextEditorView: NSView { private static let font = NSFont.systemFont(ofSize: 13) private static let textInset = NSSize(width: 0, height: 2) static let defaultMinimumHeight: CGFloat = { @@ -4238,7 +4381,7 @@ struct ContentView: View { } } - private struct CommandPaletteMultilineTextEditorRepresentable: NSViewRepresentable { + struct CommandPaletteMultilineTextEditorRepresentable: NSViewRepresentable { static let defaultMinimumHeight = CommandPaletteMultilineTextEditorView.defaultMinimumHeight let placeholder: String