Add find-in-page (Cmd+F) for browser panels (#837) (#875)

JavaScript-based find using TreeWalker + <mark> 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>
This commit is contained in:
Yoshiki Agatsuma 2026-03-05 09:15:15 +09:00 committed by GitHub
parent 5baf0d1a3b
commit 76bdf7631a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 673 additions and 2 deletions

View file

@ -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 = "<group>"; };
A5001092 /* TerminalNotificationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationStore.swift; sourceTree = "<group>"; };
A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = "<group>"; };
A5008370 /* BrowserSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserSearchOverlay.swift; sourceTree = "<group>"; };
A5008372 /* BrowserFindJavaScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserFindJavaScript.swift; sourceTree = "<group>"; };
A50012F0 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; };
A50012F2 /* KeyboardShortcutSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettings.swift; sourceTree = "<group>"; };
A50012F4 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = "<group>"; };
@ -217,6 +222,7 @@
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; };
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = "<group>"; };
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
/* 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 = "<group>";
@ -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;
};

View file

@ -0,0 +1,207 @@
import Foundation
/// JavaScript snippets for find-in-page in WKWebView.
///
/// Uses TreeWalker to scan text nodes and wraps matches with `<mark>` 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
}
}

View file

@ -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
}
}

View file

@ -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)

View file

@ -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<String, Never> 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<AnyCancellable>()
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()

View file

@ -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(

View file

@ -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

View file

@ -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"))
}
}