diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index c6bbfefc65..11ffc20011 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -15,14 +15,14 @@ 7221 cmuxTests/WorkspaceRemoteConnectionTests.swift 6317 cmuxTests/SessionPersistenceTests.swift 6299 cmuxTests/GhosttyConfigTests.swift +6158 Sources/TabManager.swift 6153 CLI/cmux_open.swift -6116 Sources/TabManager.swift 6074 Sources/TextBoxInput.swift 5925 cmuxTests/TerminalAndGhosttyTests.swift 5526 cmuxTests/BrowserConfigTests.swift 5113 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 4920 Sources/cmuxApp.swift -4467 Sources/Panels/FilePreviewPanel.swift +4509 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift 4227 Sources/BrowserWindowPortal.swift 3937 Sources/Feed/FeedPanelView.swift @@ -190,6 +190,7 @@ 528 cmuxUITests/AutomationSocketUITests.swift 527 CLI/CLISocketPathResolver.swift 524 CLI/CMUXCLI+AutoNaming.swift +523 Sources/Panels/MarkdownPanel.swift 522 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift 520 CLI/CMUXCLI+AmpExtension.swift 520 cmuxTests/MainWindowVisibilityControllerTests.swift diff --git a/Sources/Panels/AgentSessionPanel.swift b/Sources/Panels/AgentSessionPanel.swift index b74203e555..27165866ad 100644 --- a/Sources/Panels/AgentSessionPanel.swift +++ b/Sources/Panels/AgentSessionPanel.swift @@ -87,3 +87,38 @@ final class AgentSessionPanel: Panel { _ = reason } } + + +// MARK: - Find support + +extension AgentSessionPanel: FindablePanel { + /// Shows the web view's find panel. + @discardableResult + func startFind() -> Bool { + sendFindPanelAction(.showFindInterface) + } + + /// Jumps to the next match in the web view. + func findNext() { + _ = sendFindPanelAction(.nextMatch) + } + + /// Jumps to the previous match in the web view. + func findPrevious() { + _ = sendFindPanelAction(.previousMatch) + } + + /// Hiding the web view find panel is not supported by `WKWebView`. + func hideFind() {} + + /// Sends a find action to the agent session web view. + @discardableResult + private func sendFindPanelAction(_ action: NSTextFinder.Action) -> Bool { + guard let webView = rendererSession.webView else { return false } + return NSApp.sendAction( + NSSelectorFromString("performFindPanelAction:"), + to: webView, + from: action.menuItemSender + ) + } +} diff --git a/Sources/Panels/AgentSessionWebRendererSession.swift b/Sources/Panels/AgentSessionWebRendererSession.swift index b2796f3315..5652ec70ff 100644 --- a/Sources/Panels/AgentSessionWebRendererSession.swift +++ b/Sources/Panels/AgentSessionWebRendererSession.swift @@ -46,4 +46,9 @@ final class AgentSessionWebRendererSession { func close() { ownedCoordinator.close() } + + /// The underlying web view, exposed so find actions can be dispatched to it. + var webView: AgentSessionWebView? { + ownedCoordinator.webView + } } diff --git a/Sources/Panels/FilePreviewPanel.swift b/Sources/Panels/FilePreviewPanel.swift index 041763a380..7aa86b1759 100644 --- a/Sources/Panels/FilePreviewPanel.swift +++ b/Sources/Panels/FilePreviewPanel.swift @@ -4465,3 +4465,45 @@ private final class FilePreviewPointerObserverView: NSView { nil } } + + +// MARK: - Find in text mode + +extension FilePreviewPanel: FindablePanel { + /// Text-mode previews support "Use Selection for Find" when text is selected. + var hasSelectionForFind: Bool { + previewMode == .text && (textView?.selectedRange.length ?? 0) > 0 + } + + /// Shows the AppKit find bar when the panel is in text preview mode. + @discardableResult + func startFind() -> Bool { + guard previewMode == .text, let textView else { return false } + textView.performTextFinderAction(NSTextFinder.Action.showFindInterface.menuItemSender) + return true + } + + /// Jumps to the next match in the text preview. + func findNext() { + guard previewMode == .text, let textView else { return } + textView.performTextFinderAction(NSTextFinder.Action.nextMatch.menuItemSender) + } + + /// Jumps to the previous match in the text preview. + func findPrevious() { + guard previewMode == .text, let textView else { return } + textView.performTextFinderAction(NSTextFinder.Action.previousMatch.menuItemSender) + } + + /// Hides the AppKit find bar in the text preview. + func hideFind() { + guard previewMode == .text, let textView else { return } + textView.performTextFinderAction(NSTextFinder.Action.hideFindInterface.menuItemSender) + } + + /// Uses the current text selection as the find needle. + func useSelectionForFind() { + guard previewMode == .text, let textView else { return } + textView.performTextFinderAction(NSTextFinder.Action.setSearchString.menuItemSender) + } +} diff --git a/Sources/Panels/MarkdownPanel.swift b/Sources/Panels/MarkdownPanel.swift index caa05ba7b1..9b95a55a40 100644 --- a/Sources/Panels/MarkdownPanel.swift +++ b/Sources/Panels/MarkdownPanel.swift @@ -450,3 +450,74 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel } } } + + +// MARK: - Find in panel + +extension MarkdownPanel: FindablePanel { + /// Text-edit mode supports "Use Selection for Find" when text is selected. + var hasSelectionForFind: Bool { + displayMode == .text && (textView?.selectedRange.length ?? 0) > 0 + } + + /// Shows the find UI for the current display mode. + @discardableResult + func startFind() -> Bool { + switch displayMode { + case .text: + guard let textView else { return false } + textView.performTextFinderAction(NSTextFinder.Action.showFindInterface.menuItemSender) + return true + case .preview: + return sendFindPanelAction(.showFindInterface) + } + } + + /// Jumps to the next match in the current display mode. + func findNext() { + switch displayMode { + case .text: + textView?.performTextFinderAction(NSTextFinder.Action.nextMatch.menuItemSender) + case .preview: + _ = sendFindPanelAction(.nextMatch) + } + } + + /// Jumps to the previous match in the current display mode. + func findPrevious() { + switch displayMode { + case .text: + textView?.performTextFinderAction(NSTextFinder.Action.previousMatch.menuItemSender) + case .preview: + _ = sendFindPanelAction(.previousMatch) + } + } + + /// Hides the find UI. Preview mode is a no-op because `WKWebView` does not + /// expose a hide action (`NSFindPanelAction` only defines values 1-10). + func hideFind() { + switch displayMode { + case .text: + textView?.performTextFinderAction(NSTextFinder.Action.hideFindInterface.menuItemSender) + case .preview: + break + } + } + + /// Uses the current text selection as the find needle in text-edit mode. + func useSelectionForFind() { + guard displayMode == .text, let textView else { return } + textView.performTextFinderAction(NSTextFinder.Action.setSearchString.menuItemSender) + } + + /// Sends a find action to the preview `WKWebView`. + @discardableResult + private func sendFindPanelAction(_ action: NSTextFinder.Action) -> Bool { + guard let webView = rendererSession.webView else { return false } + return NSApp.sendAction( + NSSelectorFromString("performFindPanelAction:"), + to: webView, + from: action.menuItemSender + ) + } +} diff --git a/Sources/Panels/MarkdownWebSupport.swift b/Sources/Panels/MarkdownWebSupport.swift index 9c2a4dbca2..caf76a0c28 100644 --- a/Sources/Panels/MarkdownWebSupport.swift +++ b/Sources/Panels/MarkdownWebSupport.swift @@ -96,6 +96,11 @@ final class MarkdownRendererSession { func renderedText() async -> String? { await ownedCoordinator.renderedText() } + + /// The underlying web view, exposed so find actions can be dispatched to it. + var webView: MarkdownWebView? { + ownedCoordinator.webView + } } extension NSColor { diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index a344302d3e..de5e25b767 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -360,3 +360,49 @@ extension Panel { triggerFlash(reason: .navigation) } } + + +// MARK: - Find support + +/// Capability surfaced by panels that can participate in the global `Cmd+F` +/// find flow. `TabManager` routes find actions to the focused panel only if it +/// conforms to this protocol. +@MainActor +protocol FindablePanel: AnyObject { + /// Whether the panel has a text selection that can be used as the find needle. + var hasSelectionForFind: Bool { get } + + /// Opens the find UI. Returns `true` if the panel handled the request. + @discardableResult + func startFind() -> Bool + + /// Jumps to the next search result in the panel. + func findNext() + + /// Jumps to the previous search result in the panel. + func findPrevious() + + /// Closes or hides the panel's find UI. + func hideFind() + + /// Uses the current selection as the find needle (Cmd+E / "Use Selection for Find"). + func useSelectionForFind() +} + +extension FindablePanel { + /// Most panels do not support selection-based find. + var hasSelectionForFind: Bool { false } + + /// Most panels do not support seeding the find query from the current selection. + func useSelectionForFind() {} +} + +extension NSTextFinder.Action { + /// Returns an `NSMenuItem` whose tag matches this action, suitable for + /// sending to `performTextFinderAction(_:)` or `performFindPanelAction:`. + var menuItemSender: NSMenuItem { + let item = NSMenuItem() + item.tag = rawValue + return item + } +} diff --git a/Sources/Panels/ProjectBuildSettingsTabView.swift b/Sources/Panels/ProjectBuildSettingsTabView.swift index 254608b26d..1b78ed000a 100644 --- a/Sources/Panels/ProjectBuildSettingsTabView.swift +++ b/Sources/Panels/ProjectBuildSettingsTabView.swift @@ -12,6 +12,9 @@ struct ProjectBuildSettingsTabView: View { @ObservedObject var panel: ProjectPanel let model: ProjectModel + /// Drives focus into the settings filter text field when requested. + @FocusState private var focus: ProjectPanelSearchFocus? + var body: some View { let computedRows = rows VStack(alignment: .leading, spacing: 0) { @@ -21,6 +24,12 @@ struct ProjectBuildSettingsTabView: View { Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onChange(of: panel.focusState.request) { _, newValue in + if newValue == .settings { + focus = .settings + panel.focusState.request = nil + } + } } @ViewBuilder @@ -37,6 +46,7 @@ struct ProjectBuildSettingsTabView: View { TextField("Filter settings", text: $panel.settingsSearchText) .textFieldStyle(.plain) .font(.system(size: 12)) + .focused($focus, equals: .settings) Toggle("Customized only", isOn: $panel.settingsCustomizedOnly) .toggleStyle(.checkbox) .font(.system(size: 11)) diff --git a/Sources/Panels/ProjectFilesTabView.swift b/Sources/Panels/ProjectFilesTabView.swift index f38710e5e9..c0c9f2b6ca 100644 --- a/Sources/Panels/ProjectFilesTabView.swift +++ b/Sources/Panels/ProjectFilesTabView.swift @@ -19,6 +19,9 @@ struct ProjectFilesTabView: View { @ObservedObject var panel: ProjectPanel let model: ProjectModel + /// Drives focus into the files filter text field when requested. + @FocusState private var focus: ProjectPanelSearchFocus? + var body: some View { let rows = flattenedRows VStack(alignment: .leading, spacing: 0) { @@ -36,6 +39,12 @@ struct ProjectFilesTabView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } + .onChange(of: panel.focusState.request) { _, newValue in + if newValue == .files { + focus = .files + panel.focusState.request = nil + } + } } @ViewBuilder @@ -46,6 +55,7 @@ struct ProjectFilesTabView: View { TextField("Filter files (e.g. AppDelegate)", text: $panel.filesSearchText) .textFieldStyle(.plain) .font(.system(size: 12)) + .focused($focus, equals: .files) if !panel.filesSearchText.isEmpty { Button { panel.filesSearchText = "" diff --git a/Sources/Panels/ProjectPanel.swift b/Sources/Panels/ProjectPanel.swift index 40e7450cdf..12988c544a 100644 --- a/Sources/Panels/ProjectPanel.swift +++ b/Sources/Panels/ProjectPanel.swift @@ -2,6 +2,7 @@ import AppKit import CMUXProjectModel import Combine import Foundation +import Observation import SwiftUI /// Which tab is active inside a ``ProjectPanel``. @@ -47,6 +48,18 @@ public enum ProjectPanelLoadState: Sendable, Equatable { } } +/// Lightweight focus-request state for ``ProjectPanel``. +/// +/// Uses the modern `@Observable` shape so the view layer can react to focus +/// requests without adding another `@Published` property to the legacy +/// ``ObservableObject`` panel. +@MainActor +@Observable +public final class ProjectPanelFocusState { + /// Which search field should receive focus, or `nil` if none. + public var request: ProjectPanelSearchFocus? +} + /// Runtime backing for one `project` surface. /// /// Holds the user's project URL, the parsed ``ProjectModel`` snapshot (loaded @@ -71,6 +84,14 @@ public final class ProjectPanel: NSObject, Panel, ObservableObject { @Published public var collapsedNodeIDs: Set = [] @Published public var filesSearchText: String = "" @Published public var lastLoadError: String? + + /// Tracks which search field, if any, should receive focus. + /// + /// Lives outside the ``ObservableObject`` state so the modern `@Observable` + /// pattern owns the focus request rather than adding another `@Published` + /// property to the legacy panel object. + public let focusState = ProjectPanelFocusState() + private var reloadTask: Task? public var displayTitle: String { @@ -263,3 +284,38 @@ public final class ProjectPanel: NSObject, Panel, ObservableObject { return false } } + + +// MARK: - Find support + +/// Identifies which search field in a ``ProjectPanel`` should receive focus. +public enum ProjectPanelSearchFocus: Hashable { + case files + case settings +} + +extension ProjectPanel: FindablePanel { + /// Focuses the search field for tabs that have one. + @discardableResult + public func startFind() -> Bool { + switch activeTab { + case .files: + focusState.request = .files + return true + case .buildSettings: + focusState.request = .settings + return true + case .targets, .schemes: + return false + } + } + + /// Project search fields do not support find-next/find-previous navigation. + public func findNext() {} + public func findPrevious() {} + + /// Clears the focus request so the search field can resign focus normally. + public func hideFind() { + focusState.request = nil + } +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 0221887edc..f0a427cf37 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -798,11 +798,13 @@ class TabManager: ObservableObject { } var isFindVisible: Bool { - selectedTerminalPanel?.searchState != nil || focusedBrowserPanel?.searchState != nil + selectedTerminalPanel?.searchState != nil + || focusedBrowserPanel?.searchState != nil } var canUseSelectionForFind: Bool { selectedTerminalPanel?.hasSelection() == true + || focusedFindablePanel?.hasSelectionForFind == true } @discardableResult @@ -828,24 +830,26 @@ class TabManager: ObservableObject { #endif return handled } - guard let browserPanel = focusedBrowserPanel else { return false } - browserPanel.startFind() - return browserPanel.searchState != nil + return startFindInFocusedNonTerminalPanel() } func searchSelection() { - guard let panel = selectedTerminalPanel else { return } - if panel.searchState == nil { - panel.searchState = TerminalSurface.SearchState() - } + if let panel = selectedTerminalPanel { + if panel.searchState == nil { + panel.searchState = TerminalSurface.SearchState() + } #if DEBUG - cmuxDebugLog( - "find.searchSelection workspace=\(panel.workspaceId.uuidString.prefix(5)) " + - "panel=\(panel.id.uuidString.prefix(5))" - ) + cmuxDebugLog( + "find.searchSelection workspace=\(panel.workspaceId.uuidString.prefix(5)) " + + "panel=\(panel.id.uuidString.prefix(5))" + ) #endif - NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) - _ = panel.performBindingAction("search_selection") + NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) + _ = panel.performBindingAction("search_selection") + return + } + + focusedFindablePanel?.useSelectionForFind() } func findNext() { @@ -854,7 +858,7 @@ class TabManager: ObservableObject { return } - focusedBrowserPanel?.findNext() + findNextInFocusedNonTerminalPanel() } func findPrevious() { @@ -863,7 +867,7 @@ class TabManager: ObservableObject { return } - focusedBrowserPanel?.findPrevious() + findPreviousInFocusedNonTerminalPanel() } @discardableResult @@ -944,7 +948,7 @@ class TabManager: ObservableObject { return } - focusedBrowserPanel?.hideFind() + hideFindInFocusedNonTerminalPanel() } func makeWorkspaceForCreation( @@ -6114,3 +6118,41 @@ extension Notification.Name { enum BrowserFirstResponderNotificationUserInfoKey { static let pointerInitiated = "pointerInitiated" } + + +// MARK: - Find routing for non-terminal panels + +extension TabManager { + /// The focused panel if it supports global find commands. + var focusedFindablePanel: FindablePanel? { + guard let tab = selectedWorkspace, let panelId = tab.focusedPanelId else { return nil } + return tab.panels[panelId] as? FindablePanel + } + + /// Opens find in the focused browser or findable panel. + func startFindInFocusedNonTerminalPanel() -> Bool { + if let browserPanel = focusedBrowserPanel { + browserPanel.startFind() + return browserPanel.searchState != nil + } + return focusedFindablePanel?.startFind() ?? false + } + + /// Navigates to the next find result in the focused browser or findable panel. + func findNextInFocusedNonTerminalPanel() { + focusedBrowserPanel?.findNext() + focusedFindablePanel?.findNext() + } + + /// Navigates to the previous find result in the focused browser or findable panel. + func findPreviousInFocusedNonTerminalPanel() { + focusedBrowserPanel?.findPrevious() + focusedFindablePanel?.findPrevious() + } + + /// Hides find UI in the focused browser or findable panel. + func hideFindInFocusedNonTerminalPanel() { + focusedBrowserPanel?.hideFind() + focusedFindablePanel?.hideFind() + } +}