From 76bdf7631af72f7525232afdde25817dbdb9ade4 Mon Sep 17 00:00:00 2001 From: Yoshiki Agatsuma Date: Thu, 5 Mar 2026 09:15:15 +0900 Subject: [PATCH] Add find-in-page (Cmd+F) for browser panels (#837) (#875) JavaScript-based find using TreeWalker + highlights with match counter, next/previous navigation, and drag-to-corner overlay matching the existing terminal find bar. - BrowserFindJavaScript: JS generation for search/next/prev/clear - BrowserSearchOverlay: SwiftUI overlay with IME-safe onSubmit - BrowserSearchState: Observable state (needle/selected/total) - TabManager routing: Cmd+F/G dispatches to browser when focused - Visibility filter: skips script/style/hidden/aria-hidden elements - Stale DOM guard: isConnected check in next/previous scripts - Navigation cleanup: clears find on didFinish and didFailNavigation Co-authored-by: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> --- GhosttyTabs.xcodeproj/project.pbxproj | 12 ++ Sources/Find/BrowserFindJavaScript.swift | 207 +++++++++++++++++++++ Sources/Find/BrowserSearchOverlay.swift | 183 ++++++++++++++++++ Sources/GhosttyTerminalView.swift | 1 + Sources/Panels/BrowserPanel.swift | 122 ++++++++++++ Sources/Panels/BrowserPanelView.swift | 11 ++ Sources/TabManager.swift | 23 ++- cmuxTests/BrowserFindJavaScriptTests.swift | 116 ++++++++++++ 8 files changed, 673 insertions(+), 2 deletions(-) create mode 100644 Sources/Find/BrowserFindJavaScript.swift create mode 100644 Sources/Find/BrowserSearchOverlay.swift create mode 100644 cmuxTests/BrowserFindJavaScriptTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 532ab71b..56f76d80 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -38,6 +38,8 @@ B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; }; A5001270 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = A5001271 /* PostHog */; }; A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; }; + A5008371 /* BrowserSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008370 /* BrowserSearchOverlay.swift */; }; + A5008373 /* BrowserFindJavaScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008372 /* BrowserFindJavaScript.swift */; }; A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; }; A50012F3 /* KeyboardShortcutSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F2 /* KeyboardShortcutSettings.swift */; }; A50012F5 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F4 /* KeyboardLayout.swift */; }; @@ -84,6 +86,7 @@ F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; + A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; }; DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; }; DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; }; /* End PBXBuildFile section */ @@ -173,6 +176,8 @@ A5001091 /* NotificationsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPage.swift; sourceTree = ""; }; A5001092 /* TerminalNotificationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationStore.swift; sourceTree = ""; }; A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = ""; }; + A5008370 /* BrowserSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserSearchOverlay.swift; sourceTree = ""; }; + A5008372 /* BrowserFindJavaScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserFindJavaScript.swift; sourceTree = ""; }; A50012F0 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; A50012F2 /* KeyboardShortcutSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettings.swift; sourceTree = ""; }; A50012F4 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = ""; }; @@ -217,6 +222,7 @@ F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; }; F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; + A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; }; DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; /* End PBXFileReference section */ @@ -350,6 +356,8 @@ A5001091 /* NotificationsPage.swift */, A5001092 /* TerminalNotificationStore.swift */, A5001301 /* SurfaceSearchOverlay.swift */, + A5008370 /* BrowserSearchOverlay.swift */, + A5008372 /* BrowserFindJavaScript.swift */, A5001410 /* Panel.swift */, A5001411 /* TerminalPanel.swift */, A5001412 /* BrowserPanel.swift */, @@ -436,6 +444,7 @@ F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, + A5008380 /* BrowserFindJavaScriptTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -592,6 +601,8 @@ A5001094 /* NotificationsPage.swift in Sources */, A5001095 /* TerminalNotificationStore.swift in Sources */, A5001303 /* SurfaceSearchOverlay.swift in Sources */, + A5008371 /* BrowserSearchOverlay.swift in Sources */, + A5008373 /* BrowserFindJavaScript.swift in Sources */, A5001400 /* Panel.swift in Sources */, A5001401 /* TerminalPanel.swift in Sources */, A5001402 /* BrowserPanel.swift in Sources */, @@ -647,6 +658,7 @@ F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, + A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/Find/BrowserFindJavaScript.swift b/Sources/Find/BrowserFindJavaScript.swift new file mode 100644 index 00000000..c664bdc6 --- /dev/null +++ b/Sources/Find/BrowserFindJavaScript.swift @@ -0,0 +1,207 @@ +import Foundation + +/// JavaScript snippets for find-in-page in WKWebView. +/// +/// Uses TreeWalker to scan text nodes and wraps matches with `` elements. +/// The current match gets an additional `.current` class and is scrolled into view. +enum BrowserFindJavaScript { + + // MARK: - Public API + + /// Returns JS that highlights all occurrences of `query` in the document body. + /// The script evaluates to a JSON string `{"total":N,"current":0}`. + static func searchScript(query: String) -> String { + let escaped = jsStringEscape(query) + return """ + (() => { + const MARK_CLASS = '__cmux-find'; + const CURRENT_CLASS = '__cmux-find-current'; + + // Remove previous highlights first. + \(clearBody) + + const query = "\(escaped)"; + if (!query) return JSON.stringify({total: 0, current: 0}); + + const lowerQuery = query.toLowerCase(); + const SKIP_TAGS = new Set(['SCRIPT','STYLE','NOSCRIPT','TEMPLATE','IFRAME','SVG']); + const isVisible = (el) => { + while (el && el !== document.body) { + if (SKIP_TAGS.has(el.tagName)) return false; + if (el.getAttribute('aria-hidden') === 'true') return false; + const st = getComputedStyle(el); + if (st.display === 'none' || st.visibility === 'hidden') return false; + el = el.parentElement; + } + return true; + }; + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_TEXT, + { acceptNode(node) { return isVisible(node.parentElement) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } } + ); + const matches = []; + const textNodes = []; + while (walker.nextNode()) textNodes.push(walker.currentNode); + + for (const node of textNodes) { + const text = node.textContent || ''; + const lowerText = text.toLowerCase(); + let startIndex = 0; + const parts = []; + let lastEnd = 0; + while (true) { + const idx = lowerText.indexOf(lowerQuery, startIndex); + if (idx === -1) break; + parts.push({ start: idx, end: idx + query.length }); + startIndex = idx + query.length; + } + if (parts.length === 0) continue; + + const parent = node.parentNode; + if (!parent) continue; + const frag = document.createDocumentFragment(); + let pos = 0; + for (const part of parts) { + if (part.start > pos) { + frag.appendChild(document.createTextNode(text.substring(pos, part.start))); + } + const mark = document.createElement('mark'); + mark.className = MARK_CLASS; + mark.textContent = text.substring(part.start, part.end); + frag.appendChild(mark); + matches.push(mark); + pos = part.end; + } + if (pos < text.length) { + frag.appendChild(document.createTextNode(text.substring(pos))); + } + parent.replaceChild(frag, node); + } + + window.__cmuxFindMatches = matches; + window.__cmuxFindIndex = 0; + + if (matches.length > 0) { + matches[0].classList.add(CURRENT_CLASS); + matches[0].scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + + // Inject highlight styles if not already present. + if (!document.getElementById('__cmux-find-style')) { + const style = document.createElement('style'); + style.id = '__cmux-find-style'; + style.textContent = ` + mark.__cmux-find { background: #facc15; color: #000; border-radius: 2px; } + mark.__cmux-find.__cmux-find-current { background: #f97316; color: #fff; } + `; + document.head.appendChild(style); + } + + return JSON.stringify({ total: matches.length, current: 0 }); + })() + """ + } + + /// Returns JS that moves to the next match. Evaluates to `{"total":N,"current":M}`. + static func nextScript() -> String { + """ + (() => { + const matches = window.__cmuxFindMatches || []; + if (matches.length === 0) return JSON.stringify({ total: 0, current: 0 }); + let idx = window.__cmuxFindIndex || 0; + if (!matches[idx] || !matches[idx].isConnected) { + window.__cmuxFindMatches = []; + window.__cmuxFindIndex = 0; + return JSON.stringify({ total: 0, current: 0 }); + } + matches[idx].classList.remove('__cmux-find-current'); + idx = (idx + 1) % matches.length; + if (!matches[idx] || !matches[idx].isConnected) { + window.__cmuxFindMatches = []; + window.__cmuxFindIndex = 0; + return JSON.stringify({ total: 0, current: 0 }); + } + matches[idx].classList.add('__cmux-find-current'); + matches[idx].scrollIntoView({ block: 'center', behavior: 'smooth' }); + window.__cmuxFindIndex = idx; + return JSON.stringify({ total: matches.length, current: idx }); + })() + """ + } + + /// Returns JS that moves to the previous match. Evaluates to `{"total":N,"current":M}`. + static func previousScript() -> String { + """ + (() => { + const matches = window.__cmuxFindMatches || []; + if (matches.length === 0) return JSON.stringify({ total: 0, current: 0 }); + let idx = window.__cmuxFindIndex || 0; + if (!matches[idx] || !matches[idx].isConnected) { + window.__cmuxFindMatches = []; + window.__cmuxFindIndex = 0; + return JSON.stringify({ total: 0, current: 0 }); + } + matches[idx].classList.remove('__cmux-find-current'); + idx = (idx - 1 + matches.length) % matches.length; + if (!matches[idx] || !matches[idx].isConnected) { + window.__cmuxFindMatches = []; + window.__cmuxFindIndex = 0; + return JSON.stringify({ total: 0, current: 0 }); + } + matches[idx].classList.add('__cmux-find-current'); + matches[idx].scrollIntoView({ block: 'center', behavior: 'smooth' }); + window.__cmuxFindIndex = idx; + return JSON.stringify({ total: matches.length, current: idx }); + })() + """ + } + + /// Returns JS that removes all find highlights and restores the DOM. + static func clearScript() -> String { + """ + (() => { + \(clearBody) + window.__cmuxFindMatches = []; + window.__cmuxFindIndex = 0; + const style = document.getElementById('__cmux-find-style'); + if (style) style.remove(); + return 'ok'; + })() + """ + } + + // MARK: - Internal + + /// JS snippet (no wrapping IIFE) that removes existing mark highlights. + private static let clearBody = """ + document.querySelectorAll('mark.__cmux-find').forEach(mark => { + const parent = mark.parentNode; + if (!parent) return; + const text = document.createTextNode(mark.textContent || ''); + parent.replaceChild(text, mark); + parent.normalize(); + }); + """ + + /// Escape a Swift string for safe embedding inside a JS double-quoted string literal. + static func jsStringEscape(_ string: String) -> String { + var result = "" + result.reserveCapacity(string.count) + for scalar in string.unicodeScalars { + switch scalar { + case "\\": result += "\\\\" + case "\"": result += "\\\"" + case "\n": result += "\\n" + case "\r": result += "\\r" + case "\t": result += "\\t" + case "\0": result += "\\0" + case "\u{2028}": result += "\\u2028" + case "\u{2029}": result += "\\u2029" + default: + result.append(Character(scalar)) + } + } + return result + } +} diff --git a/Sources/Find/BrowserSearchOverlay.swift b/Sources/Find/BrowserSearchOverlay.swift new file mode 100644 index 00000000..635aecdb --- /dev/null +++ b/Sources/Find/BrowserSearchOverlay.swift @@ -0,0 +1,183 @@ +import Bonsplit +import SwiftUI + +struct BrowserSearchOverlay: View { + let panelId: UUID + @ObservedObject var searchState: BrowserSearchState + let onNext: () -> Void + let onPrevious: () -> Void + let onClose: () -> Void + @State private var corner: Corner = .topRight + @State private var dragOffset: CGSize = .zero + @State private var barSize: CGSize = .zero + @FocusState private var isSearchFieldFocused: Bool + + private let padding: CGFloat = 8 + + var body: some View { + GeometryReader { geo in + HStack(spacing: 4) { + TextField("Search", text: $searchState.needle) + .textFieldStyle(.plain) + .frame(width: 180) + .padding(.leading, 8) + .padding(.trailing, 50) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.1)) + .cornerRadius(6) + .focused($isSearchFieldFocused) + .overlay(alignment: .trailing) { + if let selected = searchState.selected { + let totalText = searchState.total.map { String($0) } ?? "?" + Text("\(selected + 1)/\(totalText)") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + .padding(.trailing, 8) + } else if let total = searchState.total { + Text(total == 0 ? "0/0" : "-/\(total)") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + .padding(.trailing, 8) + } + } + .onExitCommand { + onClose() + } + .onSubmit { + // onSubmit fires only after IME composition is committed. + if NSEvent.modifierFlags.contains(.shift) { + onPrevious() + } else { + onNext() + } + } + + Button(action: { + #if DEBUG + dlog("browser.findbar.next panel=\(panelId.uuidString.prefix(5))") + #endif + onNext() + }) { + Image(systemName: "chevron.up") + } + .buttonStyle(SearchButtonStyle()) + .help("Next match (Return)") + + Button(action: { + #if DEBUG + dlog("browser.findbar.prev panel=\(panelId.uuidString.prefix(5))") + #endif + onPrevious() + }) { + Image(systemName: "chevron.down") + } + .buttonStyle(SearchButtonStyle()) + .help("Previous match (Shift+Return)") + + Button(action: { + #if DEBUG + dlog("browser.findbar.close panel=\(panelId.uuidString.prefix(5))") + #endif + onClose() + }) { + Image(systemName: "xmark") + } + .buttonStyle(SearchButtonStyle()) + .help("Close (Esc)") + } + .padding(8) + .background(.background) + .clipShape(clipShape) + .shadow(radius: 4) + .onAppear { + #if DEBUG + dlog("browser.findbar.appear panel=\(panelId.uuidString.prefix(5))") + #endif + isSearchFieldFocused = true + } + .onReceive(NotificationCenter.default.publisher(for: .browserSearchFocus)) { notification in + guard let notifiedPanelId = notification.object as? UUID, + notifiedPanelId == panelId else { return } + DispatchQueue.main.async { + isSearchFieldFocused = true + } + } + .background( + GeometryReader { barGeo in + Color.clear.onAppear { + barSize = barGeo.size + } + } + ) + .padding(padding) + .offset(dragOffset) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: corner.alignment) + .gesture( + DragGesture() + .onChanged { value in + dragOffset = value.translation + } + .onEnded { value in + let centerPos = centerPosition(for: corner, in: geo.size, barSize: barSize) + let newCenter = CGPoint( + x: centerPos.x + value.translation.width, + y: centerPos.y + value.translation.height + ) + let newCorner = closestCorner(to: newCenter, in: geo.size) + withAnimation(.easeOut(duration: 0.2)) { + corner = newCorner + dragOffset = .zero + } + } + ) + } + } + + private var clipShape: some Shape { + RoundedRectangle(cornerRadius: 8) + } + + enum Corner { + case topLeft + case topRight + case bottomLeft + case bottomRight + + var alignment: Alignment { + switch self { + case .topLeft: return .topLeading + case .topRight: return .topTrailing + case .bottomLeft: return .bottomLeading + case .bottomRight: return .bottomTrailing + } + } + } + + private func centerPosition(for corner: Corner, in containerSize: CGSize, barSize: CGSize) -> CGPoint { + let halfWidth = barSize.width / 2 + padding + let halfHeight = barSize.height / 2 + padding + + switch corner { + case .topLeft: + return CGPoint(x: halfWidth, y: halfHeight) + case .topRight: + return CGPoint(x: containerSize.width - halfWidth, y: halfHeight) + case .bottomLeft: + return CGPoint(x: halfWidth, y: containerSize.height - halfHeight) + case .bottomRight: + return CGPoint(x: containerSize.width - halfWidth, y: containerSize.height - halfHeight) + } + } + + private func closestCorner(to point: CGPoint, in containerSize: CGSize) -> Corner { + let midX = containerSize.width / 2 + let midY = containerSize.height / 2 + + if point.x < midX { + return point.y < midY ? .topLeft : .bottomLeft + } + return point.y < midY ? .topRight : .bottomRight + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 5694c57b..2d5c5ecb 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4673,6 +4673,7 @@ extension Notification.Name { static let ghosttySearchFocus = Notification.Name("ghosttySearchFocus") static let ghosttyConfigDidReload = Notification.Name("ghosttyConfigDidReload") static let ghosttyDefaultBackgroundDidChange = Notification.Name("ghosttyDefaultBackgroundDidChange") + static let browserSearchFocus = Notification.Name("browserSearchFocus") } // MARK: - Scroll View Wrapper (Ghostty-style scrollbar) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 00221bb9..b39e5fad 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1232,6 +1232,18 @@ private enum BrowserInsecureHTTPNavigationIntent { case newTab } +/// Observable state for browser find-in-page. Mirrors `TerminalSurface.SearchState`. +@MainActor +final class BrowserSearchState: ObservableObject { + @Published var needle: String + @Published var selected: UInt? + @Published var total: UInt? + + init(needle: String = "") { + self.needle = needle + } +} + @MainActor final class BrowserPanel: Panel, ObservableObject { /// Shared process pool for cookie sharing across all browser panels @@ -1436,6 +1448,36 @@ final class BrowserPanel: Panel, ObservableObject { /// cleared only after BrowserPanelView acknowledges handling it. @Published private(set) var pendingAddressBarFocusRequestId: UUID? + /// Find-in-page state. Non-nil when the find bar is visible. + @Published var searchState: BrowserSearchState? = nil { + didSet { + if let searchState { + NSLog("Find: browser search state created panel=%@", id.uuidString) + searchNeedleCancellable = searchState.$needle + .removeDuplicates() + .map { needle -> AnyPublisher in + if needle.isEmpty || needle.count >= 3 { + return Just(needle).eraseToAnyPublisher() + } + return Just(needle) + .delay(for: .milliseconds(300), scheduler: DispatchQueue.main) + .eraseToAnyPublisher() + } + .switchToLatest() + .sink { [weak self] needle in + guard let self else { return } + NSLog("Find: browser needle updated panel=%@ needle=%@", self.id.uuidString, needle) + self.executeFindSearch(needle) + } + } else if oldValue != nil { + searchNeedleCancellable = nil + NSLog("Find: browser search state cleared panel=%@", id.uuidString) + executeFindClear() + } + } + } + private var searchNeedleCancellable: AnyCancellable? + private var cancellables = Set() private var navigationDelegate: BrowserNavigationDelegate? private var uiDelegate: BrowserUIDelegate? @@ -1541,6 +1583,10 @@ final class BrowserPanel: Panel, ObservableObject { Task { @MainActor [weak self] in self?.refreshFavicon(from: webView) self?.applyBrowserThemeModeIfNeeded() + // Clear find-in-page on navigation so stale highlights don't persist. + if self?.searchState != nil { + self?.searchState = nil + } } } navDelegate.didFailNavigation = { [weak self] _, failedURL in @@ -1551,6 +1597,10 @@ final class BrowserPanel: Panel, ObservableObject { self.pageTitle = failedURL.isEmpty ? "" : failedURL self.faviconPNGData = nil self.lastFaviconURLString = nil + // Clear find-in-page so stale highlights don't persist. + if self.searchState != nil { + self.searchState = nil + } } } navDelegate.openInNewTab = { [weak self] url in @@ -2502,6 +2552,78 @@ extension BrowserPanel { try await webView.evaluateJavaScript(script) } + // MARK: - Find in Page + + func startFind() { + if searchState == nil { + searchState = BrowserSearchState() + } + NotificationCenter.default.post(name: .browserSearchFocus, object: id) + } + + func findNext() { + Task { @MainActor [weak self] in + guard let self else { return } + let result = try? await self.webView.evaluateJavaScript(BrowserFindJavaScript.nextScript()) + self.parseFindResult(result) + } + } + + func findPrevious() { + Task { @MainActor [weak self] in + guard let self else { return } + let result = try? await self.webView.evaluateJavaScript(BrowserFindJavaScript.previousScript()) + self.parseFindResult(result) + } + } + + func hideFind() { + searchState = nil + } + + private func executeFindSearch(_ needle: String) { + guard !needle.isEmpty else { + executeFindClear() + searchState?.selected = nil + searchState?.total = nil + return + } + Task { @MainActor [weak self] in + guard let self else { return } + let js = BrowserFindJavaScript.searchScript(query: needle) + do { + let result = try await self.webView.evaluateJavaScript(js) + self.parseFindResult(result) + } catch { + NSLog("Find: browser JS search error: %@", error.localizedDescription) + } + } + } + + private func executeFindClear() { + Task { @MainActor [weak self] in + guard let self else { return } + do { + _ = try await self.webView.evaluateJavaScript(BrowserFindJavaScript.clearScript()) + } catch { + NSLog("Find: browser JS clear error: %@", error.localizedDescription) + } + } + } + + private func parseFindResult(_ result: Any?) { + guard let jsonString = result as? String, + let data = jsonString.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let total = json["total"] as? Int, + let current = json["current"] as? Int, + total >= 0, current >= 0 else { + return + } + searchState?.total = UInt(total) + searchState?.selected = total > 0 ? UInt(current) : nil + } + func setBrowserThemeMode(_ mode: BrowserThemeMode) { browserThemeMode = mode applyBrowserThemeModeIfNeeded() diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index eae96a00..dc4856b8 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -316,6 +316,17 @@ struct BrowserPanelView: View { .padding(FocusFlashPattern.ringInset) .allowsHitTesting(false) } + .overlay { + if let searchState = panel.searchState { + BrowserSearchOverlay( + panelId: panel.id, + searchState: searchState, + onNext: { panel.findNext() }, + onPrevious: { panel.findPrevious() }, + onClose: { panel.hideFind() } + ) + } + } .overlay(alignment: .topLeading) { if addressBarFocused, !omnibarState.suggestions.isEmpty, omnibarPillFrame.width > 0 { OmnibarSuggestionsView( diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index b89a4d84..5ff9c992 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -718,14 +718,21 @@ class TabManager: ObservableObject { } var isFindVisible: Bool { - selectedTerminalPanel?.searchState != nil + if selectedTerminalPanel?.searchState != nil { return true } + if focusedBrowserPanel?.searchState != nil { return true } + return false } var canUseSelectionForFind: Bool { - selectedTerminalPanel?.hasSelection() == true + if focusedBrowserPanel != nil { return false } + return selectedTerminalPanel?.hasSelection() == true } func startSearch() { + if let browser = focusedBrowserPanel { + browser.startFind() + return + } guard let panel = selectedTerminalPanel else { #if DEBUG dlog("find.startSearch SKIPPED no selectedTerminalPanel") @@ -756,10 +763,18 @@ class TabManager: ObservableObject { } func findNext() { + if let browser = focusedBrowserPanel, browser.searchState != nil { + browser.findNext() + return + } _ = selectedTerminalPanel?.performBindingAction("search:next") } func findPrevious() { + if let browser = focusedBrowserPanel, browser.searchState != nil { + browser.findPrevious() + return + } _ = selectedTerminalPanel?.performBindingAction("search:previous") } @@ -770,6 +785,10 @@ class TabManager: ObservableObject { } func hideFind() { + if let browser = focusedBrowserPanel, browser.searchState != nil { + browser.hideFind() + return + } #if DEBUG dlog("find.hideFind panel=\(selectedTerminalPanel?.id.uuidString.prefix(5) ?? "nil")") #endif diff --git a/cmuxTests/BrowserFindJavaScriptTests.swift b/cmuxTests/BrowserFindJavaScriptTests.swift new file mode 100644 index 00000000..4de1cfb4 --- /dev/null +++ b/cmuxTests/BrowserFindJavaScriptTests.swift @@ -0,0 +1,116 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class BrowserFindJavaScriptTests: XCTestCase { + + // MARK: - searchScript + + func testSearchScriptReturnsNonEmptyJavaScript() { + let js = BrowserFindJavaScript.searchScript(query: "hello") + XCTAssertFalse(js.isEmpty) + XCTAssertTrue(js.contains("hello")) + } + + func testSearchScriptEmptyQueryReturnsEarlyReturn() { + let js = BrowserFindJavaScript.searchScript(query: "") + XCTAssertTrue(js.contains("total: 0")) + } + + // MARK: - nextScript / previousScript + + func testNextScriptReturnsValidJavaScript() { + let js = BrowserFindJavaScript.nextScript() + XCTAssertFalse(js.isEmpty) + XCTAssertTrue(js.contains("__cmuxFindMatches")) + } + + func testPreviousScriptReturnsValidJavaScript() { + let js = BrowserFindJavaScript.previousScript() + XCTAssertFalse(js.isEmpty) + XCTAssertTrue(js.contains("__cmuxFindMatches")) + } + + // MARK: - clearScript + + func testClearScriptReturnsValidJavaScript() { + let js = BrowserFindJavaScript.clearScript() + XCTAssertFalse(js.isEmpty) + XCTAssertTrue(js.contains("__cmux-find")) + } + + // MARK: - jsStringEscape + + func testEscapesDoubleQuotes() { + let result = BrowserFindJavaScript.jsStringEscape(#"say "hello""#) + XCTAssertEqual(result, #"say \"hello\""#) + } + + func testEscapesBackslashes() { + let result = BrowserFindJavaScript.jsStringEscape(#"path\to\file"#) + XCTAssertEqual(result, #"path\\to\\file"#) + } + + func testEscapesNewlines() { + let result = BrowserFindJavaScript.jsStringEscape("line1\nline2") + XCTAssertEqual(result, "line1\\nline2") + } + + func testEscapesCarriageReturns() { + let result = BrowserFindJavaScript.jsStringEscape("line1\rline2") + XCTAssertEqual(result, "line1\\rline2") + } + + func testEscapesTabs() { + let result = BrowserFindJavaScript.jsStringEscape("col1\tcol2") + XCTAssertEqual(result, "col1\\tcol2") + } + + func testPlainTextPassesThrough() { + let result = BrowserFindJavaScript.jsStringEscape("hello world 123") + XCTAssertEqual(result, "hello world 123") + } + + func testJapaneseTextPassesThrough() { + let result = BrowserFindJavaScript.jsStringEscape("こんにちは") + XCTAssertEqual(result, "こんにちは") + } + + func testMixedSpecialCharacters() { + let result = BrowserFindJavaScript.jsStringEscape(#"a\"b\nc"#) + XCTAssertEqual(result, #"a\\\"b\\nc"#) + } + + func testEscapesNullByte() { + let result = BrowserFindJavaScript.jsStringEscape("a\0b") + XCTAssertEqual(result, "a\\0b") + } + + func testEscapesLineSeparator() { + let result = BrowserFindJavaScript.jsStringEscape("a\u{2028}b") + XCTAssertEqual(result, "a\\u2028b") + } + + func testEscapesParagraphSeparator() { + let result = BrowserFindJavaScript.jsStringEscape("a\u{2029}b") + XCTAssertEqual(result, "a\\u2029b") + } + + // MARK: - searchScript escaping integration + + func testSearchScriptEscapesQueryInOutput() { + let js = BrowserFindJavaScript.searchScript(query: #"test"injection"#) + // The double quote should be escaped, not breaking the JS string literal. + XCTAssertTrue(js.contains(#"test\"injection"#)) + XCTAssertFalse(js.contains(#"test"injection"#)) + } + + func testSearchScriptHandlesLineSeparator() { + let js = BrowserFindJavaScript.searchScript(query: "test\u{2028}break") + XCTAssertTrue(js.contains("\\u2028")) + } +}