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:
parent
5baf0d1a3b
commit
76bdf7631a
8 changed files with 673 additions and 2 deletions
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
207
Sources/Find/BrowserFindJavaScript.swift
Normal file
207
Sources/Find/BrowserFindJavaScript.swift
Normal 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
|
||||
}
|
||||
}
|
||||
183
Sources/Find/BrowserSearchOverlay.swift
Normal file
183
Sources/Find/BrowserSearchOverlay.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
116
cmuxTests/BrowserFindJavaScriptTests.swift
Normal file
116
cmuxTests/BrowserFindJavaScriptTests.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue