Find in page: the shell's find bar per pane (Ctrl+F, the app menu, the palette) over the engine's own find

Host: `tab_find {id, action, query}` (shell-only; start, next, previous, stop). On Windows it drives WebView2's own find (`ICoreWebView2Find` from `ICoreWebView2_28`, find options from `ICoreWebView2Environment15`; its built-in find bar suppressed, all matches highlighted) and reports the engine's match count and active match to the shell as `tab-find` events. On macOS, Linux and an older WebView2 Runtime native code evaluates the engine's `window.find` in the tab instead (next and previous work, no count); WKWebView's and WebKitGTK's native find are not wired here because this tree cannot be compiled or run for them on this machine. The query is one typed line (1..=256 characters, no control characters), goes to the engine and is kept nowhere. Tabs gain no command: a tab forwards the existing key name `find` (Ctrl+F) through tab_shortcut.

Frame: FindBar.tsx takes the address field's place in its pane's toolbar while open, so the page does not move and in split view the bar is the searched pane's. "3/17" where the engine counts, "No matches" for none; Enter / F3 next, Shift+Enter / Shift+F3 previous, Esc closes, clears the highlights and gives the page the keyboard. `features.find` (on by default) gates the menu row, the palette entry and the keys. Only what the page has rendered can be found, as in a browser.

Also: this makes `pub mod find` in lib.rs (already in HEAD) resolve again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-09-30 22:34:48 +07:00
parent 832396801c
commit b31fa6b1d2
17 changed files with 449 additions and 19 deletions

View file

