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:
parent
832396801c
commit
b31fa6b1d2
17 changed files with 449 additions and 19 deletions
143
crates/waybetter-host/src/find.rs
Normal file
143
crates/waybetter-host/src/find.rs
Normal 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}"#);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue