Search: index-free name and content search, streamed over SSE
Server: GET /api/search walks the selected root with ignore::WalkParallel (fd/ripgrep's walker) and greps with the grep crates, emitting one SSE event per hit. No index. Name matching is against the entry's own name, not its path, so a file is not a hit merely for sitting inside a matching directory. The walk unwinds when the client drops the connection. Client: a search view with streamed results, a stop button, a single-root select, per-file collapsible match lists, and paged result lists. Rendering is built around the reactive-graph cost dominating the DOM cost (the same markup from plain JS is ~28x faster), so: - keyed <For> for every list, so a flush builds only the new nodes - one escaped inner_html per text instead of a component per highlight fragment; file names and contents are untrusted, so everything goes through push_escaped and the only markup emitted is the <mark> - icon_svg() as a plain function, and inner_html rather than a NodeRef on_load effect, to avoid an owner and an effect per icon - one delegated click handler per list instead of a Callback per row - writes applied in chunks sized by feedback from the previous round's interval, yielding to the event loop between them Result lists page 100 rows / 20 cards at a time. Groups and open files are hidden with `display`, never unmounted, so results survive scope toggling and the window scroll is preserved across opening a result. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A.search-mockups/b2-view-streaming.html
@@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8">
<title>Mockup B2 — Search view, streaming</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght@20..24,400&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<style>
:root {
color-scheme: dark;
--bg: #0f1115;
--panel: #171a20;
--text: #e5e7eb;
--muted: #9ca3af;
--accent: #60a5fa;
--accent-hover: #3b82f6;
--accent-border: #79b5fc;
--danger: #f87171;
--danger-border: #fa8b8b;
--border: #2a2f3a;
--topbar-h: 48px;
--mark: color-mix(in srgb, var(--accent) 35%, transparent);
}
* { box-sizing: border-box; border-radius: 0; }
body { margin: 0; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); font-size: 15px; }
.material-symbols-rounded { font-family: 'Material Symbols Rounded'; font-weight: normal; font-style: normal; line-height: 1; letter-spacing: normal; text-transform: none; display: inline-block; white-space: nowrap; direction: ltr; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; }
mark { background: var(--mark); color: inherit; padding: 0 1px; }
.layout { min-height: 100vh; display: flex; flex-direction: column; }
.topbar { flex: none; position: sticky; top: 0; z-index: 130; height: var(--topbar-h); display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 16px; background: var(--panel); border-bottom: 1px solid var(--border); }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; white-space: nowrap; color: inherit; text-decoration: none; }
.logo { width: 26px; height: 26px; flex: none; display: block; }
.body-row { flex: 1; display: flex; }
.sidebar { flex: none; width: 224px; background: var(--panel); border-right: 1px solid var(--border); padding: 14px 8px 16px; }
.nav-section-head { font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); padding: 0 8px 6px; }
.nav-item { display: flex; align-items: center; gap: 9px; padding: 7px 8px; font-size: 14px; color: var(--text); text-decoration: none; }
.nav-item.active { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
.nav-item .material-symbols-rounded { font-size: 19px; color: var(--muted); }
.nav-item.active .material-symbols-rounded { color: var(--accent); }
.content { flex: 1; padding: 18px 20px; max-width: 1020px; }
/* query bar */
.query-bar { display: flex; gap: 10px; align-items: stretch; margin-bottom: 6px; position: relative; }
.query-input { flex: 1; display: flex; align-items: center; gap: 10px; background: var(--panel); border: 1px solid var(--border); padding: 0 12px; height: 40px; }
.query-input:focus-within { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.query-input .material-symbols-rounded { color: var(--muted); font-size: 20px; }
.query-input input { flex: 1; background: none; border: none; outline: none; color: var(--text); font-size: 15px; height: 100%; }
.seg { display: flex; border: 1px solid var(--border); background: var(--panel); }
.seg button { appearance: none; border: none; background: none; color: var(--muted); font-size: 13px; font-weight: 600; padding: 0 14px; cursor: pointer; border-right: 1px solid var(--border); }
.seg button:last-child { border-right: none; }
.seg button.on { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--accent); box-shadow: inset 0 -2px 0 var(--accent); }
/* roots multiselect */
.ms { position: relative; }
.ms-btn { display: flex; align-items: center; gap: 8px; background: var(--panel); border: 1px solid var(--accent); color: var(--text); font-size: 13px; height: 40px; padding: 0 12px; cursor: pointer; min-width: 130px; justify-content: space-between; }
.ms-btn .material-symbols-rounded { font-size: 17px; color: var(--muted); }
.ms-drop { position: absolute; top: 44px; left: 0; width: 230px; background: var(--panel); border: 1px solid var(--border); box-shadow: 0 12px 32px rgba(0,0,0,.5); z-index: 40; padding: 6px 0; }
.ms-opt { display: flex; align-items: center; gap: 10px; padding: 7px 12px; font-size: 14px; cursor: pointer; }
.ms-opt:hover { background: color-mix(in srgb, var(--panel) 50%, var(--bg)); }
.ms-opt .box { width: 16px; height: 16px; border: 1px solid var(--muted); display: inline-flex; align-items: center; justify-content: center; flex: none; }
.ms-opt .box.on { background: var(--accent); border-color: var(--accent); }
.ms-opt .box .material-symbols-rounded { font-size: 13px; color: #fff; }
.ms-opt .rp { margin-left: auto; color: var(--muted); font-size: 12px; }
/* stop button (visible while searching) */
.btn-stop { display: inline-flex; align-items: center; gap: 7px; border: 1px solid var(--danger-border); color: var(--danger); background: color-mix(in srgb, var(--danger) 8%, var(--panel)); font-size: 13px; font-weight: 600; padding: 0 14px; height: 40px; cursor: pointer; }
.btn-stop .material-symbols-rounded { font-size: 17px; font-variation-settings: 'FILL' 1; }
.btn { display: inline-flex; align-items: center; gap: 7px; border: 1px solid var(--border); background: var(--panel); color: var(--text); font-size: 13px; font-weight: 600; padding: 0 14px; height: 40px; cursor: pointer; }
.btn .material-symbols-rounded { font-size: 17px; }
/* live status line */
.status-line { display: flex; align-items: center; gap: 8px; margin: 4px 2px 12px; font-size: 12.5px; color: var(--muted); }
.status-line .dot { width: 8px; height: 8px; background: var(--accent); animation: pulse 1.2s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .25; } }
.status-line .grow { flex: 1; }
.status-line .linkish { color: var(--muted); cursor: pointer; }
/* result groups */
.group-label { display: flex; align-items: center; gap: 7px; margin: 16px 2px 8px; font-size: 12px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; color: var(--muted); }
.group-label .material-symbols-rounded { font-size: 15px; }
.group-label .n { font-weight: 400; letter-spacing: 0; text-transform: none; }
.group-label .grow { flex: 1; }
.collapse-all { appearance: none; border: none; background: none; color: var(--muted); font-size: 12px; display: inline-flex; align-items: center; gap: 4px; cursor: pointer; padding: 2px 4px; }
.collapse-all:hover { color: var(--text); }
.collapse-all .material-symbols-rounded { font-size: 15px; }
.entries-list { background: var(--panel); border: 1px solid var(--border); overflow: hidden; }
.srow { display: grid; grid-template-columns: 28px minmax(0,1fr) 240px 90px; gap: 12px; align-items: center; padding: 9px 14px; border-bottom: 1px solid var(--border); font-size: 14px; cursor: pointer; }
.srow:last-child { border-bottom: none; }
.srow:hover { background: color-mix(in srgb, var(--panel) 50%, var(--bg)); }
.srow .material-symbols-rounded { font-size: 20px; color: var(--muted); }
.spath { color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ssize { color: var(--muted); font-size: 13px; text-align: right; }
.more-row { display: flex; justify-content: center; gap: 8px; padding: 7px; color: var(--muted); font-size: 13px; border-top: 1px solid var(--border); }
/* content match cards — match lines only */
.match-card { background: var(--panel); border: 1px solid var(--border); margin-bottom: 10px; }
.match-head { display: grid; grid-template-columns: 28px minmax(0,1fr) 70px 90px 34px; gap: 12px; align-items: center; padding: 9px 10px 9px 14px; font-size: 14px; }
.match-head .material-symbols-rounded.mi { font-size: 20px; color: var(--muted); }
.match-head b { font-weight: 600; }
.match-head .mpath { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.match-head .mcount { color: var(--accent); font-size: 12px; font-weight: 600; text-align: right; }
.match-head .msize { color: var(--muted); font-size: 13px; text-align: right; }
.chev { appearance: none; border: 1px solid transparent; background: none; color: var(--muted); cursor: pointer; padding: 5px; display: inline-flex; align-items: center; justify-content: center; }
.chev:hover { border-color: var(--border); background: var(--bg); color: var(--text); }
.chev .material-symbols-rounded { font-size: 18px; }
.match-lines { border-top: 1px solid var(--border); background: color-mix(in srgb, var(--bg) 60%, var(--panel)); padding: 6px 0; }
.mline { display: grid; grid-template-columns: 52px minmax(0,1fr); gap: 12px; padding: 3px 14px 3px 10px; font-family: "JetBrains Mono", monospace; font-size: 12.5px; line-height: 1.5; cursor: pointer; }
.mline:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
.mline .ln { color: var(--muted); text-align: right; user-select: none; }
.mline .tx { white-space: pre; overflow: hidden; text-overflow: ellipsis; }
</style>
</head>
<body>
<div class="layout">
<header class="topbar">
<a class="brand" href="#">
<svg class="logo" viewBox="0 0 32 32"><rect width="32" height="32" fill="#2563eb"/><path d="M6 8h7l3 4h10v14H6z" fill="#fff"/><path d="M11.5 17.5 16 22l4.5-4.5" fill="none" stroke="#2563eb" stroke-width="3"/></svg>
<span>filebrowser-ng</span>
</a>
</header>
<div class="body-row">
<aside class="sidebar">
<div class="nav-section-head">Files</div>
<div class="nav-item active"><span class="material-symbols-rounded">search</span> Search</div>
<div class="nav-item"><span class="material-symbols-rounded">folder</span> Documents</div>
<div class="nav-item"><span class="material-symbols-rounded">folder</span> Projects</div>
<div class="nav-section-head" style="margin-top:14px">Manage</div>
<div class="nav-item"><span class="material-symbols-rounded">link</span> Shares</div>
<div class="nav-item"><span class="material-symbols-rounded">settings</span> Settings</div>
</aside>
<main class="content">
<div class="query-bar">
<div class="query-input">
<span class="material-symbols-rounded">search</span>
<input value="invoice 2025" spellcheck="false">
</div>
<div class="seg">
<button>Name</button>
<button>Content</button>
<button class="on">Both</button>
</div>
<div class="ms">
<button class="ms-btn"><span>2 roots</span><span class="material-symbols-rounded">expand_more</span></button>
<div class="ms-drop">
<div class="ms-opt"><span class="box on"><span class="material-symbols-rounded">check</span></span> Documents <span class="rp">612 files</span></div>
<div class="ms-opt"><span class="box on"><span class="material-symbols-rounded">check</span></span> Projects <span class="rp">588 files</span></div>
</div>
</div>
<button class="btn-stop"><span class="material-symbols-rounded">stop</span> Stop</button>
</div>
<div class="status-line">
<span class="dot"></span>
<span>searching… scanned 412 files, 5 name matches, 14 content matches</span>
<span class="grow"></span>
</div>
<div class="group-label"><span class="material-symbols-rounded">description</span> Files <span class="n">· 5</span></div>
<div class="entries-list">
<div class="srow"><span class="material-symbols-rounded">description</span><span><mark>invoice</mark>_2025-11_final.pdf</span><span class="spath">Documents / finance / 2025</span><span class="ssize">182 KB</span></div>
<div class="srow"><span class="material-symbols-rounded">description</span><span><mark>invoice</mark>_2025-08_draeger.pdf</span><span class="spath">Documents / finance / 2025</span><span class="ssize">164 KB</span></div>
<div class="srow"><span class="material-symbols-rounded">folder</span><span><mark>invoices</mark>-2025</span><span class="spath">Projects / shop</span><span class="ssize">—</span></div>
<div class="srow"><span class="material-symbols-rounded">grid_on</span><span>export_2025_Q3.<mark>xlsx</mark></span><span class="spath">Documents / finance</span><span class="ssize">58 KB</span></div>
</div>
<div class="more-row"><span class="material-symbols-rounded" style="font-size:16px">more_horiz</span> 1 more…</div>
<div class="group-label">
<span class="material-symbols-rounded">text_snippet</span> Content <span class="n">· 14 matches in 4 files</span>
<span class="grow"></span>
<button class="collapse-all"><span class="material-symbols-rounded">unfold_less</span> Collapse all</button>
</div>
<div class="match-card">
<div class="match-head">
<span class="material-symbols-rounded mi">code</span>
<span class="mpath">Projects / billing / <b>generate_2025.rs</b></span>
<span class="msize">14 KB</span>
<span class="mcount">6 matches</span>
<button class="chev" title="Collapse"><span class="material-symbols-rounded">expand_more</span></button>
</div>
<div class="match-lines">
<div class="mline"><span class="ln">42</span><span class="tx">let template = load("<mark>invoice</mark>-<mark>2025</mark>-layout");</span></div>
<div class="mline"><span class="ln">58</span><span class="tx">stamp(&mut pdf, "<mark>INVOICE</mark>-<mark>2025</mark>-…");</span></div>
<div class="mline"><span class="ln">87</span><span class="tx">// <mark>2025</mark>-11: VAT rate changed mid-run</span></div>
<div class="mline"><span class="ln">91</span><span class="tx">fn total_2025(rows: &[Line]) -> Money { … }</span></div>
</div>
</div>
<div class="match-card">
<div class="match-head">
<span class="material-symbols-rounded mi">description</span>
<span class="mpath">Documents / finance / <b>2025_summary.md</b></span>
<span class="msize">6 KB</span>
<span class="mcount">5 matches</span>
<button class="chev" title="Collapse"><span class="material-symbols-rounded">expand_more</span></button>
</div>
<div class="match-lines">
<div class="mline"><span class="ln">3</span><span class="tx">## <mark>Invoice</mark> totals <mark>2025</mark></span></div>
<div class="mline"><span class="ln">11</span><span class="tx">| <mark>2025</mark>-11 | 4,120.00 | paid |</span></div>
<div class="mline"><span class="ln">12</span><span class="tx">| <mark>2025</mark>-12 | 3,807.50 | open |</span></div>
</div>
</div>
<div class="match-card">
<div class="match-head">
<span class="material-symbols-rounded mi">grid_on</span>
<span class="mpath">Documents / finance / <b>export_2025_Q3.csv</b></span>
<span class="msize">210 KB</span>
<span class="mcount">3 matches</span>
<button class="chev" title="Expand"><span class="material-symbols-rounded">expand_less</span></button>
</div>
</div>
</main>
</div>
</div>
</body>
</html>
MCargo.lock
@@ -286,6 +286,17 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "bstr"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
dependencies = [
"memchr",
"regex-automata",
"serde_core",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -521,6 +532,31 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -615,6 +651,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "encoding_rs_io"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22"
dependencies = [
"encoding_rs",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -839,6 +884,19 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "globset"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988"
dependencies = [
"aho-corasick",
"bstr",
"log",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "gloo-net"
version = "0.6.0"
@@ -885,6 +943,85 @@ dependencies = [
"web-sys",
]
[[package]]
name = "grep"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "309217bc53e2c691c314389c7fa91f9cd1a998cda19e25544ea47d94103880c3"
dependencies = [
"grep-cli",
"grep-matcher",
"grep-printer",
"grep-regex",
"grep-searcher",
]
[[package]]
name = "grep-cli"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf32d263c5d5cc2a23ce587097f5ddafdb188492ba2e6fb638eaccdc22453631"
dependencies = [
"bstr",
"globset",
"libc",
"log",
"termcolor",
"winapi-util",
]
[[package]]
name = "grep-matcher"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9417543f4870fc8f1c8e1af870afae2431007626d9e703fce6471c468d33847"
dependencies = [
"memchr",
]
[[package]]
name = "grep-printer"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd76035e87871f51c1ee5b793e32122b3ccf9c692662d9622ef1686ff5321acb"
dependencies = [
"bstr",
"grep-matcher",
"grep-searcher",
"log",
"serde",
"serde_json",
"termcolor",
]
[[package]]
name = "grep-regex"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce0c256c3ad82bcc07b812c15a45ec1d398122e8e15124f96695234db7112ef"
dependencies = [
"bstr",
"grep-matcher",
"log",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "grep-searcher"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72348823a0eafc4bc2e9051064f28b5b42cc100b571b3a35d67918d711efcbc6"
dependencies = [
"bstr",
"encoding_rs",
"encoding_rs_io",
"grep-matcher",
"log",
"memchr",
"memmap2",
]
[[package]]
name = "guardian"
version = "1.3.0"
@@ -1157,6 +1294,22 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "ignore"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f"
dependencies = [
"crossbeam-deque",
"globset",
"log",
"memchr",
"regex-automata",
"same-file",
"walkdir",
"winapi-util",
]
[[package]]
name = "indexmap"
version = "2.14.1"
@@ -1451,6 +1604,15 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memmap2"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
dependencies = [
"libc",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -2086,7 +2248,10 @@ dependencies = [
"clap",
"flate2",
"futures-util",
"globset",
"grep",
"http-body-util",
"ignore",
"infer",
"mime_guess",
"multer",
@@ -2371,6 +2536,15 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]]
name = "thiserror"
version = "1.0.69"
Mapi-types/src/lib.rs
@@ -21,6 +21,9 @@ pub const SHARES: &str = "/api/shares";
/// Public share resolve (no login): `{SHARE}/{token}`.
pub const SHARE: &str = "/api/share";
/// Admin user management: `{ADMIN_USERS}` and `{ADMIN_USERS}/{id}`.
/// `GET /api/search` — name and/or content search, streamed as SSE.
pub const SEARCH: &str = "/api/search";
pub const ADMIN_USERS: &str = "/api/admin/users";
pub const ADMIN_SETTINGS: &str = "/api/admin/settings";
@@ -43,6 +46,12 @@ pub const ACTION_CREATE_FILE: &str = "create-file";
pub const P_FORMAT: &str = "format";
/// `?share=<token>` — authenticate file calls with a public share token.
pub const P_SHARE: &str = "share";
/// Search query text (`GET {SEARCH}`).
pub const P_Q: &str = "q";
/// Which index to search: `name`, `content` or `both`.
pub const P_SCOPE: &str = "scope";
/// Comma-separated root ids; omitted = all of the user's roots.
pub const P_ROOTS: &str = "roots";
/// `?overwrite=true|1` on mutations and uploads.
pub const P_OVERWRITE: &str = "overwrite";
@@ -215,6 +224,42 @@ pub struct FilesResp {
pub entries: Vec<Entry>,
}
/// One streamed search result. Each is sent as one SSE event
/// (`data: <json>`), in the order found; the `done` event always ends the
/// stream.
#[derive(Serialize, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SearchEvent {
/// A file or folder whose name matched (scope `name`/`both`).
File {
root_id: i64,
/// Path relative to the root, `/`-separated.
path: String,
size: u64,
is_dir: bool,
},
/// One matching line (scope `content`/`both`). `path` is relative to the
/// root; `text` is the matched line, truncated to a fixed length.
Match {
root_id: i64,
path: String,
line: u64,
text: String,
},
/// Stream finished. `stopped` is true when the client aborted before the
/// search completed.
Done {
stopped: bool,
files: usize,
matches: usize,
/// Files examined (walked) before the stream ended.
scanned: usize,
/// Files skipped for content search (over the size cap).
skipped: usize,
elapsed_ms: u64,
},
}
#[derive(Serialize, Deserialize, Clone)]
pub struct UserInfo {
pub id: i64,
Mserver/Cargo.toml
@@ -35,6 +35,9 @@ futures-util = "0.3"
tokio-stream = { version = "0.1", features = ["sync"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
ignore = "0.4"
globset = "0.4"
grep = "0.4"
[dev-dependencies]
tempfile = "3"
Mserver/src/api/mod.rs
@@ -1,7 +1,8 @@
use std::sync::Arc;
use api_types::{
ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, FILES, SHARE, SHARES,
ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, FILES, SEARCH,
SHARE, SHARES,
};
use axum::Router;
use axum::http::HeaderValue;
@@ -67,6 +68,7 @@ mod admin;
mod auth;
mod common;
mod files;
mod search;
mod shares;
mod spa;
@@ -84,6 +86,7 @@ pub fn router(state: Arc<AppState>) -> Router {
.route(AUTH_LOGOUT, post(auth::logout))
.route(AUTH_ME, get(auth::me).put(auth::update_profile))
.route(AUTH_SETUP, post(auth::setup))
.route(SEARCH, get(search::search))
.route(&files_root, get(files::list_root).put(files::file_put_root))
.route(&files_item, get(files::file_get))
.route(&files_item, put(files::file_put))
Aserver/src/api/search.rs
@@ -0,0 +1,508 @@
//! `GET /api/search` — index-free name and content search, streamed as SSE.
//!
//! No index, by design: every search walks the selected roots with
//! [`ignore::WalkParallel`] (fd/ripgrep's walker: parallel, skips hidden
//! files, honors `.gitignore`) and matches on the fly.
//!
//! * **Name** (scope `name`/`both`): every word of the query must occur
//! (case-insensitive) in the entry's own name — the last path component,
//! not the whole relative path — so "invoice 2025" matches
//! `invoice_2025-11_final.pdf` but a file is not a hit merely for sitting
//! inside a directory whose name matches.
//! * **Content** (scope `content`/`both`): the whole query as a literal,
//! case-insensitive, ripgrep-style scan via the `grep` crates (ripgrep's
//! engine). Binary files are skipped by NUL detection, files over
//! [`SEARCH_MAX_FILE_BYTES`] are skipped and counted.
//!
//! Results are one JSON object per SSE event, in the order found; the stream
//! always ends with a `done` event. The search stops when the client goes
//! away: axum drops the response body stream, the channel receiver is
//! dropped with it, and the walkers bail at the next `is_closed` check —
//! no per-search budgets or timeouts.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
use api_types::SearchEvent;
use axum::extract::{Query as AxumQuery, State};
use axum::http::StatusCode;
use axum::http::header;
use axum::http::request::Parts;
use axum::response::Response;
use futures_util::StreamExt;
use grep::regex::RegexMatcherBuilder;
use grep::searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkMatch};
use ignore::{DirEntry, WalkBuilder, WalkState};
use serde::Deserialize;
use crate::api::common::{AuthUser, HasState};
use crate::db::RootRow;
use crate::error::{ApiError, AppState};
/// Content search skips files larger than this. There is no search budget
/// (the client can stop at any time), but a multi-gigabyte text file would
/// stall its worker indefinitely — ripgrep has no such cap, we do.
const SEARCH_MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;
/// A matched line is truncated to this many bytes on the wire.
const SEARCH_MAX_LINE_BYTES: usize = 500;
/// Total matched lines emitted per search. A one-character query can match
/// tens of thousands of lines; unbounded streaming would flood the client.
const SEARCH_MAX_MATCH_EVENTS: usize = 50_000;
/// Matched lines emitted per file (mirrors the client's render cap).
const SEARCH_MAX_LINES_PER_FILE: usize = 500;
#[derive(Debug, Deserialize)]
pub(super) struct SearchQuery {
q: Option<String>,
/// `name` (default), `content` or `both`.
#[serde(default)]
scope: Option<String>,
/// Comma-separated root ids; empty = all of the user's roots.
#[serde(default)]
roots: Option<String>,
}
/// Search is a session-user feature: reject share-token sessions outright
/// (the share page has no search UI, and a share visitor must not probe
/// files outside the shared item).
pub(super) struct SearchUser(AuthUser);
impl<S> axum::extract::FromRequestParts<S> for SearchUser
where
S: HasState + Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let auth = AuthUser::from_request_parts(parts, state).await?;
if auth.share.is_some() {
return Err(ApiError::localized(
StatusCode::FORBIDDEN,
"search requires a signed-in session",
"err_search_forbidden",
));
}
Ok(SearchUser(auth))
}
}
pub(super) async fn search(
State(state): State<Arc<AppState>>,
SearchUser(auth): SearchUser,
AxumQuery(query): AxumQuery<SearchQuery>,
) -> Result<Response, ApiError> {
let q = query
.q
.as_deref()
.map(str::trim)
.filter(|q| !q.is_empty())
.ok_or_else(|| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"search requires a non-empty query",
"err_search_empty_query",
)
})?;
let scope = query.scope.as_deref().unwrap_or("name");
let want_name = matches!(scope, "name" | "both");
let want_content = matches!(scope, "content" | "both");
if !(want_name || want_content) {
return Err(ApiError::localized(
StatusCode::BAD_REQUEST,
"scope must be name, content or both",
"err_search_bad_scope",
));
}
// Selected roots: only ids the caller actually has.
let roots = match query.roots.as_deref() {
None => Ok(auth.roots.clone()),
Some(list) if list.trim().is_empty() => Ok(auth.roots.clone()),
Some(list) => list
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| -> Result<RootRow, ApiError> {
let id = s.parse::<i64>().map_err(|_| {
ApiError::localized(
StatusCode::BAD_REQUEST,
"root ids must be numbers",
"err_search_bad_roots",
)
})?;
auth.roots
.iter()
.find(|r| r.id == id)
.cloned()
.ok_or_else(|| {
ApiError::localized(
StatusCode::FORBIDDEN,
"root not accessible",
"err_root_forbidden",
)
})
})
.collect::<Result<Vec<RootRow>, ApiError>>(),
}?;
Ok(sse_response(search_stream(
q.to_string(),
want_name,
want_content,
Arc::new(roots),
state.root.clone(),
)))
}
/// Wraps an event stream in an SSE response (`data: <json>` per event).
fn sse_response(
events: impl futures_util::Stream<Item = SearchEvent> + Send + 'static,
) -> Response {
let body = events.map(|ev| {
// Infallible: SearchEvent is always serializable.
let json = serde_json::to_string(&ev).expect("SearchEvent serializes");
Ok::<String, std::io::Error>(format!("data: {json}\n\n"))
});
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache")
// nginx and friends must not buffer an event stream.
.header("x-accel-buffering", "no")
.body(axum::body::Body::from_stream(body))
.expect("static response")
}
/// Shared state of the search. The counters are `Arc`'d so the per-thread
/// walk visitors (which must be `Clone`) can update them lock-free.
struct SearchState {
tx: tokio::sync::mpsc::Sender<SearchEvent>,
words: Vec<String>,
roots: Arc<Vec<RootRow>>,
server_root: PathBuf,
started: Instant,
/// Directory entries visited (both phases).
scanned: Arc<AtomicUsize>,
/// Files skipped by the content phase (over the size cap).
skipped: Arc<AtomicUsize>,
names: Arc<AtomicUsize>,
matches: Arc<AtomicUsize>,
}
/// The search itself: walk (name phase) and grep (content phase) over the
/// selected roots, sending events as they are found. Runs on a plain thread
/// — each `WalkParallel` manages its own worker pool — while the SSE body
/// stream polls the receiver.
///
/// Stopping: when the client goes away, axum drops the body stream, which
/// drops the receiver. Every `is_closed` check in the walkers then yields
/// `WalkState::Quit` at the next entry, so the walk unwinds promptly.
fn search_stream(
q: String,
want_name: bool,
want_content: bool,
roots: Arc<Vec<RootRow>>,
server_root: PathBuf,
) -> impl futures_util::Stream<Item = SearchEvent> + Send {
let (tx, rx) = tokio::sync::mpsc::channel::<SearchEvent>(256);
let state = Arc::new(SearchState {
tx,
words: q.split_whitespace().map(|w| w.to_lowercase()).collect(),
roots,
server_root,
started: Instant::now(),
scanned: Arc::new(AtomicUsize::new(0)),
skipped: Arc::new(AtomicUsize::new(0)),
names: Arc::new(AtomicUsize::new(0)),
matches: Arc::new(AtomicUsize::new(0)),
});
std::thread::spawn(move || {
if want_name {
name_phase(&state);
}
if want_content {
// The name phase already counted every visited entry; only
// count again when the content phase is the only walk.
content_phase(&state, &q, !want_name);
}
// `stopped`: the receiver went away (client stopped or navigated)
// or the match cap was hit, before the walk finished.
let stopped = state.tx.is_closed()
|| state.matches.load(Ordering::Relaxed) >= SEARCH_MAX_MATCH_EVENTS;
let _ = state.tx.blocking_send(SearchEvent::Done {
stopped,
files: state.names.load(Ordering::Relaxed),
matches: state.matches.load(Ordering::Relaxed),
scanned: state.scanned.load(Ordering::Relaxed),
skipped: state.skipped.load(Ordering::Relaxed),
elapsed_ms: state.started.elapsed().as_millis() as u64,
});
});
tokio_stream::wrappers::ReceiverStream::new(rx)
}
/// Walks one root in parallel. `visit` sees every entry (the root itself
/// included) plus the root's absolute path, and returns [`WalkState::Quit`]
/// to stop the whole walk (client went away). Must be `Clone`: the walker
/// clones it once per worker thread.
fn walk_root<F>(root: &RootRow, server_root: &Path, visit: F)
where
F: FnMut(&DirEntry, &Path) -> WalkState + Clone + Send,
{
let abs = server_root.join(&root.path);
if !abs.is_dir() {
// Root removed out from under us: skip, keep searching the rest.
return;
}
WalkBuilder::new(&abs)
.standard_filters(true) // fd's defaults: hidden files + gitignore
.build_parallel()
.run(move || {
let mut visit = visit.clone();
let abs = abs.clone();
Box::new(move |result| match result {
Ok(entry) => visit(&entry, &abs),
Err(_) => WalkState::Continue, // unreadable entry: skip like fd
})
});
}
/// True when every query word occurs in `name`. Both are already lowercased.
///
/// `name` is the entry's own name, never its path — see the module docs.
fn name_matches(words: &[String], name: &str) -> bool {
words.iter().all(|w| name.contains(w.as_str()))
}
/// Phase 1: name matches.
fn name_phase(st: &Arc<SearchState>) {
for root in st.roots.iter() {
walk_root(root, &st.server_root, move |entry, abs| {
if st.tx.is_closed() {
return WalkState::Quit;
}
st.scanned.fetch_add(1, Ordering::Relaxed);
let Some(rel) = entry
.path()
.strip_prefix(abs)
.ok()
.filter(|p| !p.as_os_str().is_empty())
else {
return WalkState::Continue;
};
// Matched against the entry's own name, not its path: matching
// the path makes every descendant of a matching directory a hit
// too ("e" matching `search-test/` dragged in all 400 files
// under it), which buries the entries the user actually named.
let name = entry.file_name().to_string_lossy().to_lowercase();
if !name_matches(&st.words, &name) {
return WalkState::Continue;
}
// Original case, unlike the name used for matching: this path is
// what the client opens the entry by.
let rel = rel.to_string_lossy().replace('\\', "/");
st.names.fetch_add(1, Ordering::Relaxed);
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
let size = if is_dir {
0
} else {
entry.metadata().map(|m| m.len()).unwrap_or(0)
};
let ev = SearchEvent::File {
root_id: root.id,
path: rel,
size,
is_dir,
};
// blocking_send: the channel cap provides backpressure against a
// slow client. Err means the receiver is gone: stop the search.
match st.tx.blocking_send(ev) {
Ok(()) => WalkState::Continue,
Err(_) => WalkState::Quit,
}
});
}
}
/// Phase 2: content matches.
fn content_phase(st: &Arc<SearchState>, q: &str, count_scanned: bool) {
for root in st.roots.iter() {
walk_root(root, &st.server_root, move |entry, abs| {
if st.tx.is_closed() {
return WalkState::Quit;
}
if count_scanned {
st.scanned.fetch_add(1, Ordering::Relaxed);
}
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
return WalkState::Continue;
}
if entry.metadata().map(|m| m.len()).unwrap_or(0) > SEARCH_MAX_FILE_BYTES {
st.skipped.fetch_add(1, Ordering::Relaxed);
return WalkState::Continue;
}
let Some(rel) = entry.path().strip_prefix(abs).ok() else {
return WalkState::Continue;
};
search_one_file(q, entry.path(), rel, root.id, &st.tx, &st.matches);
if st.tx.is_closed() || st.matches.load(Ordering::Relaxed) >= SEARCH_MAX_MATCH_EVENTS {
WalkState::Quit
} else {
WalkState::Continue
}
});
}
}
/// Grep one file with a fresh literal matcher. The matcher is built per file
/// rather than shared: `RegexMatcher` is not `Sync`, so it cannot cross the
/// walk's thread boundary; a case-insensitive literal compiles in
/// microseconds, negligible next to the file read it protects.
fn search_one_file(
q: &str,
path: &Path,
rel: &Path,
root_id: i64,
tx: &tokio::sync::mpsc::Sender<SearchEvent>,
matches: &Arc<AtomicUsize>,
) {
let matcher = match RegexMatcherBuilder::new()
.case_insensitive(true)
.build_literals(&[q])
{
Ok(m) => m,
Err(_) => return,
};
let mut searcher = SearcherBuilder::new().line_number(true).build();
// ripgrep's default: a NUL byte means "binary, stop".
searcher.set_binary_detection(BinaryDetection::quit(0));
let rel = rel.to_string_lossy().replace('\\', "/");
let mut sink = MatchSink {
tx,
matches,
lines: 0,
root_id,
rel,
};
// I/O errors (permissions, vanished file): skip like fd does.
let _ = searcher.search_path(matcher, path, &mut sink);
}
/// Pushes each matched line onto the event channel. `blocking_send` is
/// correct here: it runs on the walk's worker threads, and the channel cap
/// provides backpressure against a slow client.
struct MatchSink<'a> {
tx: &'a tokio::sync::mpsc::Sender<SearchEvent>,
matches: &'a Arc<AtomicUsize>,
/// Matched lines emitted for the current file.
lines: usize,
root_id: i64,
rel: String,
}
impl Sink for MatchSink<'_> {
type Error = std::io::Error;
fn matched(&mut self, _searcher: &Searcher, mat: &SinkMatch) -> Result<bool, Self::Error> {
if self.tx.is_closed() || self.lines >= SEARCH_MAX_LINES_PER_FILE {
return Ok(false);
}
let line = match mat.line_number() {
Some(n) => n,
None => return Ok(true),
};
// Single-line matcher: exactly one line per match.
let text = match mat.lines().next() {
Some(l) => truncate_line(l),
None => return Ok(true),
};
// Cap check before the increment, so the counted number of matches
// is exactly the number of matches delivered.
if self.matches.load(Ordering::Relaxed) >= SEARCH_MAX_MATCH_EVENTS {
return Ok(false);
}
self.lines += 1;
self.matches.fetch_add(1, Ordering::Relaxed);
match self.tx.blocking_send(SearchEvent::Match {
root_id: self.root_id,
path: self.rel.clone(),
line,
text,
}) {
Ok(()) => Ok(true),
// Receiver gone: stop this file; the walker quits on the next
// `is_closed` check.
Err(_) => Ok(false),
}
}
}
/// Strips the line terminator and truncates at [`SEARCH_MAX_LINE_BYTES`],
/// backing off to a UTF-8 boundary if the cut split a character.
fn truncate_line(line: &[u8]) -> String {
let end = line
.iter()
.rposition(|b| *b != b'\n' && *b != b'\r')
.map(|i| i + 1)
.unwrap_or(0);
let full = &line[..end];
let cut = full.len().min(SEARCH_MAX_LINE_BYTES);
let mut line = &full[..cut];
if cut < full.len() && std::str::from_utf8(full).is_ok() {
while !line.is_empty() && std::str::from_utf8(line).is_err() {
line = &line[..line.len() - 1];
}
}
String::from_utf8_lossy(line).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_match_needs_every_word() {
let words = vec!["invoice".to_string(), "2025".to_string()];
assert!(name_matches(&words, "invoice_2025-11_final.pdf"));
assert!(!name_matches(&words, "invoice_2024.pdf"));
}
#[test]
fn name_match_ignores_the_parent_path() {
// The caller passes the entry's own name, so a file does not match
// just because an ancestor directory does.
let words = vec!["test".to_string()];
assert!(name_matches(&words, "test-notes.md"));
assert!(!name_matches(&words, "f12.txt"));
}
#[test]
fn truncation_strips_terminator() {
assert_eq!(truncate_line(b"hello\n"), "hello");
assert_eq!(truncate_line(b"hello\r\n"), "hello");
assert_eq!(truncate_line(b"no newline"), "no newline");
assert_eq!(truncate_line(b"\n"), "");
}
#[test]
fn truncation_caps_length() {
let long = vec![b'x'; 600];
let t = truncate_line(&long);
assert_eq!(t.len(), SEARCH_MAX_LINE_BYTES);
}
#[test]
fn truncation_keeps_utf8_boundary() {
// 3-byte chars; the cap must not split one.
let long: Vec<u8> = "ä".repeat(200).into_bytes();
let t = truncate_line(&long);
assert!(t.len() <= SEARCH_MAX_LINE_BYTES);
assert!(t.chars().all(|c| c == 'ä'));
}
}
Mweb/Cargo.toml
@@ -15,6 +15,7 @@ thiserror = "2"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = [
"AbortController",
"AddEventListenerOptions",
"Blob",
"Clipboard",
@@ -38,6 +39,10 @@ web-sys = { version = "0.3", features = [
"Location",
"MouseEvent",
"Navigator",
"Performance",
"ReadableStream",
"ReadableStreamDefaultReader",
"ReadableStreamReadResult",
"Request",
"RequestInit",
"RequestMode",
@@ -45,5 +50,7 @@ web-sys = { version = "0.3", features = [
"Storage",
"SubmitEvent",
"SvgElement",
"TextDecodeOptions",
"TextDecoder",
"Window",
] }
Mweb/app.css
@@ -1589,3 +1589,565 @@ button:disabled {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
/* ------------------------------------------------------------------ */
/* Search view (#/search) */
/* ------------------------------------------------------------------ */
.search-view {
display: flex;
flex-direction: column;
gap: 14px;
}
/* Query bar ------------------------------------------------------ */
.query-bar {
display: flex;
align-items: stretch;
gap: 8px;
}
.query-input {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
background: var(--panel);
border: 1px solid var(--border);
padding: 0 10px;
min-width: 0;
}
.query-input .qi-icon {
width: 18px;
height: 18px;
color: var(--muted);
flex: none;
}
.query-input input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: var(--text);
font: inherit;
font-size: 14px;
height: 38px;
padding: 0;
}
.query-input input::placeholder {
color: var(--muted);
}
/* Segmented Name / Content / Both control */
.seg {
display: flex;
border: 1px solid var(--border);
background: var(--panel);
}
.seg button {
appearance: none;
border: none;
border-right: 1px solid var(--border);
background: transparent;
color: var(--muted);
font: inherit;
font-size: 13px;
padding: 0 14px;
cursor: pointer;
}
.seg button:last-child {
border-right: none;
}
.seg button:hover {
background: var(--bg);
}
.seg button.on {
background: var(--accent);
color: #fff;
}
/* Roots multi-select --------------------------------------------- */
/* The root a search covers. A native select, styled like the query bar. */
.root-select {
flex: none;
max-width: 220px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
font: inherit;
font-size: 13px;
padding: 0 10px;
}
.ms {
position: relative;
display: flex;
}
.ms-btn {
display: flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
font: inherit;
font-size: 13px;
padding: 0 10px;
cursor: pointer;
max-width: 180px;
}
.ms-btn:hover {
background: var(--bg);
}
.ms-btn > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Square caret drawn with borders (the icon set has no chevron). */
.ms-caret {
flex: none;
width: 8px;
height: 8px;
border-right: 2px solid var(--muted);
border-bottom: 2px solid var(--muted);
transform: rotate(45deg) translate(-1px, -1px);
}
.ms-backdrop {
position: fixed;
inset: 0;
z-index: 40;
}
.ms-drop {
position: absolute;
right: 0;
top: calc(100% + 4px);
z-index: 41;
min-width: 200px;
max-height: 280px;
overflow: auto;
background: var(--panel);
border: 1px solid var(--border);
padding: 4px 0;
}
.ms-opt {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
font-size: 14px;
cursor: pointer;
user-select: none;
}
.ms-opt:hover {
background: var(--bg);
}
.ms-opt span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Run / stop ------------------------------------------------------- */
.btn-run {
border: 1px solid var(--accent-border);
background: var(--accent);
color: #fff;
font: inherit;
font-size: 13px;
font-weight: 600;
padding: 0 18px;
cursor: pointer;
}
.btn-run:hover {
background: var(--accent-hover);
}
.btn-stop {
display: flex;
align-items: center;
gap: 6px;
border: 1px solid var(--danger-border);
background: var(--danger);
color: #fff;
font: inherit;
font-size: 13px;
font-weight: 600;
padding: 0 14px;
cursor: pointer;
}
.btn-stop:hover {
opacity: 0.9;
}
.btn-stop .ic-stop {
width: 14px;
height: 14px;
}
/* Status line ------------------------------------------------------ */
.status-line {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--muted);
}
.status-line .dot {
width: 8px;
height: 8px;
background: var(--accent);
animation: search-pulse 1s ease-in-out infinite;
}
@keyframes search-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.status-line .skip {
opacity: 0.75;
}
/* Group labels ----------------------------------------------------- */
.group-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
/* Result group: a `display:flex` (or `none`, from the inline style) flex
item of `.search-view` that re-establishes the column layout for its
label/list/more-row children. */
.result-group {
flex-direction: column;
gap: 14px;
}
.group-label .gl-icon {
width: 15px;
height: 15px;
}
.group-label .n {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
.group-label .grow {
flex: 1;
}
.collapse-all {
appearance: none;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
font: inherit;
font-size: 12px;
text-transform: none;
letter-spacing: 0;
padding: 3px 10px;
cursor: pointer;
}
.collapse-all:hover {
background: var(--bg);
color: var(--text);
}
/* Name-result rows --------------------------------------------------- */
.srow {
display: grid;
/* icon | name+dir | size | goto */
grid-template-columns: 28px minmax(0, 1fr) 90px 30px;
gap: 12px;
align-items: center;
padding: 9px 14px;
border-bottom: 1px solid var(--border);
cursor: pointer;
font-size: 14px;
user-select: none;
}
.srow:last-child {
border-bottom: none;
}
.srow:hover {
background: var(--bg);
}
.srow-icon {
width: 20px;
height: 20px;
}
.srow-main {
min-width: 0;
display: flex;
flex-direction: column;
line-height: 1.3;
}
.srow-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Highlighted query hits (plain fragments render as bare spans). */
mark.hl-hit {
background: color-mix(in srgb, var(--accent) 25%, transparent);
color: inherit;
}
/* The directory-path line (the name line lives inside .srow-name). */
.srow-dir {
font-size: 12px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.srow-dir mark.hl-hit {
color: inherit;
}
.ssize {
font-size: 13px;
color: var(--muted);
text-align: right;
}
/* Paging footer: position in the results on the left, the action on the
right. Separated from the list by its own border so the button never reads
as another result row. */
.page-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--muted);
}
.page-foot .pf-count {
font-variant-numeric: tabular-nums;
}
.page-foot .pf-more {
appearance: none;
padding: 5px 12px;
border: 1px solid var(--border);
background: var(--panel);
font: inherit;
font-weight: 500;
color: var(--text);
cursor: pointer;
}
.page-foot .pf-more:hover {
border-color: var(--accent);
color: var(--accent);
}
/* Content matches ----------------------------------------------------- */
.match-card {
background: var(--panel);
border: 1px solid var(--border);
border-top: none;
}
/* The first card is the one right after the group label; `:first-of-type`
cannot be used because the label is an earlier `div` sibling. */
.group-label + .match-card {
border-top: 1px solid var(--border);
}
/* The header opens the file; the matched lines below are text to read. */
.match-head {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
font-size: 14px;
cursor: pointer;
}
.match-head:hover {
background: var(--bg);
}
.match-head .mi {
width: 18px;
height: 18px;
color: var(--muted);
flex: none;
}
/* Takes the slack, so the count and the two buttons sit at the right edge. */
.mpath {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mpath b {
font-weight: 600;
}
.mcount {
flex: none;
font-size: 12px;
color: var(--muted);
}
/* "Show in folder", in both the name rows and the match-card headers. */
.goto-btn {
appearance: none;
flex: none;
justify-self: end;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: 1px solid transparent;
background: transparent;
cursor: pointer;
}
.goto-btn:hover {
border-color: var(--border);
}
.goto-btn .goto-ic {
width: 16px;
height: 16px;
color: var(--muted);
}
.goto-btn:hover .goto-ic {
color: var(--accent);
}
.chev {
appearance: none;
border: 1px solid var(--border);
background: transparent;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex: none;
}
.chev:hover {
background: var(--bg);
}
.chev .chev-down,
.chev .chev-up {
display: block;
width: 10px;
height: 10px;
border-right: 2px solid var(--muted);
border-bottom: 2px solid var(--muted);
}
.chev .chev-down {
transform: rotate(45deg) translate(-1px, -1px);
}
.chev .chev-up {
transform: rotate(-135deg) translate(-1px, -1px);
}
.match-lines {
border-top: 1px solid var(--border);
background: var(--bg);
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px;
}
/* Matched lines are text to read, not a control: the card header is what
opens the file, so no pointer cursor and no hover here. */
.mline {
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
gap: 12px;
padding: 5px 14px;
border-bottom: 1px solid var(--border);
white-space: pre;
user-select: text;
}
.mline:last-child {
border-bottom: none;
}
.mline .ln {
color: var(--muted);
text-align: right;
user-select: none;
}
.mline .tx {
overflow: hidden;
text-overflow: ellipsis;
}
.search-empty {
padding: 48px 20px;
text-align: center;
color: var(--muted);
font-size: 14px;
}
Mweb/src/api.rs
@@ -4,6 +4,7 @@
//! `api_types` crate (the same one the server's route table and handlers
//! use), so the two sides cannot drift apart.
use leptos::prelude::Callable;
use serde::Serialize;
use serde::de::DeserializeOwned;
use wasm_bindgen::JsCast;
@@ -13,8 +14,8 @@ use wasm_bindgen_futures::JsFuture;
use api_types::{
ACTION_CONTENT, ACTION_CREATE_FILE, ACTION_DOWNLOAD, ACTION_MKDIR, ACTION_PREVIEW,
ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, CreateShare,
CreateUser, Credentials, FILES, Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_SHARE, Root,
SHARE, SHARES, Settings, UpdateUser,
CreateUser, Credentials, FILES, Mutation, P_ACTION, P_FORMAT, P_OVERWRITE, P_Q, P_ROOTS,
P_SCOPE, P_SHARE, Root, SEARCH, SHARE, SHARES, Settings, UpdateUser,
};
pub use api_types::{
AdminUser, Entry, FilesResp, Me, Mode, OkResp, Op, RootInfo, SaveResp, ShareInfo, UserInfo,
@@ -752,6 +753,135 @@ async fn parse_error_body(resp: &web_sys::Response) -> ErrBody {
.unwrap_or_default()
}
// ---------------------------------------------------------------------------
// Search (streamed)
// ---------------------------------------------------------------------------
/// Start a search and stream its results as they are found.
///
/// The server answers with an SSE stream (one `data: <json>` event per
/// result, ending in a `done` event). `on_event` runs once per event on the
/// main thread; the returned [`AbortController`] stops the search client-side
/// (the server notices the dropped connection and unwinds its walk).
///
/// `roots` empty = all of the caller's roots. Returns an error before
/// streaming starts only for request/HTTP failures; a stream that dies
/// mid-way simply ends without a `done` event.
pub fn search_stream(
q: String,
scope: &str,
roots: &[i64],
on_event: leptos::prelude::Callback<api_types::SearchEvent, ()>,
on_error: leptos::prelude::Callback<String, ()>,
) -> Result<web_sys::AbortController, ApiError> {
let mut url = format!(
"{}?{P_Q}={}&{P_SCOPE}={}",
SEARCH,
js_sys::encode_uri_component(&q),
scope
);
if !roots.is_empty() {
url.push_str(&format!(
"&{P_ROOTS}={}",
roots
.iter()
.map(|r| r.to_string())
.collect::<Vec<_>>()
.join(",")
));
}
let controller = web_sys::AbortController::new()
.map_err(|_| ApiError::Net("AbortController unavailable".to_string()))?;
let opts = web_sys::RequestInit::new();
opts.set_mode(web_sys::RequestMode::SameOrigin);
opts.set_signal(Some(&controller.signal()));
wasm_bindgen_futures::spawn_local(async move {
// HTTP-level failure (4xx/5xx JSON error from the server).
let resp = match fetch_checked(&url, &opts, "search failed").await {
Ok(r) => r,
Err(e) => {
// Aborting (user pressed Stop) rejects the fetch with an
// AbortError; that is expected, not a failure.
let msg = e.to_string();
if !msg.contains("AbortError") {
on_error.run(msg);
}
return;
}
};
let body = match resp.body() {
Some(b) => b,
None => return,
};
let reader = match body
.get_reader()
.dyn_into::<web_sys::ReadableStreamDefaultReader>()
{
Ok(r) => r,
Err(_) => return,
};
let decoder = match web_sys::TextDecoder::new() {
Ok(d) => d,
Err(_) => return,
};
let decode_opts = web_sys::TextDecodeOptions::new();
// Streaming mode: a multi-byte character split across two chunks
// must not be mangled.
decode_opts.set_stream(true);
let mut buf = String::new();
loop {
let result = match JsFuture::from(reader.read()).await {
Ok(v) => v,
Err(_) => return, // aborted or network failure: stop quietly
};
// The read() result is a plain { done, value } dictionary, so
// read its fields instead of downcasting to the (branded)
// ReadableStreamReadResult type.
let done =
js_sys::Reflect::get(&result, &JsValue::from_str("done")).unwrap_or(JsValue::TRUE);
if done.as_bool().unwrap_or(true) {
break;
}
let value = match js_sys::Reflect::get(&result, &JsValue::from_str("value")) {
Ok(v) => v,
Err(_) => break,
};
let u8: js_sys::Uint8Array = match value.dyn_into() {
Ok(u8) => u8,
Err(_) => break,
};
let chunk = match decoder.decode_with_u8_array(u8.to_vec().as_slice()) {
Ok(c) => c,
Err(_) => break,
};
buf.push_str(&chunk);
// SSE framing: events are separated by a blank line.
while let Some(pos) = buf.find("\n\n") {
let event = buf[..pos].to_string();
buf.drain(..pos + 2);
handle_sse_event(&event, &on_event);
}
}
});
Ok(controller)
}
/// Parse one SSE block (`data: {json}`) and forward the event.
fn handle_sse_event(event: &str, on_event: &leptos::prelude::Callback<api_types::SearchEvent, ()>) {
for line in event.lines() {
let Some(data) = line.strip_prefix("data:") else {
continue;
};
if let Ok(ev) = serde_json::from_str::<api_types::SearchEvent>(data.trim()) {
on_event.run(ev);
}
}
}
/// Read a form input's value by element id.
pub fn input_value(id: &str) -> String {
web_sys::window()
Mweb/src/components/icon.rs
@@ -14,29 +14,36 @@ fn icon_class(name: IconName) -> &'static str {
}
}
#[component]
pub fn Icon(name: IconName, #[prop(optional)] class: Option<String>) -> impl IntoView {
let node = NodeRef::<leptos::svg::Svg>::new();
let body = name.body().to_string();
node.on_load(move |el: web_sys::SvgElement| {
// The registry bodies are raw SVG markup (`<path …/>`), so they are
// injected as-is. The strings are static and fetched from the
// Iconify API — never user input.
el.set_inner_html(&body);
});
/// The icon markup, as a plain function rather than a component.
///
/// Prefer this over [`Icon`] inside long lists. A `#[component]` call costs a
/// reactive owner per instance, which is measurable at a thousand rows; a
/// plain function returning a view costs nothing extra.
///
/// `inner_html` rather than a `NodeRef` with an `on_load` callback, for the
/// same reason: `on_load` is an effect, so a thousand icons meant a thousand
/// effects. The registry bodies are raw SVG markup (`<path …/>`) and are
/// injected as-is: the strings are `const`, vendored from the Iconify API,
/// and never user input.
pub fn icon_svg(name: IconName, extra: &str) -> impl IntoView + use<> {
let mut cls = String::from("icon ");
cls.push_str(icon_class(name));
if let Some(extra) = class {
if !extra.is_empty() {
cls.push(' ');
cls.push_str(&extra);
cls.push_str(extra);
}
view! {
<svg
node_ref=node
class=cls
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
inner_html=name.body()
/>
}
}
#[component]
pub fn Icon(name: IconName, #[prop(optional)] class: Option<String>) -> impl IntoView {
icon_svg(name, class.as_deref().unwrap_or(""))
}
Mweb/src/i18n.rs
@@ -115,13 +115,26 @@ pub fn t_fmt(key: &str, arg: &str) -> String {
t(key).replace("{}", arg)
}
/// Like [`t_fmt`] for translations with two placeholders, filling the first
/// `{}` with `a` and the second with `b`. Positional, so a locale that needs
/// the other order has to reword rather than swap.
pub fn t_fmt2(key: &str, a: &str, b: &str) -> String {
let s = t(key);
match s.split_once("{}") {
Some((head, tail)) => format!("{head}{a}{}", tail.replacen("{}", b, 1)),
None => s.to_string(),
}
}
/// Localize a server error: a known code maps to its translation, anything
/// else (no code, unknown code) falls back to the server's English message.
pub fn error_text(code: Option<&str>, fallback: &str) -> String {
let Some(code) = code.filter(|c| find(EN, c).is_some()) else {
return fallback.to_string();
};
t(code).to_string()
// One-shot lookup for a toast message: no reactive context, so read the
// locale untracked.
text(code, current_untracked()).to_string()
}
fn text(key: &str, locale: Locale) -> &'static str {
@@ -247,6 +260,29 @@ pub mod k {
pub const THEME_LIGHT: &str = "theme_light";
pub const THEME_DARK: &str = "theme_dark";
// Search view
pub const SEARCH: &str = "search";
pub const SEARCH_PLACEHOLDER: &str = "search_placeholder";
pub const SEARCH_NAME: &str = "search_name";
pub const SEARCH_CONTENT: &str = "search_content";
pub const SEARCH_BOTH: &str = "search_both";
pub const SEARCH_GOTO: &str = "search_goto";
pub const SEARCH_RUN: &str = "search_run";
pub const SEARCH_STOP: &str = "search_stop";
pub const SEARCHING: &str = "searching";
pub const SEARCH_N_RESULTS: &str = "search_n_results";
pub const SEARCH_TOOK: &str = "search_took";
pub const SEARCH_STOPPED: &str = "search_stopped";
pub const SEARCH_SCANNED: &str = "search_scanned";
pub const SEARCH_SKIPPED: &str = "search_skipped";
pub const SEARCH_N_MATCHES: &str = "search_n_matches";
pub const SEARCH_IN_N_FILES: &str = "search_in_n_files";
pub const SEARCH_COLLAPSE_ALL: &str = "search_collapse_all";
pub const SEARCH_EXPAND_ALL: &str = "search_expand_all";
pub const SEARCH_SHOWN_OF: &str = "search_shown_of";
pub const SEARCH_SHOW_MORE: &str = "search_show_more";
pub const SEARCH_NO_RESULTS: &str = "search_no_results";
// Settings
pub const SAVING: &str = "saving";
pub const PROFILE: &str = "profile";
@@ -418,6 +454,10 @@ pub mod k {
pub const ERR_FS_FORBIDDEN: &str = "err_fs_forbidden";
pub const ERR_FS_ROOT_MISSING: &str = "err_fs_root_missing";
pub const ERR_FS_CONFLICT: &str = "err_fs_conflict";
pub const ERR_SEARCH_FORBIDDEN: &str = "err_search_forbidden";
pub const ERR_SEARCH_BAD_SCOPE: &str = "err_search_bad_scope";
pub const ERR_SEARCH_BAD_ROOTS: &str = "err_search_bad_roots";
pub const ERR_ROOT_FORBIDDEN: &str = "err_root_forbidden";
}
/// Canonical English strings (key → text). The source of truth for which
@@ -533,6 +573,27 @@ const EN: &[(&str, &str)] = &[
("theme_auto", "Auto"),
("theme_light", "Light"),
("theme_dark", "Dark"),
("search", "Search"),
("search_placeholder", "Search files and their contents"),
("search_name", "Name"),
("search_content", "Content"),
("search_both", "Both"),
("search_goto", "Show in folder"),
("search_run", "Search"),
("search_stop", "Stop"),
("searching", "searching…"),
("search_n_results", "{} results"),
("search_took", "{} ms"),
("search_stopped", "stopped · {} results"),
("search_scanned", "scanned {} files"),
("search_skipped", "{} skipped (over 10 MiB)"),
("search_n_matches", "{} matches"),
("search_in_n_files", "in {} files"),
("search_collapse_all", "Collapse all"),
("search_expand_all", "Expand all"),
("search_shown_of", "Showing {} of {}"),
("search_show_more", "Show {} more"),
("search_no_results", "no results"),
("saving", "Saving…"),
("profile", "Profile"),
("server", "Server"),
@@ -765,6 +826,16 @@ const EN: &[(&str, &str)] = &[
"the configured folder no longer exists",
),
(k::ERR_FS_CONFLICT, "already exists"),
(
k::ERR_SEARCH_FORBIDDEN,
"search requires a signed-in session",
),
(
k::ERR_SEARCH_BAD_SCOPE,
"scope must be name, content or both",
),
(k::ERR_SEARCH_BAD_ROOTS, "root ids must be numbers"),
(k::ERR_ROOT_FORBIDDEN, "root not accessible"),
];
/// German translations.
@@ -882,6 +953,30 @@ const DE: &[(&str, &str)] = &[
("theme_auto", "Auto"),
("theme_light", "Hell"),
("theme_dark", "Dunkel"),
("search", "Suche"),
(
"search_placeholder",
"Dateien und deren Inhalte durchsuchen",
),
("search_name", "Name"),
("search_content", "Inhalt"),
("search_both", "Beides"),
("search_goto", "Im Ordner anzeigen"),
("search_run", "Suchen"),
("search_stop", "Stopp"),
("searching", "Suche läuft…"),
("search_n_results", "{} Treffer"),
("search_took", "{} ms"),
("search_stopped", "gestoppt · {} Treffer"),
("search_scanned", "{} Dateien durchsucht"),
("search_skipped", "{} übersprungen (über 10 MiB)"),
("search_n_matches", "{} Treffer"),
("search_in_n_files", "in {} Dateien"),
("search_collapse_all", "Alle einklappen"),
("search_expand_all", "Alle ausklappen"),
("search_shown_of", "{} von {} angezeigt"),
("search_show_more", "{} weitere anzeigen"),
("search_no_results", "keine Treffer"),
("saving", "Wird gespeichert…"),
("profile", "Profil"),
("server", "Server"),
@@ -1168,6 +1263,16 @@ const DE: &[(&str, &str)] = &[
"der konfigurierte Ordner existiert nicht mehr",
),
(k::ERR_FS_CONFLICT, "existiert bereits"),
(
k::ERR_SEARCH_FORBIDDEN,
"Suche erfordert eine angemeldete Sitzung",
),
(
k::ERR_SEARCH_BAD_SCOPE,
"Bereich muss name, content oder both sein",
),
(k::ERR_SEARCH_BAD_ROOTS, "Ordner-IDs müssen Zahlen sein"),
(k::ERR_ROOT_FORBIDDEN, "Ordner nicht verfügbar"),
];
/// French translations.
@@ -1285,6 +1390,30 @@ const FR: &[(&str, &str)] = &[
("theme_auto", "Auto"),
("theme_light", "Clair"),
("theme_dark", "Sombre"),
("search", "Rechercher"),
(
"search_placeholder",
"Rechercher dans les fichiers et leur contenu",
),
("search_name", "Nom"),
("search_content", "Contenu"),
("search_both", "Les deux"),
("search_goto", "Afficher dans le dossier"),
("search_run", "Rechercher"),
("search_stop", "Arrêter"),
("searching", "recherche…"),
("search_n_results", "{} résultats"),
("search_took", "{} ms"),
("search_stopped", "arrêté · {} résultats"),
("search_scanned", "{} fichiers parcourus"),
("search_skipped", "{} ignorés (plus de 10 Mio.)"),
("search_n_matches", "{} correspondances"),
("search_in_n_files", "dans {} fichiers"),
("search_collapse_all", "Tout replier"),
("search_expand_all", "Tout déplier"),
("search_shown_of", "{} sur {} affichés"),
("search_show_more", "Afficher {} de plus"),
("search_no_results", "aucun résultat"),
("saving", "Enregistrement…"),
("profile", "Profil"),
("server", "Serveur"),
@@ -1577,6 +1706,19 @@ const FR: &[(&str, &str)] = &[
(k::ERR_FS_FORBIDDEN, "accès refusé"),
(k::ERR_FS_ROOT_MISSING, "le dossier configuré n'existe plus"),
(k::ERR_FS_CONFLICT, "existe déjà"),
(
k::ERR_SEARCH_FORBIDDEN,
"la recherche nécessite une session connectée",
),
(
k::ERR_SEARCH_BAD_SCOPE,
"le périmètre doit être name, content ou both",
),
(
k::ERR_SEARCH_BAD_ROOTS,
"les identifiants de dossiers doivent être des nombres",
),
(k::ERR_ROOT_FORBIDDEN, "dossier inaccessible"),
];
#[cfg(test)]
Mweb/src/icons.rs
@@ -73,6 +73,10 @@ pub enum IconName {
Close,
/// material-symbols:settings
Settings,
/// material-symbols:search
Search,
/// material-symbols:stop
Stop,
/// material-symbols:person
User,
/// material-symbols:admin-panel-settings
@@ -187,6 +191,10 @@ impl IconName {
Self::Settings => {
r#"<path fill="currentColor" d="m9.25 22l-.4-3.2q-.325-.125-.612-.3t-.563-.375L4.7 19.375l-2.75-4.75l2.575-1.95Q4.5 12.5 4.5 12.338v-.675q0-.163.025-.338L1.95 9.375l2.75-4.75l2.975 1.25q.275-.2.575-.375t.6-.3l.4-3.2h5.5l.4 3.2q.325.125.613.3t.562.375l2.975-1.25l2.75 4.75l-2.575 1.95q.025.175.025.338v.674q0 .163-.05.338l2.575 1.95l-2.75 4.75l-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2zm2.8-6.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5"/>"#
}
Self::Search => {
r#"<path fill="currentColor" d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14"/>"#
}
Self::Stop => r#"<path fill="currentColor" d="M6 18V6h12v12z"/>"#,
Self::User => {
r#"<path fill="currentColor" d="M9.175 10.825Q8 9.65 8 8t1.175-2.825T12 4t2.825 1.175T16 8t-1.175 2.825T12 12t-2.825-1.175M4 20v-2.8q0-.85.438-1.562T5.6 14.55q1.55-.775 3.15-1.162T12 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T20 17.2V20z"/>"#
}
Mweb/src/router.rs
@@ -16,6 +16,8 @@
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Section {
Files,
/// The search view (`#/search`); query params live in the hash query.
Search,
Shares,
Users,
Settings,
@@ -27,6 +29,7 @@ impl Section {
const fn segment(self) -> Option<&'static str> {
match self {
Section::Files => None,
Section::Search => Some("search"),
Section::Shares => Some("shares"),
Section::Users => Some("users"),
Section::Settings => Some("settings"),
@@ -76,6 +79,9 @@ pub fn parse_location() -> Location {
.unwrap_or_default();
let h = hash.trim_start_matches('#');
let h = h.trim_start_matches('/');
// Hash query (e.g. `#/search?q=…`) is not part of the path; the search
// view parses it itself.
let h = h.split_once('?').map(|(p, _)| p).unwrap_or(h);
let mut parts = h.split('/').filter(|s| !s.is_empty());
match parts.next() {
Some("files") => match parts.next().and_then(|s| s.parse().ok()) {
@@ -87,6 +93,12 @@ pub fn parse_location() -> Location {
},
None => Location::root(),
},
Some("search") => Location {
root_id: None,
path: Vec::new(),
share_token: None,
section: Section::Search,
},
Some("shares") => match parts.next() {
// `#/shares/{token}/...` — public share page.
Some(token) => Location {
Mweb/src/views/mod.rs
@@ -3,6 +3,7 @@ pub mod browser;
pub mod dialogs;
pub mod file_view;
pub mod login;
pub mod search;
pub mod setup;
pub mod share_page;
pub mod shares;
Aweb/src/views/search.rs
@@ -0,0 +1,1737 @@
//! The search view (`#/search`): index-free name and content search with
//! streamed results, an explicit stop button, a roots multi-select, and
//! collapsible per-file match lists.
//!
//! Results arrive as they are found (SSE, see `api::search_stream`). The
//! client caps only what it *renders* (name rows a page of [`FILE_PAGE`] at a
//! time, match lines per file at [`RENDER_LINE_CAP`]) — the search itself is
//! unbounded and ends when the walk is done or the user stops it. Stopping
//! aborts the fetch; the server notices the dropped connection and unwinds
//! its walk.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use api_types::{FileKind, SearchEvent};
use gloo_timers::future::TimeoutFuture;
use leptos::prelude::*;
use wasm_bindgen::JsCast;
use crate::api;
use crate::components::icon::{Icon, icon_svg};
use crate::components::toast::{ToastMsg, show_error};
use crate::editor::EditTarget;
use crate::i18n::{self, k};
use crate::icons::{IconName, icon_for};
use crate::preview::{PreviewTarget, preview_kind};
use crate::router::{Location, Section, navigate};
use crate::util::format_size;
use crate::views::file_view::{FileView, UnsupportedTarget};
/// Name rows rendered at once, and how many more each click of the "show
/// more" row reveals. Small on purpose: rendering a row costs a fixed amount
/// of reactive-graph setup, so the initial paint stays well under a frame and
/// the user pays for more rows only by asking for them.
const FILE_PAGE: usize = 100;
/// Name hits kept for paging. The search keeps running and counting past
/// this; hits beyond it are reported as a plain remainder that cannot be
/// paged to, since holding every hit of a one-character query would grow
/// without bound.
const RETAIN_FILE_CAP: usize = 2000;
/// Render cap for match lines per file.
const RENDER_LINE_CAP: usize = 500;
/// Match cards rendered at once, and how many more each click reveals. Lower
/// than [`FILE_PAGE`] because a card is much taller than a row — it carries a
/// header and its matched lines — so a page of them already fills the screen.
const CARD_PAGE: usize = 20;
/// Files with content matches kept for paging. As with [`RETAIN_FILE_CAP`],
/// the search counts past this; the surplus is reported but cannot be paged
/// to.
const RETAIN_MATCH_FILE_CAP: usize = 500;
/// Stream events are applied in batches this many milliseconds apart, not
/// per event: an open search can emit thousands of events and applying them
/// one by one would re-render per event and peg the main thread.
///
/// A flush applies its whole batch, but in feedback-sized chunks that yield
/// to the browser between them (see [`apply_chunked`]) rather than in one go.
const FLUSH_MS: u32 = 120;
/// Target and back-off thresholds for one chunk plus its yield.
///
/// Chunks are sized by feedback, not by a fixed item count: the cost of a row
/// depends on the machine, and a hardcoded count either blocks on a slow one
/// or trickles for seconds on a fast one.
///
/// The signal is the interval between successive resumptions of the render
/// loop, which covers the chunk's own work, the `<For>` pass it triggers, and
/// anything else the browser did in between. Keeping that near
/// [`CHUNK_TARGET_MS`] keeps the main thread free most of the time, so input
/// stays responsive while a large result set fills in.
const CHUNK_TARGET_MS: f64 = 10.0;
const CHUNK_SLOW_MS: f64 = 14.0;
/// Events folded between yields. Fixed rather than adaptive: folding an
/// event is a bounded allocate-and-hash, so unlike rendering it has no
/// machine-dependent cliff worth measuring for.
const EVENTS_PER_SLICE: usize = 2048;
/// First chunk's size, before there is a measurement to go on.
const INITIAL_CHUNK: usize = 64;
/// Chunk bounds. The floor keeps forward progress when every item is
/// expensive; the ceiling stops one chunk from blocking when the interval
/// looks cheap for an unrelated reason.
/// The ceiling matters more than it looks: `<For>` re-runs `each` over the
/// whole list per write, so a write costs roughly `chunk + list_len`. As the
/// list fills, a fixed chunk gets steadily more expensive, and the feedback
/// below can only react *after* a round overran. Capping the chunk caps how
/// bad that one round can be.
const MIN_CHUNK: usize = 16;
const MAX_CHUNK: usize = 128;
/// `performance.now()`, or 0 when there is no window (never in the browser).
fn now_ms() -> f64 {
web_sys::window()
.and_then(|w| w.performance())
.map(|p| p.now())
.unwrap_or(0.0)
}
/// Hands the main thread back to the browser, then resumes.
///
/// A plain macrotask, deliberately, after measuring the two obvious
/// alternatives:
///
/// * `requestAnimationFrame` only fires while the browser is producing
/// frames. Under a throttled compositor it dropped to 2 fps and the result
/// list took tens of seconds to fill; a backgrounded tab would stall it
/// outright.
/// * `scheduler.yield()` resumes at user-blocking priority, ahead of the
/// browser's own rendering update. Chunks then pile up unpainted and style
/// and layout land in one lump at the end — measurably worse (a 110 ms task
/// versus 85 ms, and settle 630 ms versus 390 ms).
///
/// A macrotask lets a rendering update happen between chunks, which is the
/// point. The cost is `setTimeout`'s 4 ms clamp past nesting depth 5.
async fn yield_to_browser() {
TimeoutFuture::new(0).await;
}
/// Next chunk size from the last round's interval.
///
/// Additive increase, multiplicative decrease. Doubling on the way up
/// overshoots: it keeps growing until a chunk finally overruns, and *that*
/// chunk is the visible stall (measured as a 241 ms spike once the size ran
/// away to 1024). Growing an eighth at a time bounds the overshoot, while
/// halving still backs off from a bad round immediately.
fn next_chunk(prev: usize, interval_ms: f64) -> usize {
let next = if interval_ms > CHUNK_SLOW_MS {
prev / 2
} else if interval_ms < CHUNK_TARGET_MS {
prev + (prev / 8).max(8)
} else {
prev
};
next.clamp(MIN_CHUNK, MAX_CHUNK)
}
/// Applies `items` to `write` in chunks, yielding to the browser between
/// them. One signal write per chunk, so a chunk costs one `<For>` pass.
async fn apply_chunked<T, F>(items: Vec<T>, my_gen: u64, ctx: &FlushCtx, mut write: F)
where
F: FnMut(Vec<T>),
{
let mut rest = items;
let mut chunk = INITIAL_CHUNK;
let mut last_round = now_ms();
while !rest.is_empty() {
// A new search (or an unmount) makes the remaining items obsolete.
if ctx.guard.load(Ordering::Relaxed) || ctx.search_gen.load(Ordering::SeqCst) != my_gen {
return;
}
let take = chunk.min(rest.len());
let head: Vec<T> = rest.drain(..take).collect();
write(head);
yield_to_browser().await;
let now = now_ms();
chunk = next_chunk(chunk, now - last_round);
last_round = now;
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Scope {
Name,
Content,
Both,
}
impl Scope {
const fn param(self) -> &'static str {
match self {
Scope::Name => "name",
Scope::Content => "content",
Scope::Both => "both",
}
}
}
/// `text` is an `Arc<str>` because `<For each>` clones the whole line vector
/// on every append; a refcount bump keeps that clone O(1) per line.
#[derive(Clone)]
struct MatchLine {
line: u64,
text: Arc<str>,
}
/// A file with content matches, kept in arrival order. The card's lines
/// live in the view-level registry ([`CardState`]) under the same key; the
/// vector only drives the keyed `<For>`.
///
/// `search_gen` stamps the search generation: the `<For>` key includes it so a new
/// search's items can never match a previous search's nodes (a position
/// match would keep the old node, with the old search's line state).
#[derive(Clone)]
struct MatchFile {
root_id: i64,
path: Arc<str>,
search_gen: u64,
}
/// Live state of a match card, owned by the search view (the registry
/// `RwSignal`), not by the card's `<For>` node: one owner per path, and the
/// lines survive the card being remounted. New lines and collapse toggles
/// only touch the card that owns them.
#[derive(Clone, Copy)]
struct CardState {
lines: RwSignal<Vec<MatchLine>>,
collapsed: RwSignal<bool>,
}
/// `path` is an `Arc<str>` for the same reason as [`MatchLine::text`].
#[derive(Clone)]
struct FileHit {
root_id: i64,
path: Arc<str>,
size: u64,
is_dir: bool,
}
#[derive(Clone, Copy, PartialEq)]
enum Status {
Idle,
Searching,
/// The walk finished.
Done {
scanned: usize,
skipped: usize,
elapsed_ms: u64,
},
/// The user pressed Stop. The connection was aborted before the server
/// could send its summary, so no scan counts are available.
StoppedByUser,
/// The server stopped early (match cap reached) and sent its summary.
Stopped {
scanned: usize,
skipped: usize,
},
}
/// A text fragment: plain or a highlighted hit. Rendered through
/// [`FragView`] so a mixed fragment list has one concrete element type
/// (Leptos 0.8 views are generic).
#[derive(Clone, PartialEq, Eq)]
enum Frag {
Plain(String),
Mark(String),
}
/// Render `text` with every query hit wrapped in `<mark class="hl-hit">`, as
/// one HTML string for `inner_html`.
///
/// One element per text instead of one per fragment. A fragment used to be a
/// `#[component]`, and a component costs a reactive owner plus arena slots:
/// 1000 name rows are ~9000 fragments, which measured at ~700 ms of the
/// render (the same DOM built from plain JS takes 25 ms).
///
/// Both file names and file contents are untrusted, so every character of
/// `text` goes through [`push_escaped`] and the only markup in the output is
/// the `<mark>` this function writes.
fn highlight_html(text: &str, words: &[String]) -> String {
let mut out = String::with_capacity(text.len() + 16);
for f in highlight(text, words) {
match f {
Frag::Plain(s) => push_escaped(&mut out, &s),
Frag::Mark(s) => {
out.push_str("<mark class=\"hl-hit\">");
push_escaped(&mut out, &s);
out.push_str("</mark>");
}
}
}
out
}
/// Append `s` to `out` with the five HTML-significant characters escaped.
fn push_escaped(out: &mut String, s: &str) {
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
}
/// Split `text` into plain/highlighted fragments for the query's words
/// (case-insensitive). Name matches are whole-word ANDs and content matches
/// are the whole phrase, so highlighting each word covers both.
///
/// The text is lowercased once; hits are located in lowercased-char space
/// and mapped back to the original by character index, so case mappings
/// that change the character count (rare, e.g. `İ`) stay correct.
///
/// Worst case is O(words × len²) (the inner search restarts at `pos` for
/// every word); acceptable because it runs once per rendered row, not per
/// flush.
fn highlight(text: &str, words: &[String]) -> Vec<Frag> {
let needles: Vec<Vec<char>> = words
.iter()
.filter(|w| !w.is_empty())
.map(|w| w.to_lowercase().chars().collect())
.collect();
if needles.is_empty() {
return if text.is_empty() {
Vec::new()
} else {
vec![Frag::Plain(text.to_string())]
};
}
let lowered: Vec<char> = text.chars().flat_map(|c| c.to_lowercase()).collect();
// Byte offset just past original character `i`.
let mut byte_end: Vec<usize> = Vec::with_capacity(text.len());
for (b, ch) in text.char_indices() {
byte_end.push(b + ch.len_utf8());
}
// Lowered index -> original char index. Only differs from the identity
// when some character lowercases to more than one character.
let mut l2c: Vec<usize> = Vec::new();
if lowered.len() != byte_end.len() {
for ci in 0..byte_end.len() {
let s = if ci == 0 { 0 } else { byte_end[ci - 1] };
let ch = &text[s..byte_end[ci]];
for _ in ch.to_lowercase().chars() {
l2c.push(ci);
}
}
}
let char_of = |lp: usize| -> usize {
if l2c.is_empty() {
lp
} else {
l2c.get(lp).copied().unwrap_or(byte_end.len())
}
};
// Byte offset of original character `ci` (0 for the first).
let byte_at = |ci: usize| -> usize { if ci == 0 { 0 } else { byte_end[ci - 1] } };
let mut out: Vec<Frag> = Vec::new();
let mut pos = 0usize;
while pos < lowered.len() {
// Earliest hit of any word at or after `pos`.
let mut best: Option<(usize, usize)> = None;
for w in &needles {
let n = w.len();
if pos + n > lowered.len() {
continue;
}
let mut i = pos;
while i + n <= lowered.len() {
if lowered[i..i + n] == w[..] {
if best.is_none_or(|(b, _)| i < b) {
best = Some((i, n));
}
break;
}
i += 1;
}
}
let Some((start, n)) = best else {
break;
};
let s0 = byte_at(char_of(pos));
let s1 = byte_at(char_of(start));
let s2 = byte_at(char_of(start + n));
if s1 > s0 {
out.push(Frag::Plain(text[s0..s1].to_string()));
}
out.push(Frag::Mark(text[s1..s2].to_string()));
pos = start + n;
}
let st = byte_at(char_of(pos));
if st < text.len() {
out.push(Frag::Plain(text[st..].to_string()));
}
out
}
/// Parse `#/search?q=...&scope=...&root=...` (the part after `?`).
///
/// `roots` is still accepted, and its first id taken, so links made when a
/// search could span several roots still open.
fn parse_search_url() -> (String, Scope, Option<i64>) {
let hash = web_sys::window()
.and_then(|w| w.location().hash().ok())
.unwrap_or_default();
let Some(qs) = hash.split_once('?').map(|(_, q)| q) else {
return (String::new(), Scope::Both, None);
};
let mut q = String::new();
let mut scope = Scope::Both;
let mut root = None;
for pair in qs.split('&') {
let (key, val) = match pair.split_once('=') {
Some(p) => p,
None => continue,
};
match key {
"q" => {
q = js_sys::decode_uri_component(val)
.map(String::from)
.unwrap_or_else(|_| val.to_string())
}
"scope" => {
scope = match val {
"name" => Scope::Name,
"content" => Scope::Content,
_ => Scope::Both,
}
}
"root" | "roots" => {
root = val.split(',').next().and_then(|s| s.trim().parse().ok());
}
_ => {}
}
}
(q, scope, root)
}
/// Write the current search into the hash without adding a history entry.
fn set_search_url(q: &str, scope: Scope, root: i64) {
let hash = format!(
"#/search?q={}&scope={}&root={root}",
js_sys::encode_uri_component(q),
scope.param()
);
if let Some(Ok(hist)) = web_sys::window().map(|w| w.history()) {
let _ = hist.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(&hash));
}
}
/// Current vertical window scroll, 0 when there is no window.
fn scroll_y() -> f64 {
web_sys::window()
.and_then(|w| w.scroll_y().ok())
.unwrap_or(0.0)
}
fn scroll_to_y(y: f64) {
if let Some(w) = web_sys::window() {
w.scroll_to_with_x_and_y(0.0, y);
}
}
/// Navigate to the folder `path` sits in, so the hit can be seen in context.
/// A top-level entry goes to the root itself.
fn goto_parent(root_id: i64, path: &str) {
let mut parts = split_rel(path);
parts.pop();
navigate(&Location {
root_id: Some(root_id),
path: parts,
share_token: None,
section: Section::Files,
});
}
/// The value of a `<select>` behind an event, when the target is one.
fn select_value(ev: &web_sys::Event) -> Option<String> {
ev.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
.map(|s| s.value())
}
/// Guess a file's kind from its name (search results carry no sniffed kind;
/// the browser listing does). Unknown extensions open in the "no preview"
/// view rather than the editor, so a misguess is a harmless dead end.
fn kind_of_name(name: &str) -> FileKind {
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
match ext.as_str() {
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" | "ico" => FileKind::Image,
"mp4" | "webm" | "mov" | "mkv" | "avi" => FileKind::Video,
"mp3" | "wav" | "ogg" | "flac" | "m4a" | "opus" => FileKind::Audio,
"pdf" => FileKind::Pdf,
"zip" | "tar" | "gz" | "tgz" | "bz2" | "xz" | "zst" | "7z" | "rar" => FileKind::Archive,
"txt" | "md" | "markdown" | "rs" | "py" | "js" | "mjs" | "ts" | "tsx" | "jsx" | "json"
| "jsonc" | "toml" | "yaml" | "yml" | "ini" | "cfg" | "conf" | "sh" | "bash" | "zsh"
| "c" | "h" | "cpp" | "hpp" | "cc" | "cs" | "go" | "java" | "kt" | "swift" | "rb"
| "php" | "html" | "htm" | "css" | "scss" | "less" | "xml" | "sql" | "csv" | "tsv"
| "log" | "tex" | "lua" | "pl" | "r" | "dart" | "vue" | "svelte" | "proto" | "graphql"
| "dockerfile" | "makefile" | "env" | "gitignore" => FileKind::Text,
_ => FileKind::Binary,
}
}
#[component]
pub fn SearchView(
me: ReadSignal<Option<api_types::Me>>,
open_file: WriteSignal<Option<FileView>>,
// While a file view is open it covers the content area, so the search
// UI hides (the view stays mounted: an in-flight search keeps running).
file_view: ReadSignal<Option<FileView>>,
) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
// ---- state -------------------------------------------------------------
let (query, set_query) = RwSignal::<String>::new(String::new()).split();
let (scope, set_scope) = RwSignal::<Scope>::new(Scope::Both).split();
let (exec_query, set_exec_query) = RwSignal::<String>::new(String::new()).split();
// The one root a search covers. Defaults to the caller's first root; the
// `<select>` re-reads `me` live for the names.
let (sel_root, set_sel_root) = RwSignal::<Option<i64>>::new(
me.get_untracked()
.and_then(|m| m.roots.first().map(|r| r.id)),
)
.split();
let status = RwSignal::new(Status::Idle);
let files_sig = RwSignal::<Vec<FileHit>>::new(Vec::new());
let (files_r, files_w) = files_sig.split();
let files_total = RwSignal::new(0usize);
// How many of the retained name hits are rendered. Grows a page at a
// time when the user clicks the "show more" row.
let shown_files = RwSignal::new(FILE_PAGE);
let shown_cards = RwSignal::new(CARD_PAGE);
let match_files_sig = RwSignal::<Vec<MatchFile>>::new(Vec::new());
let (match_files_r, match_files_w) = match_files_sig.split();
let matches_total = RwSignal::new(0usize);
// True count of files that had at least one match (the rendered list is
// capped), plus the set of seen paths. Non-reactive: the set only feeds
// a counter, and it outlives the flush batch it is used in.
let match_files_total = RwSignal::new(0usize);
let seen_match_files: Arc<Mutex<std::collections::HashSet<Arc<str>>>> =
Arc::new(Mutex::new(std::collections::HashSet::new()));
// Per-path card state for this view: created in `apply_batch` when a
// path is first seen, read by the card's `<For>` child, removed on card
// unmount. One owner per path, so the lines survive the card being
// remounted. Component-scoped (no static): it is disposed with the view
// and reset on every new search.
let registry: RwSignal<HashMap<Arc<str>, CardState>> = RwSignal::new(HashMap::new());
// The in-flight AbortController, so Stop and unmount can both reach it.
// Deliberately *not* reactive: the fetch task outlives the component and
// touching a disposed signal panics.
let abort_box: Arc<Mutex<Option<web_sys::AbortController>>> = Arc::new(Mutex::new(None));
// Set on unmount so a stream event arriving one tick late cannot touch
// disposed signals.
let guard: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));
// Stream events are buffered and applied in batches (see FLUSH_MS);
// `gen` is bumped on every new search so a stale flush from a previous
// run cannot leak its batch into the fresh state.
let pending: Arc<Mutex<Vec<SearchEvent>>> = Arc::new(Mutex::new(Vec::new()));
let flushing: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));
let search_gen: Arc<AtomicU64> = Arc::new(AtomicU64::new(0));
// ---- actions -----------------------------------------------------------
let stop_search = Callback::new({
let abort_box = abort_box.clone();
let pending = pending.clone();
let search_gen = search_gen.clone();
move |_| {
if let Some(c) = abort_box.lock().unwrap().take() {
c.abort();
}
// Stop should stop the UI too: discard buffered events and
// invalidate the in-flight flush so nothing keeps landing. The
// totals are left alone — they count what the server found, and
// the "N more" rows report the difference to what is rendered.
pending.lock().unwrap().clear();
search_gen.fetch_add(1, Ordering::SeqCst);
// The server can no longer send its `Done` summary (the
// connection is gone), so settle the UI here.
status.set(Status::StoppedByUser);
}
});
// Buffer stream events, apply them in batches. The first buffered event
// schedules the flush; a late flush for an unmounted view or a replaced
// search is discarded.
let flush_ctx = FlushCtx {
pending: pending.clone(),
flushing: flushing.clone(),
guard: guard.clone(),
search_gen: search_gen.clone(),
files: files_sig,
files_total,
match_files: match_files_sig,
matches_total,
match_files_total,
seen: seen_match_files.clone(),
registry,
status,
};
let on_stream_event = Callback::new({
let ctx = flush_ctx;
move |ev: SearchEvent| {
ctx.pending.lock().unwrap().push(ev);
if ctx
.flushing
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return; // a flush is already pending
}
let my_gen = ctx.search_gen.load(Ordering::SeqCst);
wasm_bindgen_futures::spawn_local(run_flush(FLUSH_MS, my_gen, ctx.clone()));
}
});
let start_search = Callback::new({
let stop = stop_search;
let abort_box = abort_box.clone();
let guard = guard.clone();
move |()| {
// Untracked reads: starting a search must never register reactive
// dependencies (this callback can run from any context).
let q = query.get_untracked();
let scope = scope.get_untracked();
if q.trim().is_empty() {
return;
}
stop.run(()); // stop any in-flight search first
*abort_box.lock().unwrap() = None;
pending.lock().unwrap().clear();
search_gen.fetch_add(1, Ordering::SeqCst);
// Exactly one root. Falls back to the first available when
// nothing is selected yet (a deep link with an unknown id, or
// `me` not loaded when the view was built).
let Some(root) = sel_root.get_untracked().or_else(|| {
me.get_untracked()
.and_then(|m| m.roots.first().map(|r| r.id))
}) else {
return;
};
let roots = vec![root];
set_sel_root.set(Some(root));
// Fresh state for this run.
files_w.set(Vec::new());
files_total.set(0);
shown_files.set(FILE_PAGE);
shown_cards.set(CARD_PAGE);
match_files_w.set(Vec::new());
matches_total.set(0);
match_files_total.set(0);
seen_match_files.lock().unwrap().clear();
registry.set(HashMap::new());
status.set(Status::Searching);
set_search_url(&q, scope, root);
set_exec_query.set(q.clone());
let ctrl = match api::search_stream(
q.clone(),
scope.param(),
&roots,
on_stream_event,
on_error_for(toast, status, guard.clone()),
) {
Ok(c) => c,
Err(e) => {
show_error(toast, e.to_string());
status.set(Status::Idle);
return;
}
};
*abort_box.lock().unwrap() = Some(ctrl);
}
});
// The search view's owner is the shell's view tree; a stream event can
// arrive on the main thread after unmount, so the stream-side callbacks
// bail on the (non-reactive) guard, and the fetch is aborted here.
{
let abort_box = abort_box.clone();
let g = guard.clone();
on_cleanup(move || {
g.store(true, Ordering::Relaxed);
if let Some(c) = abort_box.lock().unwrap().take() {
c.abort();
}
});
}
// Collapse/expand all cards. Whether the group is fully collapsed is
// derived from the cards themselves (no counter to keep in sync).
let collapse_all: Callback<(), ()> = {
Callback::new(move |_| {
// Copy the states out before writing them: writing an inner
// signal while the registry's own borrow is held would rely on
// leptos deferring effects to a microtask.
let states: Vec<CardState> = registry.with_untracked(|m| m.values().copied().collect());
let target = !(!states.is_empty() && states.iter().all(|c| c.collapsed.get_untracked()));
for c in states {
c.collapsed.set(target);
}
})
};
// Enter in the query field starts the search.
let on_query_key = move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Enter" {
start_search.run(());
}
};
// Deep link: `#/search?q=...` restores and re-runs the search.
{
let (q0, s0, r0) = parse_search_url();
if !q0.is_empty() {
set_query.set(q0);
set_scope.set(s0);
// Only honour a root the caller actually has; otherwise keep the
// default so a stale link still searches something.
if let Some(id) = r0
&& me
.get_untracked()
.is_some_and(|m| m.roots.iter().any(|r| r.id == id))
{
set_sel_root.set(Some(id));
}
// Run the search from setup (an untracked context), not from an
// `Effect`: the effect would track `start_search`'s signal reads
// and retrigger on the very signal it writes (`sel_root`),
// re-running the search in a loop and pegging the main thread.
start_search.run(());
}
}
// Keep the scroll position across opening a result. The window is the
// scroller, and while a file is open the search UI is hidden, so the
// document shrinks to one viewport and the browser clamps the scroll to
// 0. Saving it on open and restoring on close is the only way back —
// hiding rather than unmounting is necessary but not sufficient.
// Saved by the handlers that open a result, *not* by the effect below:
// hiding the search UI is itself an effect, and if it ran first the
// scroll was already clamped to 0 by the time we read it.
let saved_scroll = StoredValue::new(0.0f64);
Effect::new(move |was_open: Option<bool>| {
let open = file_view.get().is_some();
if was_open == Some(true) && !open {
let y = saved_scroll.get_value();
// Only once the display flip has been laid out; before that the
// document is one viewport tall and the scroll is clamped away
// again.
wasm_bindgen_futures::spawn_local(async move {
yield_to_browser().await;
scroll_to_y(y);
});
}
open
});
// One delegated handler for the whole name list, instead of a `Callback`
// per row: a callback is an arena entry plus a closure allocation, and at
// a thousand rows that is a measurable slice of the render. The row
// carries what the handler needs in data attributes.
let on_row_click = move |ev: web_sys::MouseEvent| {
let Some(target) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::Element>().ok())
else {
return;
};
let Some(row) = target.closest(".srow").ok().flatten() else {
return;
};
let Some(path) = row.get_attribute("data-path") else {
return;
};
let Some(root_id) = row
.get_attribute("data-root")
.and_then(|s| s.parse::<i64>().ok())
else {
return;
};
// The goto button is inside the row, so it is handled here rather
// than with its own callback per row.
if target.closest(".goto-btn").ok().flatten().is_some() {
goto_parent(root_id, &path);
return;
}
if row.get_attribute("data-dir").is_some() {
navigate(&Location {
root_id: Some(root_id),
path: split_rel(&path),
share_token: None,
section: Section::Files,
});
return;
}
let is_rw = me
.get_untracked()
.map(|m| {
m.roots
.iter()
.find(|r| r.id == root_id)
.map(|r| r.mode.is_writable())
.unwrap_or(false)
})
.unwrap_or(false);
let name = path.rsplit('/').next().unwrap_or(&path).to_string();
let kind = kind_of_name(&name);
saved_scroll.set_value(scroll_y());
open_file.set(Some(open_file_view(root_id, path, name, kind, is_rw)));
};
// ---- render ------------------------------------------------------------
view! {
// Hidden with `display` rather than unmounted while a file is open
// (the same treatment the browser view gets). Unmounting would throw
// away every rendered row and rebuild it on close, and the document
// would lose its scroll position — see `keep_scroll` above.
<div
class="search-view"
style:display=move || if file_view.get().is_some() { "none" } else { "flex" }
>
<div class="query-bar">
<div class="query-input">
<Icon name=IconName::Search class="qi-icon".to_string()/>
<input
type="text"
placeholder=move || i18n::t(k::SEARCH_PLACEHOLDER).to_string()
value=move || query.get()
on:input=move |ev: web_sys::Event| {
if let Some(t) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
set_query.set(t.value());
}
}
on:keydown=on_query_key
autocomplete="off"
/>
</div>
<div class="seg">
<button
class:on=move || scope.get() == Scope::Name
on:click=move |_| set_scope.set(Scope::Name)
>
{i18n::tr(k::SEARCH_NAME)}
</button>
<button
class:on=move || scope.get() == Scope::Content
on:click=move |_| set_scope.set(Scope::Content)
>
{i18n::tr(k::SEARCH_CONTENT)}
</button>
<button
class:on=move || scope.get() == Scope::Both
on:click=move |_| set_scope.set(Scope::Both)
>
{i18n::tr(k::SEARCH_BOTH)}
</button>
</div>
// A search covers exactly one root. A native `<select>`, as
// everywhere else in the app: the browser gives keyboard
// handling, and there is no "all" entry to misread.
<select
class="root-select"
on:change=move |ev: web_sys::Event| {
if let Some(id) = select_value(&ev).and_then(|v| v.parse::<i64>().ok()) {
set_sel_root.set(Some(id));
}
}
>
{move || {
let cur = sel_root.get();
me.get()
.map(|m| m.roots)
.unwrap_or_default()
.into_iter()
.map(|r| {
view! {
<option value=r.id.to_string() selected=cur == Some(r.id)>
{r.name}
</option>
}
})
.collect::<Vec<_>>()
}}
</select>
{move || {
if status.get() == Status::Searching {
view! {
<button class="btn-stop" on:click=move |_| stop_search.run(())>
<Icon name=IconName::Stop class="ic-stop".to_string()/>
{i18n::t(k::SEARCH_STOP)}
</button>
}
.into_view()
.into_any()
} else {
view! {
<button class="btn-run" on:click=move |_| start_search.run(())>
{i18n::t(k::SEARCH_RUN)}
</button>
}
.into_view()
.into_any()
}
}}
</div>
{move || match status.get() {
Status::Idle => view! {}.into_view().into_any(),
Status::Searching => view! {
<div class="status-line">
<span class="dot"></span>
<span>
{i18n::t(k::SEARCHING)}
{" · "}
{i18n::t_fmt(
k::SEARCH_N_RESULTS,
&(files_total.get() + matches_total.get()).to_string(),
)}
</span>
</div>
}
.into_view()
.into_any(),
Status::Done { scanned, skipped, elapsed_ms } => view! {
<div class="status-line finished">
<span>
{i18n::t_fmt(k::SEARCH_N_RESULTS, &(files_total.get() + matches_total.get()).to_string())}
{" · "}
{i18n::t_fmt(k::SEARCH_TOOK, &elapsed_ms.to_string())}
{" · "}
{i18n::t_fmt(k::SEARCH_SCANNED, &scanned.to_string())}
{move || {
if skipped > 0 {
view! {
<span class="skip">
{" · "}
{i18n::t_fmt(k::SEARCH_SKIPPED, &skipped.to_string())}
</span>
}
.into_view()
.into_any()
} else {
view! {}.into_view().into_any()
}
}}
</span>
</div>
}
.into_view()
.into_any(),
Status::StoppedByUser => view! {
<div class="status-line finished">
<span>
{i18n::t_fmt(
k::SEARCH_STOPPED,
&(files_total.get() + matches_total.get()).to_string(),
)}
</span>
</div>
}
.into_view()
.into_any(),
Status::Stopped { scanned, skipped } => view! {
<div class="status-line finished">
<span>
{i18n::t_fmt(k::SEARCH_STOPPED, &(files_total.get() + matches_total.get()).to_string())}
{" · "}
{i18n::t_fmt(k::SEARCH_SCANNED, &scanned.to_string())}
{move || {
if skipped > 0 {
view! {
<span class="skip">
{" · "}
{i18n::t_fmt(k::SEARCH_SKIPPED, &skipped.to_string())}
</span>
}
.into_view()
.into_any()
} else {
view! {}.into_view().into_any()
}
}}
</span>
</div>
}
.into_view()
.into_any(),
}}
// The groups are hidden with `display` instead of unmounted:
// toggling scope must not rebuild the rows/cards (and the cards'
// line signals live in the registry, but keeping the DOM stable
// makes the toggle a single attribute write).
<div
class="result-group"
style:display=move || {
let show = matches!(scope.get(), Scope::Name | Scope::Both)
&& (files_total.get() > 0 || status.get() != Status::Idle);
if show {
"flex"
} else {
"none"
}
}
>
<div class="group-label">
<Icon name=IconName::File class="gl-icon".to_string()/>
<span>{i18n::tr(k::FILES)}</span>
<span class="n">{"· "}{files_total}</span>
</div>
<div class="entries-list" on:click=on_row_click>
<For
// Slice inside `with`, so a page's worth is cloned
// rather than every retained hit.
each=move || {
let n = shown_files.get();
files_r.with(|v| v.iter().take(n).cloned().collect::<Vec<_>>())
}
key=move |h: &FileHit| (h.root_id, h.path.clone())
children=move |h: FileHit| {
// Note: only the first `shown_files` hits reach
// here; `each` above slices before cloning.
let (dir, name) = split_name(&h.path);
let words = query_words(&exec_query.get_untracked());
let icon = if h.is_dir {
IconName::Folder
} else {
icon_for(kind_of_name(name), name)
};
// Only the name is highlighted. The server matches
// the entry's own name, not its path, so a mark
// in the directory line would point at text that
// was never matched.
let name_h = highlight_html(name, &words);
let dir_s = dir.to_string();
let size = if h.is_dir {
"—".to_string()
} else {
format_size(h.size)
};
// `icon_svg`, not `<Icon/>`: no component owner
// per row. Click handling is delegated to the
// list (see `on_row_click`), so the row only
// carries data attributes.
view! {
<div
class="srow"
data-root=h.root_id.to_string()
data-path=h.path.to_string()
data-dir=h.is_dir.then_some("")
>
{icon_svg(icon, "srow-icon")}
<div class="srow-main">
<span class="srow-name" inner_html=name_h></span>
<span class="srow-dir">{dir_s}</span>
</div>
<span class="ssize">{size}</span>
<button
class="goto-btn"
title=move || i18n::t(k::SEARCH_GOTO).to_string()
aria-label=move || i18n::t(k::SEARCH_GOTO).to_string()
>
{icon_svg(IconName::FolderOpen, "goto-ic")}
</button>
</div>
}
}
/>
{move || {
let retained = files_r.with(|v| v.len());
page_footer(
shown_files.get().min(retained),
retained,
files_total.get(),
FILE_PAGE,
move || shown_files.update(|n| *n += FILE_PAGE),
)
}}
</div>
</div>
<div
class="result-group"
style:display=move || {
let show = matches!(scope.get(), Scope::Content | Scope::Both)
&& (!match_files_r.get().is_empty() || status.get() != Status::Idle);
if show {
"flex"
} else {
"none"
}
}
>
<div class="group-label">
<Icon name=IconName::Text class="gl-icon".to_string()/>
<span>{i18n::tr(k::SEARCH_CONTENT)}</span>
// Closures, not bare calls: these have to re-read the
// totals as the search streams in.
<span class="n">
{"· "}
{move || {
i18n::t_fmt(k::SEARCH_N_MATCHES, &matches_total.get().to_string())
}}
{" "}
{move || {
i18n::t_fmt(k::SEARCH_IN_N_FILES, &match_files_total.get().to_string())
}}
</span>
<span class="grow"></span>
<button class="collapse-all" on:click=move |_| collapse_all.run(())>
{move || {
let all_collapsed = registry.with(|m| {
!m.is_empty()
&& m.values().all(|c| c.collapsed.get())
});
i18n::t(if all_collapsed {
k::SEARCH_EXPAND_ALL
} else {
k::SEARCH_COLLAPSE_ALL
})
}}
</button>
</div>
<For
// Sliced inside `with`, as for the name rows: a page's
// worth is cloned rather than every retained match file.
each=move || {
let n = shown_cards.get();
match_files_r.with(|v| v.iter().take(n).cloned().collect::<Vec<_>>())
}
key=move |f: &MatchFile| (f.search_gen, f.root_id, f.path.clone())
children=move |f: MatchFile| {
// The card's live state is created in `apply_batch`
// (when the path was first seen) and owned by the
// view-level registry: one owner per path, and the
// lines survive this node being remounted (e.g. by a
// keyed reorder mid-search). The child only reads
// the entry; it never removes it, so an unmount
// racing a new search cannot delete a fresh entry.
// Entries of a finished search are wiped by the next
// `start_search` or with the view itself.
//
// The entry is always there (`apply_batch` inserts it
// before the path reaches `match_files`), but render
// an empty card rather than panic the whole view if
// that ordering ever changes.
let Some(st) = registry.with_untracked(|m| m.get(&f.path).copied()) else {
return view! {}.into_view().into_any();
};
let lines_r = st.lines;
let collapsed = st.collapsed;
let key = f.path.clone();
let root_id = f.root_id;
let (dir, name) = split_name(&f.path);
let icon = icon_for(kind_of_name(name), name);
// The card's path is *not* highlighted: a content hit
// was found in the file's text, not in its name, so
// marking the name would claim a match that is not
// there and duplicate what the name results show.
let dir_s = dir.to_string();
let name_s = name.to_string();
// One open-callback per card (Copy). The header opens
// the file; the matched lines are text to read, not
// buttons.
let open_file_cb = open_match_cb(root_id, key.clone(), me, open_file);
let goto_path = key.clone();
view! {
<div class="match-card">
// The whole header opens the file, so the two
// buttons in it stop their clicks here.
<div class="match-head" on:click=move |_| {
saved_scroll.set_value(scroll_y());
open_file_cb.run(());
}>
{icon_svg(icon, "mi")}
<span class="mpath">
<span>{dir_s}</span>
{" / "}
<b>{name_s}</b>
</span>
<span class="mcount">
{move || {
let n = lines_r.with(|v| v.len());
i18n::t_fmt(k::SEARCH_N_MATCHES, &n.to_string())
}}
</span>
<button
class="goto-btn"
title=move || i18n::t(k::SEARCH_GOTO).to_string()
aria-label=move || i18n::t(k::SEARCH_GOTO).to_string()
on:click=move |ev: web_sys::MouseEvent| {
ev.stop_propagation();
goto_parent(root_id, &goto_path);
}
>
{icon_svg(IconName::FolderOpen, "goto-ic")}
</button>
<button
class="chev"
on:click=move |ev: web_sys::MouseEvent| {
ev.stop_propagation();
collapsed.update(|v| *v = !*v);
}
>
<span class=move || {
if collapsed.get() {
"chev chev-up".to_string()
} else {
"chev chev-down".to_string()
}
}></span>
</button>
</div>
<div
class="match-lines"
style:display=move || {
if collapsed.get() {
"none"
} else {
"block"
}
}
>
<For
each=move || lines_r.get().into_iter().enumerate()
key=move |p: &(usize, MatchLine)| (p.1.line, p.0)
children=move |p: (usize, MatchLine)| {
let l = p.1;
let words =
query_words(&exec_query.get_untracked());
let text_h = highlight_html(&l.text, &words);
view! {
<div class="mline">
<span class="ln">{l.line}</span>
<span class="tx" inner_html=text_h></span>
</div>
}
}
/>
</div>
</div>
}
.into_view()
.into_any()
}
/>
{move || {
let retained = match_files_r.with(|v| v.len());
page_footer(
shown_cards.get().min(retained),
retained,
match_files_total.get(),
CARD_PAGE,
move || shown_cards.update(|n| *n += CARD_PAGE),
)
}}
</div>
{move || {
let done = matches!(
status.get(),
Status::Done { .. } | Status::Stopped { .. } | Status::StoppedByUser
);
if done && files_total.get() == 0 && matches_total.get() == 0 {
view! {
<div class="search-empty">{i18n::t(k::SEARCH_NO_RESULTS)}</div>
}
.into_view()
.into_any()
} else {
view! {}.into_view().into_any()
}
}}
</div>
}
}
/// Everything a flush needs: the shared event queue and the view signals.
#[derive(Clone)]
struct FlushCtx {
pending: Arc<Mutex<Vec<SearchEvent>>>,
flushing: Arc<AtomicBool>,
guard: Arc<AtomicBool>,
search_gen: Arc<AtomicU64>,
files: RwSignal<Vec<FileHit>>,
files_total: RwSignal<usize>,
match_files: RwSignal<Vec<MatchFile>>,
matches_total: RwSignal<usize>,
match_files_total: RwSignal<usize>,
seen: Arc<Mutex<std::collections::HashSet<Arc<str>>>>,
registry: RwSignal<HashMap<Arc<str>, CardState>>,
status: RwSignal<Status>,
}
/// Waits `delay_ms`, then applies every event buffered in the meantime.
///
/// `flushing` is held for the whole (yielding) apply, so only one flush ever
/// writes the view state; events arriving during it buffer in `pending` and
/// the next flush picks them up right away instead of waiting `FLUSH_MS`.
async fn run_flush(delay_ms: u32, my_gen: u64, ctx: FlushCtx) {
let _ = TimeoutFuture::new(delay_ms).await;
let stale =
|| ctx.guard.load(Ordering::Relaxed) || ctx.search_gen.load(Ordering::SeqCst) != my_gen;
if stale() {
ctx.flushing.store(false, Ordering::SeqCst);
return;
}
let batch: Vec<SearchEvent> = ctx.pending.lock().unwrap().drain(..).collect();
if !batch.is_empty() {
apply_batch(&ctx, batch, my_gen).await;
}
ctx.flushing.store(false, Ordering::SeqCst);
// Applying yields, so more events may have arrived meanwhile. They are
// already `FLUSH_MS` old, so pick them up on the next frame.
if stale() || ctx.pending.lock().unwrap().is_empty() {
return;
}
if ctx
.flushing
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
{
wasm_bindgen_futures::spawn_local(run_flush(0, my_gen, ctx.clone()));
}
}
/// Folds one batch into the view state with a single render per signal:
/// events are aggregated into locals first, then each signal is written
/// once. (Writing per event would trigger a re-render per event, which is
/// what froze the page.)
///
/// A file seen for the first time gets its registry entry here, before its
/// path reaches `match_files`, so the card's `<For>` child always finds it.
/// Lines for a file that already has a card are appended to that card's own
/// line signal, which leaves every other card's DOM untouched.
///
/// Counters and the finished status are applied at once — they are cheap and
/// the status line should tell the truth immediately. Only the parts that
/// build DOM go through [`apply_chunked`], which yields between chunks.
async fn apply_batch(ctx: &FlushCtx, batch: Vec<SearchEvent>, my_gen: u64) {
let mut new_files: Vec<FileHit> = Vec::new();
// Rows still allowed in the name list: it must never grow past the cap,
// no matter how events are batched. `with_untracked` is a signposted
// non-reactive, clone-free read of the length (this runs outside any
// tracking context).
let mut file_budget = RETAIN_FILE_CAP.saturating_sub(ctx.files.with_untracked(|v| v.len()));
let mut file_count = 0usize;
let mut mt = 0usize;
let mut mft = 0usize;
let mut new_match_files: Vec<MatchFile> = Vec::new();
let mut mf_len = ctx.match_files.with_untracked(|v| v.len());
// Generation stamp for the card keys (see `MatchFile::search_gen`).
let search_gen = ctx.search_gen.load(Ordering::SeqCst);
let mut done: Option<Status> = None;
// Group the batch's match events per file, so a new card is seeded with
// all of its lines at once. Grouped through an index rather than by
// comparing against the last entry: several walker threads share one
// channel, so two files grepped concurrently interleave in the stream.
let mut acc: Vec<(i64, Arc<str>, Vec<MatchLine>)> = Vec::new();
let mut acc_at: HashMap<Arc<str>, usize> = HashMap::new();
{
// Folded in slices with a yield between them. This loop allocates and
// hashes a path per *event*, and a one-character query can emit tens
// of thousands of them, so its cost tracks events received rather
// than items rendered — it needs its own yield points, independent of
// `apply_chunked` below. (Measured as a 133 ms task before this.)
let mut since_yield = 0usize;
for ev in batch {
since_yield += 1;
if since_yield >= EVENTS_PER_SLICE {
since_yield = 0;
yield_to_browser().await;
if ctx.guard.load(Ordering::Relaxed)
|| ctx.search_gen.load(Ordering::SeqCst) != my_gen
{
return;
}
}
match ev {
SearchEvent::Done {
stopped,
files: _,
matches: _,
scanned,
skipped,
elapsed_ms,
} => {
done = Some(if stopped {
Status::Stopped { scanned, skipped }
} else {
Status::Done {
scanned,
skipped,
elapsed_ms,
}
});
}
SearchEvent::File {
root_id,
path,
size,
is_dir,
} => {
file_count += 1;
if file_budget > 0 {
file_budget -= 1;
new_files.push(FileHit {
root_id,
path: path.into(),
size,
is_dir,
});
}
}
SearchEvent::Match {
root_id,
path,
line,
text,
} => {
mt += 1;
let path: Arc<str> = path.into();
// Locked per event rather than for the whole fold: the
// fold yields, and holding the guard across an await
// would keep it locked over a suspension point.
if ctx.seen.lock().unwrap().insert(path.clone()) {
mft += 1;
}
let ml = MatchLine {
line,
text: text.into(),
};
match acc_at.get(&path) {
Some(&i) => acc[i].2.push(ml),
None => {
acc_at.insert(path.clone(), acc.len());
acc.push((root_id, path, vec![ml]));
}
}
}
}
}
}
// One owner per path: a card's lines live in the registry, not in the
// card's `<For>` node, so remounting a card never loses them. Appends to
// existing cards are collected and applied after the registry's borrow
// is released — writing an inner signal from inside `update` would rely
// on leptos deferring effects to a microtask.
let mut appends: Vec<(CardState, Vec<MatchLine>)> = Vec::new();
ctx.registry.update(|reg| {
for (root_id, path, mut lines) in acc {
match reg.get(&path) {
Some(st) => appends.push((*st, lines)),
None => {
if mf_len >= RETAIN_MATCH_FILE_CAP {
// The file will never get a card; its matches are
// counted above and dropped here.
continue;
}
// Seed a new card's collapse state from the group's
// current all-collapsed state, so a live "collapse all"
// is not undone by arriving cards.
let all_collapsed =
!reg.is_empty() && reg.values().all(|c| c.collapsed.get_untracked());
lines.truncate(RENDER_LINE_CAP);
reg.insert(
path.clone(),
CardState {
lines: RwSignal::new(lines),
collapsed: RwSignal::new(all_collapsed),
},
);
mf_len += 1;
new_match_files.push(MatchFile {
root_id,
path,
search_gen,
});
}
}
}
});
// Cheap, so applied immediately: the counters and the finished status.
if file_count > 0 {
ctx.files_total.update(|n| *n += file_count);
}
if mt > 0 {
ctx.matches_total.update(|n| *n += mt);
if mft > 0 {
ctx.match_files_total.update(|n| *n += mft);
}
}
if let Some(s) = done {
ctx.status.set(s);
}
// Expensive, so chunked: everything that builds DOM. Cards first, so a
// file's lines have somewhere to land, then the lines, then the name
// rows.
let files = ctx.files;
let match_files = ctx.match_files;
apply_chunked(new_match_files, my_gen, ctx, move |head| {
match_files.update(|v| v.extend(head));
})
.await;
for (st, lines) in appends {
let room = RENDER_LINE_CAP.saturating_sub(st.lines.with_untracked(|v| v.len()));
if room == 0 {
continue;
}
let lines: Vec<MatchLine> = lines.into_iter().take(room).collect();
apply_chunked(lines, my_gen, ctx, move |head| {
st.lines.update(|v| v.extend(head));
})
.await;
}
apply_chunked(new_files, my_gen, ctx, move |head| {
files.update(|v| v.extend(head));
})
.await;
}
fn on_error_for(
toast: ToastMsg,
status: RwSignal<Status>,
guard: Arc<AtomicBool>,
) -> Callback<String, ()> {
Callback::new(move |msg| {
if guard.load(Ordering::Relaxed) {
return;
}
show_error(toast, msg);
status.set(Status::Idle);
})
}
fn open_file_view(
root_id: i64,
path: String,
name: String,
kind: FileKind,
is_rw: bool,
) -> FileView {
if kind == FileKind::Text {
return FileView::Editor(EditTarget {
root_id,
path,
name,
readonly: !is_rw,
});
}
match preview_kind(kind) {
Some(pk) => FileView::Preview(
PreviewTarget {
root_id,
path,
name,
},
pk,
),
None => FileView::Unsupported(UnsupportedTarget {
root_id,
path,
name,
kind,
}),
}
}
/// Open a content match: always a file. One callback per card, shared by all
/// of its lines. Free function rather than a component closure so the card's
/// `<For>` children closure only captures `Copy` signal handles (the
/// surrounding closures must be re-runnable, and `move` closures may only
/// move `Copy` captures out of that environment).
fn open_match_cb(
root_id: i64,
path: Arc<str>,
me: ReadSignal<Option<api_types::Me>>,
open_file: WriteSignal<Option<FileView>>,
) -> Callback<()> {
Callback::new(move |_| {
let is_rw = me
.get_untracked()
.map(|m| {
m.roots
.iter()
.find(|r| r.id == root_id)
.map(|r| r.mode.is_writable())
.unwrap_or(false)
})
.unwrap_or(false);
let name = path.rsplit('/').next().unwrap_or(&path).to_string();
let kind = kind_of_name(&name);
let fv = open_file_view(root_id, path.to_string(), name, kind, is_rw);
open_file.set(Some(fv));
})
}
/// Footer under a result list: where the user is in the results, and a button
/// to reveal the next page.
///
/// `shown` of `total` is stated plainly rather than only counting what is
/// hidden — "Showing 100 of 6027" answers "where am I?", which a bare
/// "5927 more…" does not. The button appears only while more results are
/// actually retained; past [`RETAIN_FILE_CAP`] the count is all that is left
/// to say, so the footer becomes a plain line.
///
/// Returns nothing at all when everything found is on screen.
fn page_footer(
shown: usize,
retained: usize,
total: usize,
page: usize,
reveal: impl Fn() + 'static,
) -> AnyView {
if total <= shown {
return view! {}.into_view().into_any();
}
let label = i18n::t_fmt2(k::SEARCH_SHOWN_OF, &shown.to_string(), &total.to_string());
let pageable = retained.saturating_sub(shown);
if pageable == 0 {
return view! { <div class="page-foot">{label}</div> }
.into_view()
.into_any();
}
let next = pageable.min(page);
view! {
<div class="page-foot">
<span class="pf-count">{label}</span>
<button class="pf-more" on:click=move |_| reveal()>
{i18n::t_fmt(k::SEARCH_SHOW_MORE, &next.to_string())}
</button>
</div>
}
.into_view()
.into_any()
}
/// Lowercased whitespace-split words of the query.
fn query_words(query: &str) -> Vec<String> {
query.split_whitespace().map(|w| w.to_lowercase()).collect()
}
fn split_rel(path: &str) -> Vec<String> {
path.split('/')
.filter(|s| !s.is_empty())
.map(String::from)
.collect()
}
/// `("docs/notes", "runbook.md")` from a relative path.
fn split_name(path: &str) -> (&str, &str) {
match path.rfind('/') {
Some(i) if i > 0 => (&path[..i], &path[i + 1..]),
_ => ("", path),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frags(f: &[Frag]) -> Vec<String> {
f.iter()
.map(|f| match f {
Frag::Plain(s) => format!("={s}="),
Frag::Mark(s) => format!("<{s}>"),
})
.collect()
}
#[test]
fn highlight_marks_hits() {
let w = vec!["world".to_string()];
assert_eq!(
frags(&highlight("Hello world", &w)),
vec!["=Hello =", "<world>"]
);
}
#[test]
fn highlight_is_case_insensitive() {
let w = vec!["hello".to_string()];
assert_eq!(
frags(&highlight("HeLLo there", &w)),
vec!["<HeLLo>", "= there="]
);
}
#[test]
fn highlight_marks_all_words() {
let w = vec!["foo".to_string(), "bar".to_string()];
assert_eq!(
frags(&highlight("foo bar foo", &w)),
vec!["<foo>", "= =", "<bar>", "= =", "<foo>"]
);
}
#[test]
fn highlight_no_hit_is_plain() {
let w = vec!["xyz".to_string()];
assert_eq!(frags(&highlight("abc", &w)), vec!["=abc="]);
}
#[test]
fn highlight_empty_words_is_plain() {
assert_eq!(frags(&highlight("abc", &[])), vec!["=abc="]);
assert!(highlight("", &["a".to_string()]).is_empty());
}
#[test]
fn highlight_html_escapes_markup() {
// File names and file contents are untrusted: the only markup in the
// output must be the `<mark>` wrapper.
let w = vec!["script".to_string()];
assert_eq!(
highlight_html("<script>alert(1)</script>", &w),
"<<mark class=\"hl-hit\">script</mark>>alert(1)</\
<mark class=\"hl-hit\">script</mark>>"
);
// Escaping applies inside the highlighted span too.
assert_eq!(
highlight_html("a<b", &["<b".to_string()]),
"a<mark class=\"hl-hit\"><b</mark>"
);
assert_eq!(
highlight_html(r#"& " ' < >"#, &[]),
"& " ' < >"
);
}
#[test]
fn highlight_handles_non_ascii() {
// Ö (2 bytes) and ö (2 bytes) vs. the ASCII around them: the hit
// slices must follow character boundaries of the original text.
let w = vec!["ö".to_string()];
let out = highlight("AÖ aö b", &w);
assert_eq!(frags(&out), vec!["=A=", "<Ö>", "= a=", "<ö>", "= b="]);
// The plain and mark fragments must reassemble the input.
let joined: String = out
.iter()
.map(|f| match f {
Frag::Plain(s) | Frag::Mark(s) => s.as_str(),
})
.collect();
assert_eq!(joined, "AÖ aö b");
}
}
Mweb/src/views/shell.rs
@@ -14,6 +14,7 @@ use crate::views::admin::{SettingsView, UsersView};
use crate::views::browser::{Browser, SelectionActions, effective_root};
use crate::views::dialogs::{Dialog, DialogView};
use crate::views::file_view::{FileView, FileViewActions, FileViewContent, FileViewTitle};
use crate::views::search::SearchView;
use crate::views::shares::SharesView;
/// Whether the signed-in user has admin rights.
@@ -261,6 +262,14 @@ pub fn ShellView(
<Icon name=IconName::Refresh class="ic-btn".to_string()/>
</button>
</div>
<button
class="nav-tab"
class:active=move || loc.get().section == Section::Search
on:click=move |_| open_section(Section::Search)
>
<Icon name=IconName::Search class="ic-tab".to_string()/>
<span class="nav-tab-name">{i18n::tr(i18n::k::SEARCH)}</span>
</button>
{move || {
let Some(m) = me.get() else {
return {
@@ -405,6 +414,15 @@ pub fn ShellView(
}
.into_view()
.into_any(),
Section::Search => view! {
<SearchView
me=me
open_file=set_file_view
file_view=file_view
/>
}
.into_view()
.into_any(),
Section::Shares => view! { <SharesView/> }
.into_view()
.into_any(),