@ -0,0 +1,143 @@
//! Find in page. The shell owns the find bar; a tab's page is searched natively through the
//! shell-only `tab_find {id, action, query}`: `start` (a new query), `next`, `previous`, `stop`
//! (the bar closed: highlights go).
//!
//! The engine's own find is used where this build can drive it: WebView2's `ICoreWebView2Find`
//! (Windows, WebView2 Runtime with `ICoreWebView2_28`; its own find bar suppressed), which also
//! reports the match count and the active match (`tab-find` events to the shell). Everywhere else
//! (macOS, Linux, an older WebView2 Runtime) native code evaluates [`fallback_script`] in the tab:
//! the engine's `window.find`, which selects the next match and scrolls to it but counts nothing.
//!
//! The query is data: it goes from the shell to the engine and is kept nowhere (no file, no log,
//! no state in this crate). Tabs gain no command; a tab may only forward the key name `find`
//! through `tab_shortcut`. Only what the page has rendered can be found, as in a browser.
use serde::Serialize;
/// The shell event with the engine's counts for a tab's find session.
pub const EVENT_TAB_FIND: &str = "tab-find";
/// A query is one line of text a person typed.
pub const MAX_QUERY: usize = 256;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FindAction {
Start,
Next,
Previous,
Stop,
}
impl FindAction {
pub fn parse(action: &str) -> Result<Self, String> {
match action {
"start" => Ok(Self::Start),
"next" => Ok(Self::Next),
"previous" => Ok(Self::Previous),
"stop" => Ok(Self::Stop),
_ => Err("VALIDATION: unknown find action".into()),
}
}
}
/// What the engine reported for a tab's find session: how many matches and which one is active
/// (0-based; -1 for none).
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct FindState {
pub id: String,
pub count: i32,
pub index: i32,
}
/// A request as native code runs it: `start` carries the query, the others none.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FindRequest {
pub action: FindAction,
pub query: Option<String>,
}
/// Validates a `tab_find` call: a known action; for `start` a query of 1..=256 characters with no
/// control characters (one typed line). `next`/`previous` repeat the last query the shell sent for
/// the fallback (the engine's own find keeps its session), so they may carry it too.
pub fn request(action: &str, query: Option<&str>) -> Result<FindRequest, String> {
let action = FindAction::parse(action)?;
let query = match query {
Some(text) => {
let count = text.chars().count();
if count == 0 || count > MAX_QUERY || text.chars().any(char::is_control) {
return Err("VALIDATION: invalid find query".into());
}
Some(text.to_owned())
}
None => None,
};
if action == FindAction::Start && query.is_none() {
return Err("VALIDATION: find needs a query".into());
}
Ok(FindRequest { action, query: if action == FindAction::Stop { None } else { query } })
}
/// The script native code evaluates where the engine's find cannot be driven natively: the
/// engine's `window.find` (case-insensitive, wrapping), or clearing the selection for `stop`.
/// The query appears only as a JSON string literal; nothing is sent anywhere or stored in the page.
pub fn fallback_script(request: &FindRequest) -> String {
match (&request.action, &request.query) {
(FindAction::Stop, _) | (_, None) => {
"(()=>{try{const s=getSelection();if(s)s.removeAllRanges();}catch(e){}})();".to_owned()
}
(action, Some(query)) => {
let text = serde_json::to_string(query).unwrap_or_else(|_| "\"\"".into());
// A new query starts from the top of the page; next/previous continue from the selection.
let restart = if *action == FindAction::Start { "const s=getSelection();if(s)s.removeAllRanges();" } else { "" };
let backwards = *action == FindAction::Previous;
format!("(()=>{{try{{{restart}if(typeof window.find==='function')window.find({text},false,{backwards},true,false,false,false);}}catch(e){{}}}})();")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_request_is_a_known_action_and_one_typed_line() {
assert_eq!(request("start", Some("tauri")).unwrap(), FindRequest { action: FindAction::Start, query: Some("tauri".into()) });
assert_eq!(request("next", None).unwrap().action, FindAction::Next);
assert_eq!(request("previous", Some("x")).unwrap().query.as_deref(), Some("x"));
assert_eq!(request("stop", Some("left over")).unwrap(), FindRequest { action: FindAction::Stop, query: None }, "stop carries nothing");
assert!(request("start", None).is_err(), "start needs a query");
assert!(request("start", Some("")).is_err());
assert!(request("eval", Some("x")).is_err());
assert!(request("start", Some("two\nlines")).is_err());
assert!(request("start", Some("nul\0")).is_err());
assert!(request("start", Some(&"a".repeat(MAX_QUERY))).is_ok());
assert!(request("start", Some(&"a".repeat(MAX_QUERY + 1))).is_err());
assert!(request("start", Some(&"ş".repeat(MAX_QUERY))).is_ok(), "characters, not bytes");
}
#[test]
fn the_fallback_script_carries_the_query_only_as_a_string_literal() {
let script = fallback_script(&request("start", Some("it's \"quoted\" </script> \\ ${x} `y`")).unwrap());
assert!(script.contains(r#"window.find("it's \"quoted\" </script> \\ ${x} `y`",false,false,true"#), "{script}");
assert!(script.contains("removeAllRanges"), "a new query starts from the top");
let next = fallback_script(&request("next", Some("a")).unwrap());
assert!(next.contains(r#"window.find("a",false,false,true"#) && !next.contains("removeAllRanges"));
assert!(fallback_script(&request("previous", Some("a")).unwrap()).contains(r#"window.find("a",false,true,true"#));
// Stop (and a step without a query) only clears the selection.
for request in [request("stop", None).unwrap(), request("next", None).unwrap()] {
let script = fallback_script(&request);
assert!(script.contains("removeAllRanges") && !script.contains("window.find"), "{script}");
}
// Nothing leaves the page or stays in it: no network, storage, invoke or DOM writes.
for script in [script, next] {
for forbidden in ["fetch", "XMLHttpRequest", "localStorage", "sessionStorage", "invoke", "postMessage", "innerHTML", "document.cookie", "eval("] {
assert!(!script.contains(forbidden), "{forbidden} in {script}");
}
}
}
#[test]
fn counts_are_numbers_about_the_tab() {
let state = FindState { id: "t1".into(), count: 17, index: 2 };
assert_eq!(serde_json::to_string(&state).unwrap(), r#"{"id":"t1","count":17,"index":2}"#);
}
}