diff --git a/patches/@tauri-apps__api@2.12.4.patch b/patches/@tauri-apps__api@2.12.4.patch new file mode 100644 index 00000000000..c17bcb366bb --- /dev/null +++ b/patches/@tauri-apps__api@2.12.4.patch @@ -0,0 +1,164 @@ +diff --git a/event.cjs b/event.cjs +index b53f3497d826d26e6182e28781b519e6e14f39c8..c926acf081427b7c79bcf170c0028a18fceda786 100644 +--- a/event.cjs ++++ b/event.cjs +@@ -99,7 +99,13 @@ exports.TauriEvent = void 0; + * @returns + */ + async function _unlisten(event, eventId) { +- window.__TAURI_EVENT_PLUGIN_INTERNALS__.unregisterListener(event, eventId); ++ try { ++ window.__TAURI_EVENT_PLUGIN_INTERNALS__?.unregisterListener(event, eventId); ++ } ++ catch { ++ // Native registration can complete before its JavaScript registry eval. ++ // The captured callback has already been released by _listen. ++ } + await core.invoke('plugin:event|unlisten', { + event, + eventId +@@ -134,17 +140,44 @@ async function _unlisten(event, eventId) { + * @since 1.0.0 + */ + async function listen(event, handler, options) { ++ return _listen(event, handler, options, false); ++} ++async function _listen(event, handler, options, onlyOnce) { + var _a; + const target = typeof (options === null || options === void 0 ? void 0 : options.target) === 'string' + ? { kind: 'AnyLabel', label: options.target } + : ((_a = options === null || options === void 0 ? void 0 : options.target) !== null && _a !== void 0 ? _a : { kind: 'Any' }); +- return core.invoke('plugin:event|listen', { +- event, +- target, +- handler: core.transformCallback(handler) +- }).then((eventId) => { +- return async () => _unlisten(event, eventId); ++ let disposed = false; ++ const releaseCallback = () => { ++ window.__TAURI_INTERNALS__.unregisterCallback?.(handlerId); ++ }; ++ const cleanup = async (eventId) => { ++ if (disposed) ++ return; ++ disposed = true; ++ releaseCallback(); ++ await _unlisten(event, eventId); ++ }; ++ const handlerId = core.transformCallback((eventData) => { ++ if (disposed) ++ return; ++ if (onlyOnce) ++ void cleanup(eventData.id).catch(() => { }); ++ handler(eventData); + }); ++ try { ++ const eventId = await core.invoke('plugin:event|listen', { ++ event, ++ target, ++ handler: handlerId ++ }); ++ return async () => cleanup(eventId); ++ } ++ catch (error) { ++ disposed = true; ++ releaseCallback(); ++ throw error; ++ } + } + /** + * Listens once to an emitted event to any {@link EventTarget|target}. +@@ -177,10 +210,7 @@ async function listen(event, handler, options) { + * @since 1.0.0 + */ + async function once(event, handler, options) { +- return listen(event, (eventData) => { +- void _unlisten(event, eventData.id); +- handler(eventData); +- }, options); ++ return _listen(event, handler, options, true); + } + /** + * Emits an event to all {@link EventTarget|targets}. +diff --git a/event.js b/event.js +index 1e03752962d8cb5edad90d59732e486e187bee5b..3a3ebc94e4d5a58a95f1529a031615d162946f03 100644 +--- a/event.js ++++ b/event.js +@@ -97,7 +97,13 @@ var TauriEvent; + * @returns + */ + async function _unlisten(event, eventId) { +- window.__TAURI_EVENT_PLUGIN_INTERNALS__.unregisterListener(event, eventId); ++ try { ++ window.__TAURI_EVENT_PLUGIN_INTERNALS__?.unregisterListener(event, eventId); ++ } ++ catch { ++ // Native registration can complete before its JavaScript registry eval. ++ // The captured callback has already been released by _listen. ++ } + await invoke('plugin:event|unlisten', { + event, + eventId +@@ -132,17 +138,44 @@ async function _unlisten(event, eventId) { + * @since 1.0.0 + */ + async function listen(event, handler, options) { ++ return _listen(event, handler, options, false); ++} ++async function _listen(event, handler, options, onlyOnce) { + var _a; + const target = typeof (options === null || options === void 0 ? void 0 : options.target) === 'string' + ? { kind: 'AnyLabel', label: options.target } + : ((_a = options === null || options === void 0 ? void 0 : options.target) !== null && _a !== void 0 ? _a : { kind: 'Any' }); +- return invoke('plugin:event|listen', { +- event, +- target, +- handler: transformCallback(handler) +- }).then((eventId) => { +- return async () => _unlisten(event, eventId); ++ let disposed = false; ++ const releaseCallback = () => { ++ window.__TAURI_INTERNALS__.unregisterCallback?.(handlerId); ++ }; ++ const cleanup = async (eventId) => { ++ if (disposed) ++ return; ++ disposed = true; ++ releaseCallback(); ++ await _unlisten(event, eventId); ++ }; ++ const handlerId = transformCallback((eventData) => { ++ if (disposed) ++ return; ++ if (onlyOnce) ++ void cleanup(eventData.id).catch(() => { }); ++ handler(eventData); + }); ++ try { ++ const eventId = await invoke('plugin:event|listen', { ++ event, ++ target, ++ handler: handlerId ++ }); ++ return async () => cleanup(eventId); ++ } ++ catch (error) { ++ disposed = true; ++ releaseCallback(); ++ throw error; ++ } + } + /** + * Listens once to an emitted event to any {@link EventTarget|target}. +@@ -175,10 +208,7 @@ async function listen(event, handler, options) { + * @since 1.0.0 + */ + async function once(event, handler, options) { +- return listen(event, (eventData) => { +- void _unlisten(event, eventData.id); +- handler(eventData); +- }, options); ++ return _listen(event, handler, options, true); + } + /** + * Emits an event to all {@link EventTarget|targets}. diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 00000000000..722a2e55781 --- /dev/null +++ b/patches/README.md @@ -0,0 +1,32 @@ +# OverlayScrollbars 2.15.1 + +The patch selects OverlayScrollbars' existing scroll-event implementation and +percentage-based handle positioning in WebKit. Its native implementation +animates scrollbar handles with +`ScrollTimeline` and `translate*(calc(-100% + 100cq*))`. On macOS 27.2 +(26B123), repeatedly updating those timelines retains native WebKit allocations +even when the DOM size and JavaScript live heap stay small. + +A system WKWebView reproducer with 24 scrolling elements grew from 293 MiB to +2,324 MiB in 40 seconds. The same timeline with a fixed-pixel transform stayed +near 111 MiB for 90 seconds; the scroll-event control stayed near 116 MiB for +150 seconds. The CSS fallback also uses container-relative transforms by default, +so WebKit scrollbar elements receive a class that keeps the original percentage +rules instead. The patch avoids the problematic interpolation without changing +the page's scrolling or disabling CSS animations. Blink keeps the native path. + +All four ESM/CommonJS package entrypoints used by package exports and the +legacy `main`/`module` fields carry the same change. The lockfile records the +patch hash so clean and frozen installs apply it. + +`src/lib/overlay-scrollbars-memory.test.ts` runs fresh ESM and CommonJS imports +for desktop/iOS WebKit, Chromium browsers, and browsers without the API. It +checks that WebKit creates handles without native timeline animations and that +Chromium retains the native implementation. Browser functionality and memory +also need a system WebKit check: initialize real scrollbars, repeatedly change +the viewport's scroll position, and verify that the handle moves while +`document.getAnimations()` contains no scrollbar `ScrollTimeline` animations. + +Revisit the patch when upgrading OverlayScrollbars or when WebKit fixes this +interpolation path. Test native physical footprint, rather than only a +JavaScript heap snapshot. diff --git a/patches/overlayscrollbars@2.15.1.patch b/patches/overlayscrollbars@2.15.1.patch new file mode 100644 index 00000000000..7a7daa17a8f --- /dev/null +++ b/patches/overlayscrollbars@2.15.1.patch @@ -0,0 +1,135 @@ +diff --git a/overlayscrollbars.cjs b/overlayscrollbars.cjs +index 7268fd59a854208ab38233e920a995bf864ef6f1..19e256594fef0a5a535e81aa2da34b7e2d4c7804 100644 +--- a/overlayscrollbars.cjs ++++ b/overlayscrollbars.cjs +@@ -62,7 +62,13 @@ const _ = getApi("IntersectionObserver"); + + const d = getApi("ResizeObserver"); + +-const p = getApi("ScrollTimeline"); ++// WebKit retains native allocations when scroll timelines interpolate the ++// container-relative calc() transforms used by scrollbar handles. Use the ++// existing scroll-event fallback in that engine; keep Blink's native path. ++const isWebKit = /AppleWebKit\//.test(n.navigator && n.navigator.userAgent || "") && ++ !/(?:Chrome|Chromium|Edg|OPR)\//.test(n.navigator && n.navigator.userAgent || ""); ++ ++const p = isWebKit ? void 0 : getApi("ScrollTimeline"); + + const isUndefined = t => t === void 0; + +@@ -1967,7 +1973,7 @@ const createScrollbarsSetupElements = (t, n, o, s) => { + const generateScrollbarDOM = t => { + const n = t ? "x" : "y"; + const o = t ? ft : _t; +- const e = createDiv(`${at} ${o}`); ++ const e = createDiv(`${at} ${o}${isWebKit ? " os-scrollbar-webkit" : ""}`); + const c = createDiv(dt); + const r = createDiv(pt); + const i = { +diff --git a/overlayscrollbars.cjs.js b/overlayscrollbars.cjs.js +index 010b70bd487226ccd01fb6c0d772ab6bb8638c51..1bd9797196a3079563be955c637fa23b530311d7 100644 +--- a/overlayscrollbars.cjs.js ++++ b/overlayscrollbars.cjs.js +@@ -62,7 +62,13 @@ const _ = getApi("IntersectionObserver"); + + const d = getApi("ResizeObserver"); + +-const p = getApi("ScrollTimeline"); ++// WebKit retains native allocations when scroll timelines interpolate the ++// container-relative calc() transforms used by scrollbar handles. Use the ++// existing scroll-event fallback in that engine; keep Blink's native path. ++const isWebKit = /AppleWebKit\//.test(n.navigator && n.navigator.userAgent || "") && ++ !/(?:Chrome|Chromium|Edg|OPR)\//.test(n.navigator && n.navigator.userAgent || ""); ++ ++const p = isWebKit ? void 0 : getApi("ScrollTimeline"); + + const isUndefined = t => t === void 0; + +@@ -1967,7 +1973,7 @@ const createScrollbarsSetupElements = (t, n, o, s) => { + const generateScrollbarDOM = t => { + const n = t ? "x" : "y"; + const o = t ? ft : _t; +- const e = createDiv(`${at} ${o}`); ++ const e = createDiv(`${at} ${o}${isWebKit ? " os-scrollbar-webkit" : ""}`); + const c = createDiv(dt); + const r = createDiv(pt); + const i = { +diff --git a/overlayscrollbars.esm.js b/overlayscrollbars.esm.js +index c66166eacc57c3ed6a95f01e1bcc290a58c3d176..e054c67b241249d2bf2059bdc9872b23832e2bf6 100644 +--- a/overlayscrollbars.esm.js ++++ b/overlayscrollbars.esm.js +@@ -56,7 +56,13 @@ const _ = getApi("IntersectionObserver"); + + const d = getApi("ResizeObserver"); + +-const p = getApi("ScrollTimeline"); ++// WebKit retains native allocations when scroll timelines interpolate the ++// container-relative calc() transforms used by scrollbar handles. Use the ++// existing scroll-event fallback in that engine; keep Blink's native path. ++const isWebKit = /AppleWebKit\//.test(n.navigator && n.navigator.userAgent || "") && ++ !/(?:Chrome|Chromium|Edg|OPR)\//.test(n.navigator && n.navigator.userAgent || ""); ++ ++const p = isWebKit ? void 0 : getApi("ScrollTimeline"); + + const isUndefined = t => t === void 0; + +@@ -1961,7 +1967,7 @@ const createScrollbarsSetupElements = (t, n, o, s) => { + const generateScrollbarDOM = t => { + const n = t ? "x" : "y"; + const o = t ? ft : _t; +- const e = createDiv(`${at} ${o}`); ++ const e = createDiv(`${at} ${o}${isWebKit ? " os-scrollbar-webkit" : ""}`); + const c = createDiv(dt); + const r = createDiv(pt); + const i = { +diff --git a/overlayscrollbars.mjs b/overlayscrollbars.mjs +index 82dc05b44e92c5125aee59691a00c3babee7d934..dd4e6826e75f48fca6d8e2784ed351873f5ce749 100644 +--- a/overlayscrollbars.mjs ++++ b/overlayscrollbars.mjs +@@ -56,7 +56,13 @@ const _ = getApi("IntersectionObserver"); + + const d = getApi("ResizeObserver"); + +-const p = getApi("ScrollTimeline"); ++// WebKit retains native allocations when scroll timelines interpolate the ++// container-relative calc() transforms used by scrollbar handles. Use the ++// existing scroll-event fallback in that engine; keep Blink's native path. ++const isWebKit = /AppleWebKit\//.test(n.navigator && n.navigator.userAgent || "") && ++ !/(?:Chrome|Chromium|Edg|OPR)\//.test(n.navigator && n.navigator.userAgent || ""); ++ ++const p = isWebKit ? void 0 : getApi("ScrollTimeline"); + + const isUndefined = t => t === void 0; + +@@ -1961,7 +1967,7 @@ const createScrollbarsSetupElements = (t, n, o, s) => { + const generateScrollbarDOM = t => { + const n = t ? "x" : "y"; + const o = t ? ft : _t; +- const e = createDiv(`${at} ${o}`); ++ const e = createDiv(`${at} ${o}${isWebKit ? " os-scrollbar-webkit" : ""}`); + const c = createDiv(dt); + const r = createDiv(pt); + const i = { +diff --git a/styles/overlayscrollbars.css b/styles/overlayscrollbars.css +index 9fd60dce428b62713c7b2f216085402cf4ba8f3e..594d1304fc96a2e6b1c397971de457930ebd0c4a 100644 +--- a/styles/overlayscrollbars.css ++++ b/styles/overlayscrollbars.css +@@ -478,15 +478,15 @@ body > .os-scrollbar { + .os-scrollbar-track { + container-type: size; + } +- .os-scrollbar-horizontal .os-scrollbar-handle { ++ .os-scrollbar-horizontal:not(.os-scrollbar-webkit) .os-scrollbar-handle { + left: auto; + transform: translateX(calc(var(--os-scroll-percent-directional) * 100cqw + var(--os-scroll-percent-directional) * -100%)); + } +- .os-scrollbar-vertical .os-scrollbar-handle { ++ .os-scrollbar-vertical:not(.os-scrollbar-webkit) .os-scrollbar-handle { + top: auto; + transform: translateY(calc(var(--os-scroll-percent-directional) * 100cqh + var(--os-scroll-percent-directional) * -100%)); + } +- .os-scrollbar-rtl.os-scrollbar-horizontal .os-scrollbar-handle { ++ .os-scrollbar-rtl.os-scrollbar-horizontal:not(.os-scrollbar-webkit) .os-scrollbar-handle { + right: auto; + left: 0; + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bced3b46bb3..fb4ef5ab260 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,10 @@ overrides: katex: ^0.19.0 ansi-to-react>linkify-it: ^5.0.2 +patchedDependencies: + '@tauri-apps/api@2.12.4': cb5d7a9395e8e570985347e12bb9ec386e8ac4e2981f4cc5d9d3351452b5e2ce + overlayscrollbars@2.15.1: d31b0912d157c6838f7cea20573a5917730580b13fceaa2c7c53b4856f605328 + importers: .: @@ -62,7 +66,7 @@ importers: version: 1.0.3(react@19.2.4) '@tauri-apps/api': specifier: ^2.12.4 - version: 2.12.4 + version: 2.12.4(patch_hash=cb5d7a9395e8e570985347e12bb9ec386e8ac4e2981f4cc5d9d3351452b5e2ce) '@tauri-apps/plugin-dialog': specifier: ^2.8.3 version: 2.8.3 @@ -167,10 +171,10 @@ importers: version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) overlayscrollbars: specifier: ^2.15.1 - version: 2.15.1 + version: 2.15.1(patch_hash=d31b0912d157c6838f7cea20573a5917730580b13fceaa2c7c53b4856f605328) overlayscrollbars-react: specifier: ^0.5.6 - version: 0.5.6(overlayscrollbars@2.15.1)(react@19.2.4) + version: 0.5.6(overlayscrollbars@2.15.1(patch_hash=d31b0912d157c6838f7cea20573a5917730580b13fceaa2c7c53b4856f605328))(react@19.2.4) pdfjs-dist: specifier: 6.4.299 version: 6.4.299 @@ -9410,7 +9414,7 @@ snapshots: postcss: 8.5.30 tailwindcss: 4.1.18 - '@tauri-apps/api@2.12.4': {} + '@tauri-apps/api@2.12.4(patch_hash=cb5d7a9395e8e570985347e12bb9ec386e8ac4e2981f4cc5d9d3351452b5e2ce)': {} '@tauri-apps/cli-darwin-arm64@2.12.3': optional: true @@ -9461,23 +9465,23 @@ snapshots: '@tauri-apps/plugin-dialog@2.8.3': dependencies: - '@tauri-apps/api': 2.12.4 + '@tauri-apps/api': 2.12.4(patch_hash=cb5d7a9395e8e570985347e12bb9ec386e8ac4e2981f4cc5d9d3351452b5e2ce) '@tauri-apps/plugin-opener@2.7.2': dependencies: - '@tauri-apps/api': 2.12.4 + '@tauri-apps/api': 2.12.4(patch_hash=cb5d7a9395e8e570985347e12bb9ec386e8ac4e2981f4cc5d9d3351452b5e2ce) '@tauri-apps/plugin-process@2.4.0': dependencies: - '@tauri-apps/api': 2.12.4 + '@tauri-apps/api': 2.12.4(patch_hash=cb5d7a9395e8e570985347e12bb9ec386e8ac4e2981f4cc5d9d3351452b5e2ce) '@tauri-apps/plugin-updater@2.14.0': dependencies: - '@tauri-apps/api': 2.12.4 + '@tauri-apps/api': 2.12.4(patch_hash=cb5d7a9395e8e570985347e12bb9ec386e8ac4e2981f4cc5d9d3351452b5e2ce) '@tauri-apps/plugin-window-state@2.5.0': dependencies: - '@tauri-apps/api': 2.12.4 + '@tauri-apps/api': 2.12.4(patch_hash=cb5d7a9395e8e570985347e12bb9ec386e8ac4e2981f4cc5d9d3351452b5e2ce) '@testing-library/dom@10.4.1': dependencies: @@ -13025,12 +13029,12 @@ snapshots: outvariant@1.4.3: {} - overlayscrollbars-react@0.5.6(overlayscrollbars@2.15.1)(react@19.2.4): + overlayscrollbars-react@0.5.6(overlayscrollbars@2.15.1(patch_hash=d31b0912d157c6838f7cea20573a5917730580b13fceaa2c7c53b4856f605328))(react@19.2.4): dependencies: - overlayscrollbars: 2.15.1 + overlayscrollbars: 2.15.1(patch_hash=d31b0912d157c6838f7cea20573a5917730580b13fceaa2c7c53b4856f605328) react: 19.2.4 - overlayscrollbars@2.15.1: {} + overlayscrollbars@2.15.1(patch_hash=d31b0912d157c6838f7cea20573a5917730580b13fceaa2c7c53b4856f605328): {} own-keys@1.0.1: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f5f7f886596..b094c0ad62a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -33,3 +33,7 @@ minimumReleaseAgeExclude: - '@tauri-apps/plugin-dialog@2.8.3' - '@tauri-apps/plugin-opener@2.7.2' - '@tauri-apps/plugin-updater@2.14.0' + +patchedDependencies: + '@tauri-apps/api@2.12.4': patches/@tauri-apps__api@2.12.4.patch + overlayscrollbars@2.15.1: patches/overlayscrollbars@2.15.1.patch diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index dd08d2a2a6e..af4f101dc5d 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -5174,16 +5174,24 @@ fn build_file_tree( } /// Flat, gitignore-aware listing of every file and directory under `path`, for -/// fuzzy file search. Unlike [`get_file_tree`], ignored directories -/// (`node_modules`, `target`, `dist`, …) are pruned *during* the walk, so no -/// depth cap is needed: deep files stay reachable while the heavy trees are -/// never descended and the payload stays small. Gitignore handling that used to -/// run client-side now happens here at native speed in a single pass. +/// compatibility with clients that need enumeration. Search surfaces use +/// `workspace_file_search` so an unbounded listing never reaches the renderer. #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn list_workspace_files( path: String, ) -> Result, AppCommandError> { - let root = PathBuf::from(&path); + let mut entries = Vec::new(); + visit_workspace_files(&path, |entry| { + entries.push(entry); + true + }); + Ok(entries) +} + +/// Visit the search-visible entries without collecting the whole workspace. +/// Returning false stops both the main walk and any authorized linked roots. +pub(super) fn visit_workspace_files(path: &str, mut visit: impl FnMut(WorkspaceFileEntry) -> bool) { + let root = PathBuf::from(path); // Linked directories are walked separately below, rooted at the link so // their own `.gitignore` applies. Excluded from the main pass because the // walker reports a symlink as a leaf file. @@ -5220,7 +5228,6 @@ pub async fn list_workspace_files( }) .build(); - let mut entries: Vec = Vec::new(); for result in walker { // Skip unreadable entries (permission errors, transient races) rather // than failing the whole search. @@ -5244,7 +5251,7 @@ pub async fn list_workspace_files( .to_string_lossy() .replace('\\', "/"); - entries.push(WorkspaceFileEntry { + if !visit(WorkspaceFileEntry { name, path: rel_path, kind: if is_dir { @@ -5252,28 +5259,36 @@ pub async fn list_workspace_files( } else { WorkspaceEntryKind::File }, - }); + }) { + return; + } } for (link_name, target) in linked { - entries.push(WorkspaceFileEntry { + if !visit(WorkspaceFileEntry { name: link_name.clone(), path: link_name.clone(), kind: WorkspaceEntryKind::Dir, - }); + }) { + return; + } // Rooted at the resolved target so the linked project's own ignore // files apply, then re-prefixed with the link name so every path stays // relative to the workspace root and resolves back through the link. - entries.extend(list_files_under(&target, &link_name)); + if !visit_files_under(&target, &link_name, &mut visit) { + return; + } } - - Ok(entries) } /// Flat listing of `root`, with every path prefixed by `prefix/`. Shares the /// ignore configuration of [`list_workspace_files`]; nested symlinks are not /// followed, so this cannot recurse. -fn list_files_under(root: &Path, prefix: &str) -> Vec { +fn visit_files_under( + root: &Path, + prefix: &str, + visit: &mut impl FnMut(WorkspaceFileEntry) -> bool, +) -> bool { let walker = WalkBuilder::new(root) .hidden(false) .parents(false) @@ -5295,7 +5310,6 @@ fn list_files_under(root: &Path, prefix: &str) -> Vec { }) .build(); - let mut entries = Vec::new(); for result in walker { let Ok(entry) = result else { continue }; let entry_path = entry.path(); @@ -5308,7 +5322,7 @@ fn list_files_under(root: &Path, prefix: &str) -> Vec { let is_dir = entry .file_type() .is_some_and(|t| entry_presents_as_dir(&t, entry_path)); - entries.push(WorkspaceFileEntry { + if !visit(WorkspaceFileEntry { name: entry.file_name().to_string_lossy().to_string(), path: format!("{prefix}/{}", rel.to_string_lossy().replace('\\', "/")), kind: if is_dir { @@ -5316,9 +5330,11 @@ fn list_files_under(root: &Path, prefix: &str) -> Vec { } else { WorkspaceEntryKind::File }, - }); + }) { + return false; + } } - entries + true } #[cfg_attr(feature = "tauri-runtime", tauri::command)] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c3bdf783993..885bdb9e869 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -83,6 +83,7 @@ pub mod version_control; #[cfg(feature = "tauri-runtime")] pub mod windows; pub mod work_task; +pub mod workspace_file_search; pub mod workspace_state; #[cfg(feature = "tauri-runtime")] pub mod workspace_windows; diff --git a/src-tauri/src/commands/workspace_file_search.rs b/src-tauri/src/commands/workspace_file_search.rs new file mode 100644 index 00000000000..c6f548f84cf --- /dev/null +++ b/src-tauri/src/commands/workspace_file_search.rs @@ -0,0 +1,1168 @@ +//! Keep workspace discovery and ranking out of the renderer. A workspace can +//! contain millions of non-ignored files: sending a flat list to each composer +//! multiplies path strings, lowercase copies and JS objects across webviews. + +use std::cmp::{Ordering, Reverse}; +use std::collections::{BinaryHeap, HashMap}; +use std::mem::size_of; +use std::sync::{Arc, LazyLock, Mutex as StdMutex}; +use std::time::{Duration, Instant}; + +use serde::Deserialize; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; + +use super::folders::{visit_workspace_files, WorkspaceEntryKind, WorkspaceFileEntry}; +use crate::app_error::AppCommandError; + +const MAX_RESULTS: usize = 200; +const INDEX_BYTES: usize = 128 * 1024 * 1024; +const BUILD_BYTES: usize = 192 * 1024 * 1024; +const CACHE_TTL: Duration = Duration::from_secs(300); +const SEARCH_TIMEOUT: Duration = Duration::from_secs(300); +const MAX_CONSUMERS: usize = 128; +const NO_PARENT: u32 = u32::MAX; + +#[derive(Clone, Copy)] +struct IndexBudget { + build: usize, + retained: usize, +} + +const INDEX_BUDGET: IndexBudget = IndexBudget { + build: BUILD_BYTES, + retained: INDEX_BYTES, +}; + +// One workspace cache and one builder/search worker for the whole process, +// shared by every composer, dialog, desktop window and web client. Switching +// roots drops the previous index before building the next one. +static CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(None)); +static SEARCHES: LazyLock> = + LazyLock::new(|| StdMutex::new(Searches::default())); + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchRequest { + pub consumer_id: String, + pub generation: u64, +} + +struct Consumer { + generation: u64, + cancel: CancellationToken, + touched: Instant, + active: bool, +} + +#[derive(Default)] +struct Searches(HashMap); + +impl Searches { + fn register( + &mut self, + request: &SearchRequest, + cancel: bool, + ) -> Result, AppCommandError> { + if request.consumer_id.is_empty() || request.consumer_id.len() > 128 { + return Err(AppCommandError::invalid_input("Invalid search consumer")); + } + self.0 + .retain(|_, c| c.active || c.touched.elapsed() < SEARCH_TIMEOUT); + if let Some(previous) = self.0.get(&request.consumer_id) { + if request.generation <= previous.generation { + if cancel && request.generation == previous.generation { + previous.cancel.cancel(); + } + return Ok(None); + } + previous.cancel.cancel(); + } else if self.0.len() >= MAX_CONSUMERS { + // Retain active consumers; bound completed generation tombstones + // too. Tombstones handle cancellation arriving before its request. + let oldest = self + .0 + .iter() + .filter(|(_, c)| !c.active) + .min_by_key(|(_, c)| c.touched) + .map(|(key, _)| key.clone()); + if let Some(oldest) = oldest { + self.0.remove(&oldest); + } else { + return Err(AppCommandError::task_execution_failed( + "Too many workspace file searches", + )); + } + } + let token = CancellationToken::new(); + if cancel { + token.cancel(); + } + self.0.insert( + request.consumer_id.clone(), + Consumer { + generation: request.generation, + cancel: token.clone(), + touched: Instant::now(), + active: !cancel, + }, + ); + Ok(Some(token)) + } +} + +struct SearchControl { + cancel: CancellationToken, + deadline: Instant, +} + +impl Default for SearchControl { + fn default() -> Self { + Self { + cancel: CancellationToken::new(), + deadline: Instant::now() + SEARCH_TIMEOUT, + } + } +} + +impl SearchControl { + fn stopped(&self) -> bool { + self.cancel.is_cancelled() || Instant::now() >= self.deadline + } +} + +struct ActiveSearch { + request: SearchRequest, + control: SearchControl, +} + +impl Drop for ActiveSearch { + fn drop(&mut self) { + self.control.cancel.cancel(); + if let Some(consumer) = SEARCHES + .lock() + .unwrap() + .0 + .get_mut(&self.request.consumer_id) + { + if consumer.generation == self.request.generation { + consumer.active = false; + consumer.touched = Instant::now(); + } + } + } +} + +struct CachedIndex { + root: String, + built_at: Instant, + index: Option, +} + +// Store each basename once and each parent as a small integer. In a workspace +// containing many checkouts/archives this avoids retaining the same long path +// prefix (and basename) millions of times. +#[derive(Clone, Copy)] +struct Node { + parent: u32, + name: u32, + is_dir: bool, +} + +struct IndexedName { + text: Arc, + lower: String, + utf16_len: usize, + byte_mask: u64, +} + +fn byte_mask(text: &str) -> u64 { + text.bytes().fold(0, |mask, byte| mask | (1 << (byte % 64))) +} + +#[derive(Default)] +struct CompactIndex { + nodes: Vec, + names: Vec, + string_bytes: usize, +} + +impl CompactIndex { + fn bytes(&self) -> usize { + self.nodes.capacity() * size_of::() + + self.names.capacity() * size_of::() + + self.string_bytes + } + + fn search( + &self, + query: &str, + limit: usize, + files_only: bool, + control: &SearchControl, + ) -> Vec { + let mut matches = Matches::new(query, limit, files_only); + // Repeated basenames share normalization and name-tier scoring too. + // A full set of name matches outranks every path/subsequence match. + let name_scores: Vec<_> = self + .names + .iter() + .map(|name| score_name(&matches.query, &name.lower, name.utf16_len)) + .collect(); + let query_mask = matches.query.is_ascii().then(|| byte_mask(&matches.query)); + let mut ancestors: Vec<(u32, usize, usize, u64)> = Vec::new(); + let mut path = String::new(); + let mut lower_path = String::new(); + for (id, node) in self.nodes.iter().enumerate() { + if control.stopped() { + return Vec::new(); + } + while ancestors.last().map(|(id, ..)| *id).unwrap_or(NO_PARENT) != node.parent { + ancestors.pop(); + path.truncate(ancestors.last().map(|(_, len, ..)| *len).unwrap_or(0)); + lower_path.truncate(ancestors.last().map(|(_, _, len, _)| *len).unwrap_or(0)); + } + let parent_len = path.len(); + let lower_parent_len = lower_path.len(); + let name = &self.names[node.name as usize]; + let mask = ancestors.last().map(|(_, _, _, mask)| *mask).unwrap_or(0) + | name.byte_mask + | if parent_len > 0 { byte_mask("/") } else { 0 }; + let name_score = name_scores[node.name as usize]; + let may_match = (!files_only || !node.is_dir) + && match name_score { + Some(score) => matches.accepts(score, id), + None => { + matches.needs_path_matches() + && query_mask.is_none_or(|query| mask & query == query) + } + }; + // Directories still maintain the prefix for descendants; rejected + // files need no full path, lowercase allocation or UTF-16 scan. + if !node.is_dir && !may_match { + continue; + } + if !path.is_empty() { + path.push('/'); + lower_path.push('/'); + } + path.push_str(&name.text); + lower_path.push_str(&name.lower); + if may_match { + let score = name_score.or_else(|| { + score_path(&matches.query, &name.lower, name.utf16_len, &lower_path) + }); + if let Some(score) = score { + matches.offer_ranked(&name.text, &path, node.is_dir, id, score); + } + } + if node.is_dir { + ancestors.push((id as u32, path.len(), lower_path.len(), mask)); + } else { + path.truncate(parent_len); + lower_path.truncate(lower_parent_len); + } + } + matches.finish() + } +} + +#[derive(Default)] +struct Builder { + index: CompactIndex, + names: HashMap, u32>, + parents: Vec, +} + +impl Builder { + fn push(&mut self, entry: &WorkspaceFileEntry, budget: usize) -> bool { + // Account for capacity growth before reserving it. The interning table + // exists only during the build; it is dropped before caching the index. + let new_name = !self.names.contains_key(entry.name.as_str()); + let extra_nodes = if self.index.nodes.len() == self.index.nodes.capacity() { + 4096 * size_of::() + } else { + 0 + }; + let extra_names = if new_name && self.index.names.len() == self.index.names.capacity() { + 1024 * size_of::() + } else { + 0 + }; + let table_capacity = if new_name && self.names.len() == self.names.capacity() { + (self.names.capacity().max(4) * 2).max(self.names.len() + 1) + } else { + self.names.capacity() + }; + // HashMap capacity excludes empty buckets/control bytes. Round up and + // include Arc's allocation header/alignment for each interned string. + let table_bytes = table_capacity * (size_of::<(Arc, u32)>() + 1) * 8 / 7 + 32; + let lower = new_name.then(|| entry.name.to_lowercase()); + let string_bytes = lower + .as_ref() + .map(|lower| entry.name.len() + 32 + lower.capacity() + 16) + .unwrap_or(0); + if self.index.bytes() + extra_nodes + extra_names + table_bytes + string_bytes > budget + || self.index.nodes.len() >= u32::MAX as usize + { + return false; + } + if extra_nodes != 0 { + self.index.nodes.reserve_exact(4096); + } + if extra_names != 0 { + self.index.names.reserve_exact(1024); + } + let name = match self.names.get(entry.name.as_str()) { + Some(id) => *id, + None => { + let id = self.index.names.len() as u32; + let name: Arc = Arc::from(entry.name.as_str()); + self.names.insert(Arc::clone(&name), id); + let lower = lower.unwrap(); + self.index.names.push(IndexedName { + utf16_len: lower.encode_utf16().count(), + byte_mask: byte_mask(&lower), + text: name, + lower, + }); + self.index.string_bytes += string_bytes; + id + } + }; + let depth = entry.path.bytes().filter(|c| *c == b'/').count(); + self.parents.truncate(depth); + if self.parents.len() != depth { + return false; + } + let id = self.index.nodes.len() as u32; + self.index.nodes.push(Node { + parent: self.parents.last().copied().unwrap_or(NO_PARENT), + name, + is_dir: entry.kind == WorkspaceEntryKind::Dir, + }); + if entry.kind == WorkspaceEntryKind::Dir { + self.parents.push(id); + } + true + } +} + +struct Ranked { + score: i64, + order: usize, + entry: WorkspaceFileEntry, +} + +impl PartialEq for Ranked { + fn eq(&self, other: &Self) -> bool { + self.score == other.score && self.order == other.order + } +} +impl Eq for Ranked {} +impl PartialOrd for Ranked { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for Ranked { + fn cmp(&self, other: &Self) -> Ordering { + self.score + .cmp(&other.score) + .then_with(|| other.order.cmp(&self.order)) + } +} + +struct Matches { + query: String, + limit: usize, + files_only: bool, + best: BinaryHeap>, +} + +impl Matches { + fn new(query: &str, limit: usize, files_only: bool) -> Self { + Self { + query: query.trim().to_lowercase(), + limit, + files_only, + best: BinaryHeap::new(), + } + } + + fn offer(&mut self, name: &str, path: &str, is_dir: bool, order: usize) { + if self.limit == 0 || (self.files_only && is_dir) { + return; + } + let Some(score) = score_file(&self.query, name, path) else { + return; + }; + self.offer_ranked(name, path, is_dir, order, score); + } + + fn accepts(&self, score: i64, order: usize) -> bool { + self.limit != 0 + && (self.best.len() < self.limit + || self.best.peek().is_some_and(|worst| { + score > worst.0.score || (score == worst.0.score && order < worst.0.order) + })) + } + + fn needs_path_matches(&self) -> bool { + self.limit != 0 + && (self.best.len() < self.limit + || self + .best + .peek() + .is_some_and(|worst| worst.0.score < 300_000_000)) + } + + fn offer_ranked(&mut self, name: &str, path: &str, is_dir: bool, order: usize, score: i64) { + if !self.accepts(score, order) { + return; + } + self.best.push(Reverse(Ranked { + score, + order, + entry: WorkspaceFileEntry { + name: name.to_owned(), + path: path.to_owned(), + kind: if is_dir { + WorkspaceEntryKind::Dir + } else { + WorkspaceEntryKind::File + }, + }, + })); + if self.best.len() > self.limit { + self.best.pop(); + } + } + + fn finish(self) -> Vec { + let mut best = self.best.into_iter().map(|r| r.0).collect::>(); + best.sort_by(|a, b| b.cmp(a)); + best.into_iter().map(|r| r.entry).collect() + } +} + +fn tier_score(tier: i64, position: usize, length: usize) -> i64 { + tier * 100_000_000 - position.min(9999) as i64 * 1000 - length.min(999) as i64 +} + +fn subsequence(query: &str, text: &str) -> Option { + let mut query = query.encode_utf16().peekable(); + let mut first = None; + for (i, ch) in text.encode_utf16().enumerate() { + if query.peek() == Some(&ch) { + first.get_or_insert(i); + query.next(); + if query.peek().is_none() { + return first; + } + } + } + None +} + +// Same tiers and UTF-16 positions/lengths as lib/file-search-match.ts. +fn score_file(query: &str, name: &str, path: &str) -> Option { + let name = name.to_lowercase(); + let name_len = name.encode_utf16().count(); + score_name(query, &name, name_len) + .or_else(|| score_path(query, &name, name_len, &path.to_lowercase())) +} + +fn score_name(query: &str, name: &str, name_len: usize) -> Option { + if query.is_empty() { + return Some(0); + } + if name == query { + return Some(tier_score(6, 0, name_len)); + } + if name.starts_with(query) { + return Some(tier_score(5, 0, name_len)); + } + if let Some(i) = name.find(query) { + return Some(tier_score(4, name[..i].encode_utf16().count(), name_len)); + } + None +} + +fn score_path(query: &str, name: &str, name_len: usize, path: &str) -> Option { + let path_len = path.encode_utf16().count(); + if let Some(i) = path.find(query) { + return Some(tier_score(3, path[..i].encode_utf16().count(), path_len)); + } + if let Some(i) = subsequence(query, name) { + return Some(tier_score(2, i, name_len)); + } + subsequence(query, path).map(|i| tier_score(1, i, path_len)) +} + +#[derive(Clone, Copy)] +struct SearchOptions<'a> { + query: &'a str, + limit: usize, + files_only: bool, + refresh: bool, +} + +fn search_or_build( + cache: &mut Option, + root: String, + options: SearchOptions<'_>, + budget: IndexBudget, + control: &SearchControl, +) -> Vec { + let SearchOptions { + query, + limit, + files_only, + refresh, + } = options; + if control.stopped() { + return Vec::new(); + } + let fresh = cache + .as_ref() + .is_some_and(|c| c.root == root && c.built_at.elapsed() < CACHE_TTL); + if fresh && !refresh { + if limit == 0 { + return Vec::new(); + } + if let Some(index) = cache.as_ref().and_then(|c| c.index.as_ref()) { + return index.search(query, limit, files_only, control); + } + } + let should_build = !fresh || refresh; + if should_build { + *cache = None; + } + let mut builder = should_build.then(Builder::default); + let mut matches = Matches::new(query, limit, files_only); + let mut order = 0; + visit_workspace_files(&root, |entry| { + if control.stopped() { + return false; + } + matches.offer( + &entry.name, + &entry.path, + entry.kind == WorkspaceEntryKind::Dir, + order, + ); + order += 1; + if builder + .as_mut() + .is_some_and(|b| !b.push(&entry, budget.build)) + { + builder = None; + } + // A warmup can stop once the budget is exhausted. A real search keeps + // walking and ranking so a late/deep exact match is never omitted. + builder.is_some() || limit != 0 + }); + if control.stopped() { + // A partial traversal must never become a fresh, incomplete index. + return Vec::new(); + } + if should_build { + let index = builder + .map(|b| b.index) + .filter(|i| i.bytes() <= budget.retained); + *cache = Some(CachedIndex { + root, + built_at: Instant::now(), + index, + }); + } + matches.finish() +} + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn search_workspace_files( + path: String, + query: String, + limit: Option, + files_only: Option, + refresh: Option, + request: Option, +) -> Result, AppCommandError> { + let requested_at = Instant::now(); + if path.trim().is_empty() || query.len() > 2048 { + return Err(AppCommandError::invalid_input( + "Invalid workspace file search", + )); + } + let limit = limit.unwrap_or(100).clamp(1, MAX_RESULTS); + let files_only = files_only.unwrap_or(false); + // Register before admission: a newer generation cancels both queued and + // running work for this consumer, without affecting another picker/client. + let request = request.unwrap_or_else(|| SearchRequest { + consumer_id: uuid::Uuid::new_v4().to_string(), + generation: 0, + }); + let cancel = match SEARCHES.lock().unwrap().register(&request, false)? { + Some(token) => token, + None => return Ok(Vec::new()), + }; + let search = ActiveSearch { + request, + control: SearchControl { + cancel, + deadline: requested_at + SEARCH_TIMEOUT, + }, + }; + let mut cache = tokio::select! { + biased; + _ = search.control.cancel.cancelled() => return Ok(Vec::new()), + _ = tokio::time::sleep_until(search.control.deadline.into()) => { + return Err(AppCommandError::task_execution_failed("Workspace file search timed out")); + } + cache = CACHE.lock() => cache, + }; + // A warmup or another refresh may have rebuilt the index while this + // request waited. Reuse that newer snapshot instead of scanning twice. + let refresh = + refresh.unwrap_or(false) && cache.as_ref().is_none_or(|c| c.built_at < requested_at); + tokio::task::spawn_blocking(move || { + let control = &search.control; + let results = if query.trim().is_empty() { + // Even files-only listings can walk an entire tree of directories. + // They share the worker, cancellation and deadline with searches. + let mut entries = Vec::new(); + visit_workspace_files(&path, |entry| { + if control.stopped() { + return false; + } + if !files_only || entry.kind == WorkspaceEntryKind::File { + entries.push(entry); + } + entries.len() < limit + }); + entries + } else { + search_or_build( + &mut cache, + path, + SearchOptions { + query: &query, + limit, + files_only, + refresh, + }, + INDEX_BUDGET, + control, + ) + }; + if Instant::now() >= control.deadline { + Err(AppCommandError::task_execution_failed( + "Workspace file search timed out", + )) + } else if control.cancel.is_cancelled() { + Ok(Vec::new()) + } else { + Ok(results) + } + }) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))? +} + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn cancel_workspace_file_search(request: SearchRequest) -> Result<(), AppCommandError> { + SEARCHES.lock().unwrap().register(&request, true)?; + Ok(()) +} + +/// Prewarm only native, budgeted storage. Return no file list to the renderer. +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn prepare_workspace_file_search(path: String) { + if path.trim().is_empty() { + return; + } + let Ok(mut cache) = CACHE.try_lock() else { + return; + }; + tokio::task::spawn_blocking(move || { + search_or_build( + &mut cache, + path, + SearchOptions { + query: "", + limit: 0, + files_only: false, + refresh: false, + }, + INDEX_BUDGET, + &SearchControl::default(), + ); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run_search( + cache: &mut Option, + root: String, + query: &str, + limit: usize, + files_only: bool, + refresh: bool, + budget: IndexBudget, + ) -> Vec { + search_or_build( + cache, + root, + SearchOptions { + query, + limit, + files_only, + refresh, + }, + budget, + &SearchControl::default(), + ) + } + + fn file(path: &str, is_dir: bool) -> WorkspaceFileEntry { + WorkspaceFileEntry { + name: path.rsplit('/').next().unwrap().into(), + path: path.into(), + kind: if is_dir { + WorkspaceEntryKind::Dir + } else { + WorkspaceEntryKind::File + }, + } + } + + #[test] + fn compact_index_preserves_paths_and_rank_order() { + let entries = [ + file("a", true), + file("a/deep", true), + file("a/deep/target.ts", false), + file("a/target-extra.ts", false), + file("b", true), + file("b/target.ts", false), + ]; + let mut builder = Builder::default(); + let mut matches = Matches::new("target", 3, false); + for (i, e) in entries.iter().enumerate() { + assert!(builder.push(e, BUILD_BYTES)); + matches.offer(&e.name, &e.path, e.kind == WorkspaceEntryKind::Dir, i); + } + assert_eq!( + builder + .index + .search("target", 3, false, &SearchControl::default()), + matches.finish() + ); + assert_eq!( + builder + .index + .search("", 100, false, &SearchControl::default()), + entries + ); + } + + #[test] + fn scores_match_frontend_tiers_and_utf16_offsets() { + assert_eq!(score_file("foo", "FOO", "a/FOO"), Some(599_999_997)); + assert_eq!(score_file("foo", "foo-long", "foo-long"), Some(499_999_992)); + assert_eq!(score_file("foo", "😀foo", "😀foo"), Some(399_997_995)); + assert!(score_file("fbr", "FooBar.rs", "src/FooBar.rs").is_some()); + assert_eq!(score_file("xyz", "abc", "src/abc"), None); + } + + #[test] + fn compact_search_pruning_preserves_path_unicode_and_subsequence_matches() { + let entries = [ + file("foo", true), + file("foo/deep", true), + file("foo/deep/😀FooBar.rs", false), + file("foo/deep/README.md", false), + file("foo/README.md", false), + file("src", true), + file("src/ab", true), + file("src/ab/README.md", false), + file("src/ab/İstanbul.ts", false), + file("src/FooBar.rs", false), + file("src/éclair.ts", false), + ]; + let mut builder = Builder::default(); + for entry in &entries { + assert!(builder.push(entry, BUILD_BYTES)); + } + for query in ["", "README.md", "src/ab", "/", "fbr", "😀", "é", "İ"] { + for limit in [1, 3, 100] { + for files_only in [false, true] { + let mut expected = Matches::new(query, limit, files_only); + for (i, entry) in entries.iter().enumerate() { + expected.offer( + &entry.name, + &entry.path, + entry.kind == WorkspaceEntryKind::Dir, + i, + ); + } + assert_eq!( + builder + .index + .search(query, limit, files_only, &SearchControl::default()), + expected.finish(), + "query={query:?}, limit={limit}, files_only={files_only}" + ); + } + } + } + } + + #[test] + fn result_budget_keeps_late_exact_match_and_stable_ties() { + let mut matches = Matches::new("needle", 2, false); + for i in 0..1000 { + matches.offer("needle-extra", &format!("{i}/needle-extra"), false, i); + } + matches.offer("needle", "very/deep/needle", false, 1000); + assert_eq!(matches.best.len(), 2); + let best = matches.finish(); + assert_eq!(best[0].path, "very/deep/needle"); + assert_eq!(best[1].path, "0/needle-extra"); + } + + #[test] + fn budget_fallback_still_searches_the_entire_workspace() { + let dir = tempfile::tempdir().unwrap(); + for i in 0..100 { + std::fs::write(dir.path().join(format!("a{i:03}.txt")), "x").unwrap(); + } + std::fs::create_dir_all(dir.path().join("z/deep")).unwrap(); + std::fs::write(dir.path().join("z/deep/needle.txt"), "x").unwrap(); + let mut cache = None; + let root = dir.path().to_string_lossy().into_owned(); + let budget = IndexBudget { + build: 1024, + retained: 1024, + }; + let best = run_search(&mut cache, root.clone(), "needle", 1, true, false, budget); + assert_eq!(best[0].path, "z/deep/needle.txt"); + assert!(cache.as_ref().unwrap().index.is_none()); + assert_eq!( + run_search(&mut cache, root, "needle", 1, true, false, budget), + best + ); + } + + #[test] + fn budget_fallback_preserves_failed_build_ttl_and_eventually_retries() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("needle.txt"), "x").unwrap(); + let root = dir.path().to_string_lossy().into_owned(); + let mut cache = None; + run_search( + &mut cache, + root.clone(), + "needle", + 1, + true, + false, + IndexBudget { + build: 1024, + retained: 1024, + }, + ); + let marker = Instant::now() - Duration::from_secs(290); + cache.as_mut().unwrap().built_at = marker; + for _ in 0..3 { + let result = run_search( + &mut cache, + root.clone(), + "needle", + 1, + true, + false, + INDEX_BUDGET, + ); + assert_eq!(result[0].path, "needle.txt"); + assert_eq!(cache.as_ref().unwrap().built_at, marker); + assert!(cache.as_ref().unwrap().index.is_none()); + } + cache.as_mut().unwrap().built_at = Instant::now() - CACHE_TTL; + run_search(&mut cache, root, "needle", 1, true, false, INDEX_BUDGET); + assert!(cache.as_ref().unwrap().index.is_some()); + } + + fn request(consumer: &str, generation: u64) -> SearchRequest { + SearchRequest { + consumer_id: consumer.into(), + generation, + } + } + + #[test] + fn superseding_requests_cancel_only_their_own_consumer_and_reject_old_generations() { + let mut searches = Searches::default(); + let first = searches + .register(&request("picker", 1), false) + .unwrap() + .unwrap(); + let other = searches + .register(&request("other-client", 1), false) + .unwrap() + .unwrap(); + let latest = searches + .register(&request("picker", 3), false) + .unwrap() + .unwrap(); + assert!(first.is_cancelled()); + assert!(!other.is_cancelled()); + assert!(searches + .register(&request("picker", 2), false) + .unwrap() + .is_none()); + searches.register(&request("picker", 1), true).unwrap(); + assert!(!latest.is_cancelled()); + searches.register(&request("picker", 3), true).unwrap(); + assert!(latest.is_cancelled()); + searches + .register(&request("before-arrival", 2), true) + .unwrap(); + assert!(searches + .register(&request("before-arrival", 2), false) + .unwrap() + .is_none()); + } + + #[test] + fn search_admission_and_completed_consumer_markers_are_bounded() { + let mut searches = Searches::default(); + for i in 0..MAX_CONSUMERS { + searches + .register(&request(&i.to_string(), 1), false) + .unwrap(); + } + assert!(searches.register(&request("overflow", 1), false).is_err()); + searches.0.get_mut("0").unwrap().active = false; + searches.register(&request("overflow", 1), false).unwrap(); + assert_eq!(searches.0.len(), MAX_CONSUMERS); + } + + #[test] + fn canceled_or_expired_searches_do_not_replace_a_cached_index() { + let mut cache = Some(CachedIndex { + root: "existing".into(), + built_at: Instant::now(), + index: None, + }); + let canceled = SearchControl::default(); + canceled.cancel.cancel(); + let expired = SearchControl { + deadline: Instant::now() - Duration::from_secs(1), + ..SearchControl::default() + }; + for control in [canceled, expired] { + assert!(search_or_build( + &mut cache, + "replacement".into(), + SearchOptions { + query: "needle", + limit: 1, + files_only: true, + refresh: true + }, + INDEX_BUDGET, + &control, + ) + .is_empty()); + assert_eq!(cache.as_ref().unwrap().root, "existing"); + } + } + + #[tokio::test] + async fn empty_query_shares_admission_and_can_cancel_while_waiting() { + let cache = CACHE.lock().await; + let request = request(&uuid::Uuid::new_v4().to_string(), 1); + let search_request = request.clone(); + let task = tokio::spawn(search_workspace_files( + "/unused-because-canceled".into(), + "".into(), + Some(200), + Some(true), + None, + Some(search_request), + )); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if SEARCHES + .lock() + .unwrap() + .0 + .contains_key(&request.consumer_id) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert!( + !task.is_finished(), + "empty queries must wait for the shared worker" + ); + cancel_workspace_file_search(request).await.unwrap(); + let result = tokio::time::timeout(Duration::from_secs(1), task) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(result.is_empty()); + drop(cache); + } + + #[tokio::test] + async fn newer_generation_coalesces_a_queued_search_before_it_can_traverse() { + let cache = CACHE.lock().await; + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("latest.txt"), "x").unwrap(); + let root = dir.path().to_string_lossy().into_owned(); + let first = request(&uuid::Uuid::new_v4().to_string(), 1); + let latest = request(&first.consumer_id, 2); + let old_task = tokio::spawn(search_workspace_files( + root.clone(), + "obsolete".into(), + None, + None, + None, + Some(first.clone()), + )); + tokio::time::timeout(Duration::from_secs(1), async { + while !SEARCHES.lock().unwrap().0.contains_key(&first.consumer_id) { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let latest_task = tokio::spawn(search_workspace_files( + root, + "latest".into(), + None, + None, + None, + Some(latest), + )); + // The old request completes while the worker is still held: it never + // starts a superseded traversal or waits for the newer one's scan. + assert!(tokio::time::timeout(Duration::from_secs(1), old_task) + .await + .unwrap() + .unwrap() + .unwrap() + .is_empty()); + assert!(!latest_task.is_finished()); + drop(cache); + let results = latest_task.await.unwrap().unwrap(); + assert_eq!(results[0].path, "latest.txt"); + } + + #[test] + fn refresh_and_root_changes_replace_cached_entries() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + std::fs::write(first.path().join("old.ts"), "x").unwrap(); + std::fs::write(second.path().join("other.ts"), "x").unwrap(); + let mut cache = None; + let root = first.path().to_string_lossy().into_owned(); + run_search(&mut cache, root.clone(), "", 0, false, false, INDEX_BUDGET); + std::fs::write(first.path().join("new.ts"), "x").unwrap(); + assert!(run_search( + &mut cache, + root.clone(), + "new", + 10, + true, + false, + INDEX_BUDGET + ) + .is_empty()); + assert_eq!( + run_search(&mut cache, root, "new", 10, true, true, INDEX_BUDGET)[0].path, + "new.ts" + ); + let result = run_search( + &mut cache, + second.path().to_string_lossy().into_owned(), + "ts", + 10, + true, + false, + INDEX_BUDGET, + ); + assert_eq!(result.len(), 1); + assert_eq!(result[0].path, "other.ts"); + assert!(cache.as_ref().unwrap().index.as_ref().unwrap().bytes() <= INDEX_BYTES); + } + + #[tokio::test] + async fn api_caps_empty_query_and_keeps_files_only() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("a-directory")).unwrap(); + for i in 0..300 { + std::fs::write(dir.path().join(format!("f{i:03}.txt")), "x").unwrap(); + } + let results = search_workspace_files( + dir.path().to_string_lossy().into_owned(), + "".into(), + Some(usize::MAX), + Some(true), + None, + None, + ) + .await + .unwrap(); + assert_eq!(results.len(), MAX_RESULTS); + assert!(results.iter().all(|e| e.kind == WorkspaceEntryKind::File)); + } + + /// Manual end-to-end probe for unusually large workspaces. The path stays + /// local; output contains counts/budgets/timings rather than filenames. + #[test] + #[ignore = "set CODEG_MEMORY_WORKSPACE to profile a real workspace"] + fn large_workspace_memory_probe() { + let root = std::env::var("CODEG_MEMORY_WORKSPACE").expect("CODEG_MEMORY_WORKSPACE"); + let started = Instant::now(); + let mut cache = None; + println!("probe_pid={}", std::process::id()); + run_search(&mut cache, root.clone(), "", 0, false, false, INDEX_BUDGET); + let warm_seconds = started.elapsed().as_secs_f64(); + let stats = cache.as_ref().and_then(|c| c.index.as_ref()).map(|i| { + serde_json::json!({"entries":i.nodes.len(),"uniqueNames":i.names.len(),"retainedBytes":i.bytes()}) + }); + println!( + "{}", + serde_json::json!({"warmSeconds":warm_seconds,"index":stats}) + ); + for query in ["README.md", "index.ts", "workspace_file_search"] { + let started = Instant::now(); + let results = run_search( + &mut cache, + root.clone(), + query, + 100, + true, + false, + INDEX_BUDGET, + ); + assert!(results.len() <= 100); + println!( + "{}", + serde_json::json!({"query":query,"results":results.len(),"responseBytes":serde_json::to_vec(&results).unwrap().len(),"seconds":started.elapsed().as_secs_f64()}) + ); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aa8c8a39a5d..1554deb2d85 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1980,6 +1980,9 @@ mod tauri_app { folders::list_directory_with_files, folders::get_file_tree, folders::list_workspace_files, + crate::commands::workspace_file_search::search_workspace_files, + crate::commands::workspace_file_search::cancel_workspace_file_search, + crate::commands::workspace_file_search::prepare_workspace_file_search, folders::read_file_base64, folders::read_workspace_file_base64, folders::read_file_preview, diff --git a/src-tauri/src/web/handlers/folders.rs b/src-tauri/src/web/handlers/folders.rs index 9ade38657d8..a0d27722618 100644 --- a/src-tauri/src/web/handlers/folders.rs +++ b/src-tauri/src/web/handlers/folders.rs @@ -410,6 +410,52 @@ pub async fn list_workspace_files( Ok(Json(result)) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchWorkspaceFilesParams { + pub path: String, + pub query: String, + pub limit: Option, + pub files_only: Option, + pub refresh: Option, + pub request: Option, +} + +pub async fn search_workspace_files( + Json(params): Json, +) -> Result>, AppCommandError> { + Ok(Json( + crate::commands::workspace_file_search::search_workspace_files( + params.path, + params.query, + params.limit, + params.files_only, + params.refresh, + params.request, + ) + .await?, + )) +} + +#[derive(Deserialize)] +pub struct CancelWorkspaceFileSearchParams { + pub request: crate::commands::workspace_file_search::SearchRequest, +} + +pub async fn cancel_workspace_file_search( + Json(params): Json, +) -> Result, AppCommandError> { + crate::commands::workspace_file_search::cancel_workspace_file_search(params.request).await?; + Ok(Json(())) +} + +pub async fn prepare_workspace_file_search( + Json(params): Json, +) -> Json<()> { + crate::commands::workspace_file_search::prepare_workspace_file_search(params.path).await; + Json(()) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct OpenSettingsWindowParams { diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 9b4cb100f01..831452e514a 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -529,6 +529,18 @@ pub fn build_router_with_proxy_auth( "/list_workspace_files", post(handlers::folders::list_workspace_files), ) + .route( + "/search_workspace_files", + post(handlers::folders::search_workspace_files), + ) + .route( + "/cancel_workspace_file_search", + post(handlers::folders::cancel_workspace_file_search), + ) + .route( + "/prepare_workspace_file_search", + post(handlers::folders::prepare_workspace_file_search), + ) .route( "/start_workspace_state_stream", post(handlers::workspace_state::start_workspace_state_stream), diff --git a/src/components/ai-elements/code-block.tsx b/src/components/ai-elements/code-block.tsx index db29ebaf99b..1be545b02b0 100644 --- a/src/components/ai-elements/code-block.tsx +++ b/src/components/ai-elements/code-block.tsx @@ -1,12 +1,7 @@ "use client" import type { ComponentProps, CSSProperties, HTMLAttributes } from "react" -import type { - BundledLanguage, - BundledTheme, - HighlighterGeneric, - ThemedToken, -} from "shiki" +import type { BundledLanguage, ThemedToken, TokensResult } from "shiki" import { Button } from "@/components/ui/button" import { ScrollArea } from "@/components/ui/scroll-area" @@ -18,6 +13,13 @@ import { SelectValue, } from "@/components/ui/select" import { cn, copyTextToClipboard } from "@/lib/utils" +import { + DEFAULT_CODE_THEMES, + getCachedHighlightedCode, + highlightCode as highlightSharedCode, + rawCodeTokens, + subscribeHighlightedCode, +} from "@/lib/code-highlighter" import { CheckIcon, CopyIcon } from "lucide-react" import { createContext, @@ -118,127 +120,22 @@ const CodeBlockContext = createContext({ code: "", }) -// Highlighter cache (singleton per language) -const highlighterCache = new Map< - string, - Promise> ->() - -// Token cache -const tokensCache = new Map() - -// Subscribers for async token updates -const subscribers = new Map void>>() - -const getTokensCacheKey = (code: string, language: BundledLanguage) => { - const start = code.slice(0, 100) - const end = code.length > 100 ? code.slice(-100) : "" - return `${language}:${code.length}:${start}:${end}` -} - -const getHighlighter = ( - language: BundledLanguage -): Promise> => { - const cached = highlighterCache.get(language) - if (cached) { - return cached - } - - // Load Shiki's engine lazily: a static `import` would pull it (and its - // textmate machinery) into the first-paint bundle for every message list, - // even a text-only conversation. Highlighting is already async with an - // immediate raw-token fallback (see `createRawTokens`), so deferring the - // engine import only extends that existing pre-highlight window slightly. - const highlighterPromise = import("shiki").then(({ createHighlighter }) => - createHighlighter({ - langs: [language], - themes: ["github-light", "github-dark"], - }) - ) - - highlighterCache.set(language, highlighterPromise) - return highlighterPromise -} - -// Create raw tokens for immediate display while highlighting loads -const createRawTokens = (code: string): TokenizedCode => ({ - bg: "transparent", - fg: "inherit", - tokens: code.split("\n").map((line) => - line === "" - ? [] - : [ - { - color: "inherit", - content: line, - } as ThemedToken, - ] - ), +const normalizeTokens = (result: TokensResult): TokenizedCode => ({ + tokens: result.tokens, + fg: result.fg ?? "inherit", + bg: result.bg ?? "transparent", }) -// Synchronous highlight with callback for async results export const highlightCode = ( code: string, language: BundledLanguage, - // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks) callback?: (result: TokenizedCode) => void ): TokenizedCode | null => { - const tokensCacheKey = getTokensCacheKey(code, language) - - // Return cached result if available - const cached = tokensCache.get(tokensCacheKey) - if (cached) { - return cached - } - - // Subscribe callback if provided - if (callback) { - if (!subscribers.has(tokensCacheKey)) { - subscribers.set(tokensCacheKey, new Set()) - } - subscribers.get(tokensCacheKey)?.add(callback) - } - - // Start highlighting in background - fire-and-forget async pattern - getHighlighter(language) - // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then) - .then((highlighter) => { - const availableLangs = highlighter.getLoadedLanguages() - const langToUse = availableLangs.includes(language) ? language : "text" - - const result = highlighter.codeToTokens(code, { - lang: langToUse, - themes: { - dark: "github-dark", - light: "github-light", - }, - }) - - const tokenized: TokenizedCode = { - bg: result.bg ?? "transparent", - fg: result.fg ?? "inherit", - tokens: result.tokens, - } - - // Cache the result - tokensCache.set(tokensCacheKey, tokenized) - - // Notify all subscribers - const subs = subscribers.get(tokensCacheKey) - if (subs) { - for (const sub of subs) { - sub(tokenized) - } - subscribers.delete(tokensCacheKey) - } - }) - // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks) - .catch((error) => { - console.error("Failed to highlight code:", error) - subscribers.delete(tokensCacheKey) - }) - - return null + const result = highlightSharedCode( + { code, language, themes: DEFAULT_CODE_THEMES }, + callback ? (tokens) => callback(normalizeTokens(tokens)) : undefined + ) + return result ? normalizeTokens(result) : null } // Line number styles using CSS counters @@ -391,13 +288,17 @@ export function useHighlightedTokens( language: BundledLanguage ): TokenizedCode { // Memoized raw tokens for immediate display - const rawTokens = useMemo(() => createRawTokens(code), [code]) + const rawTokens = useMemo(() => normalizeTokens(rawCodeTokens(code)), [code]) // Synchronous cached-or-raw value, recomputed when code/language changes - const syncTokenized = useMemo( - () => highlightCode(code, language) ?? rawTokens, - [code, language, rawTokens] - ) + const syncTokenized = useMemo(() => { + const cached = getCachedHighlightedCode({ + code, + language, + themes: DEFAULT_CODE_THEMES, + }) + return cached ? normalizeTokens(cached) : rawTokens + }, [code, language, rawTokens]) // Async highlighted result, tagged with its source code/language const [asyncState, setAsyncState] = useState<{ @@ -406,20 +307,16 @@ export function useHighlightedTokens( tokenized: TokenizedCode } | null>(null) - useEffect(() => { - let cancelled = false - - // Subscribe to async highlighting result - highlightCode(code, language, (result) => { - if (!cancelled) { - setAsyncState({ code, language, tokenized: result }) - } - }) - - return () => { - cancelled = true - } - }, [code, language]) + useEffect( + () => + subscribeHighlightedCode( + { code, language, themes: DEFAULT_CODE_THEMES }, + (result) => { + setAsyncState({ code, language, tokenized: normalizeTokens(result) }) + } + ), + [code, language] + ) // Use async result only if it matches current code/language return asyncState?.code === code && asyncState?.language === language diff --git a/src/components/ai-elements/html-code-preview-memory.test.tsx b/src/components/ai-elements/html-code-preview-memory.test.tsx new file mode 100644 index 00000000000..8e482e11b6c --- /dev/null +++ b/src/components/ai-elements/html-code-preview-memory.test.tsx @@ -0,0 +1,128 @@ +import { act, fireEvent, render, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { buildChatHtmlDocument } from "@/lib/chat-html-document" +import { + HtmlCodePreview, + resetHtmlPreviewCachesForTests, +} from "./html-code-preview" + +vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key })) +vi.mock("next-themes", () => ({ + useTheme: () => ({ resolvedTheme: "light" }), +})) +vi.mock("@/lib/chat-html-document", () => ({ + buildChatHtmlDocument: vi.fn(async (source: string) => `

${source}

`), +})) + +const build = vi.mocked(buildChatHtmlDocument) +const preview = (source: string, key?: string) => ( + +
code content
+
+) + +beforeEach(() => { + resetHtmlPreviewCachesForTests() + build.mockClear() +}) + +describe("HTML preview cache retention", () => { + it("trims entries when the last owners of more than 32 previews close", async () => { + const sources = Array.from({ length: 40 }, (_, i) => `preview ${i}`) + const mounted = render(<>{sources.map((s) => preview(s, s))}) + await waitFor(() => + expect(mounted.container.querySelectorAll("iframe")).toHaveLength(40) + ) + mounted.unmount() + + const reopened = render(preview(sources[0])) + await waitFor(() => + expect(reopened.container.querySelector("iframe")).not.toBeNull() + ) + expect(build).toHaveBeenCalledTimes(41) + }) + + it("does not retain an oversized document after its view closes", async () => { + const source = "x".repeat(3 * 1024 * 1024) + const mounted = render(preview(source)) + await waitFor(() => + expect(mounted.container.querySelector("iframe")).not.toBeNull() + ) + mounted.unmount() + + const reopened = render(preview(source)) + await waitFor(() => + expect(reopened.container.querySelector("iframe")).not.toBeNull() + ) + expect(build).toHaveBeenCalledTimes(2) + }) + + it("bounds the combined size of otherwise cacheable documents", async () => { + const sources = Array.from( + { length: 8 }, + (_, i) => `${i}${"x".repeat(256 * 1024)}` + ) + const mounted = render(<>{sources.map((s, i) => preview(s, String(i)))}) + await waitFor(() => + expect(mounted.container.querySelectorAll("iframe")).toHaveLength(8) + ) + mounted.unmount() + + const reopened = render(preview(sources[0])) + await waitFor(() => + expect(reopened.container.querySelector("iframe")).not.toBeNull() + ) + expect(build).toHaveBeenCalledTimes(9) + }) + + it("does not keep a large source alive merely to remember its view choice", async () => { + const source = "x".repeat(1024 * 1024) + const mounted = render(preview(source)) + await waitFor(() => + expect(mounted.container.querySelector("iframe")).not.toBeNull() + ) + fireEvent.click(mounted.getByRole("button", { name: "code" })) + mounted.unmount() + + const reopened = render(preview(source)) + await waitFor(() => + expect(reopened.container.querySelector("iframe")).not.toBeNull() + ) + }) + + it("keeps a shared active document pinned until its final owner closes", async () => { + const source = "shared" + "x".repeat(3 * 1024 * 1024) + const first = render(preview(source)) + await waitFor(() => + expect(first.container.querySelector("iframe")).not.toBeNull() + ) + const second = render(preview(source)) + expect(second.container.querySelector("iframe")).not.toBeNull() + first.unmount() + const third = render(preview(source)) + expect(third.container.querySelector("iframe")).not.toBeNull() + expect(build).toHaveBeenCalledTimes(1) + second.unmount() + third.unmount() + }) + + it("applies the byte budget to a build that finishes after unmount", async () => { + const source = "late" + "x".repeat(3 * 1024 * 1024) + let finish!: (doc: string) => void + build.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const mounted = render(preview(source)) + mounted.unmount() + await act(async () => finish(`

${source}

`)) + + const reopened = render(preview(source)) + await waitFor(() => + expect(reopened.container.querySelector("iframe")).not.toBeNull() + ) + expect(build).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/components/ai-elements/html-code-preview.tsx b/src/components/ai-elements/html-code-preview.tsx index 914eb34631b..877f5e0c3d7 100644 --- a/src/components/ai-elements/html-code-preview.tsx +++ b/src/components/ai-elements/html-code-preview.tsx @@ -39,13 +39,46 @@ const views = new Map() /** Previews on screen, by `frames` key: their entries are never evicted. */ const pins = new Map() const CACHE_LIMIT = 32 +const FRAME_CACHE_BYTES = 8 * 1024 * 1024 +const VIEW_CACHE_BYTES = 1024 * 1024 +let frameBytes = 0 +let viewBytes = 0 -function remember(cache: Map, key: string, value: T) { - cache.delete(key) - cache.set(key, value) - for (const oldest of cache.keys()) { - if (cache.size <= CACHE_LIMIT) break - if (cache !== frames || !pins.has(oldest)) cache.delete(oldest) +// Count source keys as well as generated documents. A small number of large +// replies can otherwise keep arbitrarily large strings alive after closing. +function frameSize(key: string, entry: FrameEntry) { + return 128 + (key.length + entry.theme.length + (entry.doc?.length ?? 0)) * 2 +} + +function trimFrames() { + for (const [key, entry] of frames) { + if (frames.size <= CACHE_LIMIT && frameBytes <= FRAME_CACHE_BYTES) break + if (pins.has(key)) continue + frames.delete(key) + frameBytes -= frameSize(key, entry) + } +} + +function rememberFrame(key: string, entry: FrameEntry) { + const previous = frames.get(key) + if (previous) frameBytes -= frameSize(key, previous) + frames.delete(key) + frames.set(key, entry) + frameBytes += frameSize(key, entry) + trimFrames() +} + +function rememberView(key: string, view: View) { + const bytes = 64 + key.length * 2 + if (views.delete(key)) viewBytes -= bytes + // A view preference alone should never pin a multi-megabyte source. + if (bytes > VIEW_CACHE_BYTES) return + views.set(key, view) + viewBytes += bytes + for (const oldest of views.keys()) { + if (views.size <= CACHE_LIMIT && viewBytes <= VIEW_CACHE_BYTES) break + views.delete(oldest) + viewBytes -= 64 + oldest.length * 2 } } @@ -53,6 +86,8 @@ function remember(cache: Map, key: string, value: T) { export function resetHtmlPreviewCachesForTests(): void { frames.clear() views.clear() + frameBytes = 0 + viewBytes = 0 } export function HtmlCodePending() { @@ -81,7 +116,7 @@ export function HtmlCodePreview({ const { preview, expanded } = view const changeView = (change: Partial) => { const next = { ...view, ...change } - remember(views, source, next) + rememberView(source, next) setView(next) } const frameRef = useRef(null) @@ -102,6 +137,9 @@ export function HtmlCodePreview({ const count = (pins.get(key) ?? 1) - 1 if (count > 0) pins.set(key, count) else pins.delete(key) + // Pinned entries may exceed the cache budget while visible. Enforce it + // immediately when they close, even if no new preview is ever built. + trimFrames() } }, [key]) @@ -111,7 +149,7 @@ export function HtmlCodePreview({ let active = true buildChatHtmlDocument(source, { nonce: randomUUID(), dark, theme }).then( (doc) => { - remember(frames, key, { + rememberFrame(key, { doc, theme, height: frames.get(key)?.height, @@ -119,7 +157,7 @@ export function HtmlCodePreview({ if (active) setLoaded({ key, doc }) }, () => { - remember(frames, key, { doc: null, theme }) + rememberFrame(key, { doc: null, theme }) if (active) setLoaded({ key, doc: null }) } ) diff --git a/src/components/ai-elements/message.test.tsx b/src/components/ai-elements/message.test.tsx index a0549a4f410..1e386ec43f4 100644 --- a/src/components/ai-elements/message.test.tsx +++ b/src/components/ai-elements/message.test.tsx @@ -35,11 +35,16 @@ vi.mock("@streamdown/math", () => ({ createMathPlugin: () => ({}), })) vi.mock("@streamdown/mermaid", () => ({ mermaid: {} })) -vi.mock("@streamdown/code", () => ({ - code: { +vi.mock("@/lib/code-highlighter", async (importOriginal) => ({ + ...(await importOriginal()), + createCodePlugin: async () => ({ + name: "shiki", + type: "code-highlighter", highlight: vi.fn(), supportsLanguage: vi.fn(() => true), - }, + getSupportedLanguages: () => [], + getThemes: () => ["github-light", "github-dark"], + }), })) vi.mock("@/components/ai-elements/link-safety", () => ({ diff --git a/src/components/ai-elements/reasoning.test.tsx b/src/components/ai-elements/reasoning.test.tsx index c7410937a0e..9a5c16d3eea 100644 --- a/src/components/ai-elements/reasoning.test.tsx +++ b/src/components/ai-elements/reasoning.test.tsx @@ -39,8 +39,16 @@ vi.mock("streamdown", () => ({ vi.mock("@streamdown/cjk", () => ({ cjk: {} })) vi.mock("@streamdown/math", () => ({ createMathPlugin: () => ({}) })) vi.mock("@streamdown/mermaid", () => ({ mermaid: {} })) -vi.mock("@streamdown/code", () => ({ - code: { highlight: vi.fn(), supportsLanguage: vi.fn(() => true) }, +vi.mock("@/lib/code-highlighter", async (importOriginal) => ({ + ...(await importOriginal()), + createCodePlugin: async () => ({ + name: "shiki", + type: "code-highlighter", + highlight: vi.fn(), + supportsLanguage: vi.fn(() => true), + getSupportedLanguages: () => [], + getThemes: () => ["github-light", "github-dark"], + }), })) vi.mock("@/components/ai-elements/link-safety", () => ({ diff --git a/src/components/ai-elements/streamdown-plugins.test.ts b/src/components/ai-elements/streamdown-plugins.test.ts index 961686f01ff..c8afe9d3289 100644 --- a/src/components/ai-elements/streamdown-plugins.test.ts +++ b/src/components/ai-elements/streamdown-plugins.test.ts @@ -26,15 +26,15 @@ vi.mock("@streamdown/cjk", () => ({ })) // The three heavy engines are dynamically imported by `ensure()`; vitest mocks // intercept dynamic `import()` too, so these stand in for shiki/katex/mermaid. -vi.mock("@streamdown/code", () => ({ - code: { +vi.mock("@/lib/code-highlighter", () => ({ + createCodePlugin: async () => ({ name: "shiki", type: "code-highlighter", highlight: mocks.highlight, supportsLanguage: mocks.supportsLanguage, getSupportedLanguages: () => [], getThemes: () => ["light", "dark"], - }, + }), })) vi.mock("@streamdown/math", () => ({ createMathPlugin: mocks.createMathPlugin, diff --git a/src/components/ai-elements/streamdown-plugins.ts b/src/components/ai-elements/streamdown-plugins.ts index 5926f05341b..161403af3d5 100644 --- a/src/components/ai-elements/streamdown-plugins.ts +++ b/src/components/ai-elements/streamdown-plugins.ts @@ -16,7 +16,7 @@ export type HeavyKind = "code" | "math" | "mermaid" // --- Why this module exists -------------------------------------------------- // // The three heavy Streamdown plugins pull in the largest dependencies in the -// whole frontend: `@streamdown/code` → shiki (+ its full grammar/theme index, +// whole frontend: the code plugin → shiki (+ its full grammar/theme index, // ~3.8M), `@streamdown/math` → katex (~4.3M), `@streamdown/mermaid` → mermaid + // cytoscape + the entire d3 galaxy + dagre (~70M+ unpacked). Statically // importing them — as `message.tsx`, `reasoning.tsx`, and `file-workspace-panel` @@ -179,9 +179,10 @@ function ensure(kind: HeavyKind): void { emit() } if (kind === "code") { - import("@streamdown/code") - .then((mod) => { - loaded.code = makeSafeCode(mod.code) + import("@/lib/code-highlighter") + .then((mod) => mod.createCodePlugin()) + .then((code) => { + loaded.code = makeSafeCode(code) }) .catch(() => {}) .finally(settle) diff --git a/src/components/canvas/add-node-menu.tsx b/src/components/canvas/add-node-menu.tsx index 2fa323a68cd..19473a83b0a 100644 --- a/src/components/canvas/add-node-menu.tsx +++ b/src/components/canvas/add-node-menu.tsx @@ -371,9 +371,8 @@ const FILE_RESULT_LIMIT = 20 * search dialog and the composer's `@` picker use. "Browse…" covers everything * outside a workspace folder. * - * The walk is lazy: `useFileTree` only fetches once `enabled` goes true, which - * happens when the submenu opens — a board that never adds a file never pays - * for the listing. + * Workspace discovery starts only when the user types a query; the initial + * open-files listing needs no backend traversal. */ function AddFileSubmenu({ onPick }: { onPick: (absPath: string) => void }) { const t = useTranslations("Canvas") @@ -393,7 +392,10 @@ function AddFileSubmenu({ onPick }: { onPick: (absPath: string) => void }) { const { allFiles, loading } = useFileTree({ folderPath: searchFolder?.path, - enabled: open && searchFolder != null, + enabled: open && searchFolder != null && query.trim().length > 0, + query, + filesOnly: true, + limit: FILE_RESULT_LIMIT, }) const results = useMemo(() => { diff --git a/src/components/chat/composer/suggestion/suggestion-popup.test.tsx b/src/components/chat/composer/suggestion/suggestion-popup.test.tsx index cc711990cd9..b4404ebcd78 100644 --- a/src/components/chat/composer/suggestion/suggestion-popup.test.tsx +++ b/src/components/chat/composer/suggestion/suggestion-popup.test.tsx @@ -709,10 +709,38 @@ describe("SuggestionPopup with a chosen tab order", () => { const spy = vi.fn(() => groups) mountPopup({ search: spy, tabs: ["file", "agent"] }) await screen.findByText("alpha.md") - expect(spy).toHaveBeenCalledWith(state.query, expect.any(AbortSignal), [ - "file", - "agent", - ]) + expect(spy).toHaveBeenCalledWith( + state.query, + expect.any(AbortSignal), + ["file", "agent"], + expect.any(Function) + ) + }) + + it("shows ready agents while files load and preserves selection when file results arrive", async () => { + let finish!: (groups: SuggestionGroup[]) => void + const progressive: ReferenceSearch = (_query, _signal, _kinds, update) => { + update?.( + groups.map((group) => + group.kind === "file" ? { ...group, items: [], loading: true } : group + ) + ) + return new Promise((resolve) => { + finish = resolve + }) + } + const { ref, onSelect } = mountPopup({ + search: progressive, + tabs: ["agent", "file"], + }) + await screen.findByText("Codex Helper") + fireEvent.click(screen.getByRole("tab", { name: /^Files/ })) + expect(screen.getByRole("status")).toHaveTextContent("Searching…") + fireEvent.click(screen.getByRole("tab", { name: /^Agents/ })) + act(() => ref.current?.onKeyDown(key("ArrowDown"))) + await act(async () => finish(groups)) + act(() => ref.current?.onKeyDown(key("Enter"))) + expect(onSelect).toHaveBeenCalledWith(agentRef2, state.range) }) it("re-queries when the shown tabs change while it is open", async () => { @@ -739,11 +767,12 @@ describe("SuggestionPopup with a chosen tab order", () => { // keystroke. rerender(view(["file", "agent", "session"])) await waitFor(() => expect(spy).toHaveBeenCalledTimes(2)) - expect(spy).toHaveBeenLastCalledWith(state.query, expect.any(AbortSignal), [ - "file", - "agent", - "session", - ]) + expect(spy).toHaveBeenLastCalledWith( + state.query, + expect.any(AbortSignal), + ["file", "agent", "session"], + expect.any(Function) + ) expect(screen.getAllByRole("tab")).toHaveLength(3) }) diff --git a/src/components/chat/composer/suggestion/suggestion-popup.tsx b/src/components/chat/composer/suggestion/suggestion-popup.tsx index b886670194f..4194b222812 100644 --- a/src/components/chat/composer/suggestion/suggestion-popup.tsx +++ b/src/components/chat/composer/suggestion/suggestion-popup.tsx @@ -207,18 +207,21 @@ export const SuggestionPopup = forwardRef< useEffect(() => { const abort = new AbortController() let active = true + let published = false + const publish = (groups: SuggestionGroup[]) => { + if (!active || abort.signal.aborted) return + const kinds = tabOrder.filter( + (kind) => !groups.some((group) => group.kind === kind && group.loading) + ) + setResult({ query: state.query, kinds, groups }) + // A late file result must not reset navigation within a ready group. + if (!published) setSelectedIndex(0) + published = true + } const timer = setTimeout(() => { - Promise.resolve(search(state.query, abort.signal, tabOrder)) - .then((groups) => { - if (!active || abort.signal.aborted) return - setResult({ query: state.query, kinds: tabOrder, groups }) - setSelectedIndex(0) - }) - .catch(() => { - if (!active || abort.signal.aborted) return - setResult({ query: state.query, kinds: tabOrder, groups: [] }) - setSelectedIndex(0) - }) + Promise.resolve(search(state.query, abort.signal, tabOrder, publish)) + .then(publish) + .catch(() => publish([])) }, FETCH_DEBOUNCE_MS) return () => { active = false diff --git a/src/components/chat/composer/suggestion/types.ts b/src/components/chat/composer/suggestion/types.ts index 0e37b803b0e..85d410c3020 100644 --- a/src/components/chat/composer/suggestion/types.ts +++ b/src/components/chat/composer/suggestion/types.ts @@ -22,6 +22,8 @@ export interface SuggestionGroup { * the overflow. */ truncated?: boolean + /** This source is still loading; other groups may already be selectable. */ + loading?: boolean } /** @@ -50,14 +52,15 @@ export interface MentionUiLabels { * outside it is never displayed, so an implementation may skip loading its * source and return it empty; omitted means every kind is wanted. * - * Phase 2 ships the panel against this interface; Phase 3 supplies the real - * implementation (wired to the live data hooks) when the composer replaces the - * textarea in message-input. + * `onUpdate` publishes partial snapshots as sources become ready. Groups still + * waiting for their source set `loading`; the returned value is the final + * snapshot. Consumers that omit the callback can await all groups as before. */ export type ReferenceSearch = ( query: string, signal?: AbortSignal, - kinds?: readonly ReferenceKind[] + kinds?: readonly ReferenceKind[], + onUpdate?: (groups: SuggestionGroup[]) => void ) => SuggestionGroup[] | Promise /** State the suggestion plugin pushes to React while the `@` panel is open. */ diff --git a/src/components/chat/composer/use-reference-search.test.ts b/src/components/chat/composer/use-reference-search.test.ts index 13f71358bab..e937360f46d 100644 --- a/src/components/chat/composer/use-reference-search.test.ts +++ b/src/components/chat/composer/use-reference-search.test.ts @@ -415,6 +415,8 @@ const mocks = vi.hoisted(() => ({ files: { allFiles: [] as FlatFileEntry[], loaded: false }, listAllConversations: vi.fn(), gitLog: vi.fn(), + searchWorkspaceFiles: vi.fn(), + prepareWorkspaceFileSearch: vi.fn(), })) vi.mock("@/hooks/use-file-tree", () => ({ @@ -432,6 +434,10 @@ vi.mock("@/lib/api", () => ({ listAllConversations: (...args: unknown[]) => mocks.listAllConversations(...args), gitLog: (...args: unknown[]) => mocks.gitLog(...args), + searchWorkspaceFiles: (...args: unknown[]) => + mocks.searchWorkspaceFiles(...args), + prepareWorkspaceFileSearch: (...args: unknown[]) => + mocks.prepareWorkspaceFileSearch(...args), })) describe("useReferenceSearch", () => { @@ -439,12 +445,49 @@ describe("useReferenceSearch", () => { resetAssistantsStore() mocks.agents = [] mocks.files = { allFiles: [], loaded: false } + mocks.prepareWorkspaceFileSearch.mockReset().mockResolvedValue(undefined) + mocks.searchWorkspaceFiles.mockReset().mockImplementation(async () => + mocks.files.loaded + ? mocks.files.allFiles.map((f) => ({ + name: f.name, + path: f.relativePath, + kind: f.kind, + })) + : [] + ) mocks.listAllConversations.mockReset().mockResolvedValue([]) mocks.gitLog .mockReset() .mockResolvedValue({ entries: [], has_upstream: false }) }) + it("prepares native storage without transferring files until the file group is requested", async () => { + const { result } = renderHook(() => + useReferenceSearch({ defaultPath: "/repo" }) + ) + expect(mocks.prepareWorkspaceFileSearch).toHaveBeenCalledWith("/repo") + expect(mocks.searchWorkspaceFiles).not.toHaveBeenCalled() + await act(async () => { + await result.current("a", undefined, ["agent"]) + }) + expect(mocks.searchWorkspaceFiles).not.toHaveBeenCalled() + await act(async () => { + await result.current("a", undefined, ["file"]) + }) + expect(mocks.searchWorkspaceFiles).toHaveBeenCalledExactlyOnceWith( + "/repo", + "a", + { + limit: 51, + request: { + consumerId: expect.any(String), + generation: expect.any(Number), + }, + signal: expect.any(AbortSignal), + } + ) + }) + it("returns a referentially stable search across data-source updates (R7)", async () => { mocks.agents = [makeAgent("codex", { name: "Codex" })] const { result, rerender } = renderHook( @@ -730,4 +773,69 @@ describe("useReferenceSearch", () => { // …which succeeds and populates the group. expect(itemsOf(second, "session")).toHaveLength(1) }) + + it("publishes ready groups while file discovery is pending and merges each source independently", async () => { + mocks.agents = [makeAgent("codex")] + useAssistantsStore.getState().merge([makeAssistant(3, "Reviewer")]) + let filesReady!: ( + files: { name: string; path: string; kind: "file" }[] + ) => void + mocks.searchWorkspaceFiles.mockReturnValue( + new Promise((resolve) => { + filesReady = resolve + }) + ) + let sessionsReady!: (sessions: DbConversationSummary[]) => void + mocks.listAllConversations.mockReturnValue( + new Promise((resolve) => { + sessionsReady = resolve + }) + ) + const { result } = renderHook(() => + useReferenceSearch({ defaultPath: "/repo" }) + ) + const update = vi.fn() + const pending = result.current("", undefined, undefined, update) + const ready = update.mock.lastCall![0] as SuggestionGroup[] + expect(itemsOf(ready, "agent")).toHaveLength(1) + expect(itemsOf(ready, "assistant")).toHaveLength(1) + expect(ready.find((g) => g.kind === "file")?.loading).toBe(true) + sessionsReady([makeConversation(7, "Session")]) + await vi.waitFor(() => { + expect(itemsOf(update.mock.lastCall![0], "session")).toHaveLength(1) + }) + expect( + update.mock.lastCall![0].find((g: SuggestionGroup) => g.kind === "file") + ?.loading + ).toBe(true) + filesReady([{ name: "a.ts", path: "a.ts", kind: "file" }]) + expect(itemsOf(await pending, "file")).toHaveLength(1) + expect(itemsOf(update.mock.lastCall![0], "file")).toHaveLength(1) + }) + + it("cancels superseded file searches and suppresses stale partial updates", async () => { + let resolveOld!: (files: []) => void + mocks.searchWorkspaceFiles.mockReturnValueOnce( + new Promise((resolve) => { + resolveOld = resolve + }) + ) + const { result, unmount } = renderHook(() => + useReferenceSearch({ defaultPath: "/repo" }) + ) + const oldUpdate = vi.fn() + const old = result.current("a", undefined, ["file"], oldUpdate) + const first = mocks.searchWorkspaceFiles.mock.calls[0][2] + const newer = result.current("ab", undefined, ["file"]) + const second = mocks.searchWorkspaceFiles.mock.calls[1][2] + expect(first.signal.aborted).toBe(true) + expect(second.request.consumerId).toBe(first.request.consumerId) + expect(second.request.generation).toBeGreaterThan(first.request.generation) + await newer + oldUpdate.mockClear() + resolveOld([]) + expect(await old).toEqual([]) + expect(oldUpdate).not.toHaveBeenCalled() + unmount() + }) }) diff --git a/src/components/chat/composer/use-reference-search.ts b/src/components/chat/composer/use-reference-search.ts index fecf3aee4d8..0abb8661fcf 100644 --- a/src/components/chat/composer/use-reference-search.ts +++ b/src/components/chat/composer/use-reference-search.ts @@ -3,8 +3,13 @@ import { useCallback, useEffect, useLayoutEffect, useRef } from "react" import { useAcpAgents } from "@/hooks/use-acp-agents" -import { useFileTree, type FlatFileEntry } from "@/hooks/use-file-tree" -import { gitLog, listAllConversations } from "@/lib/api" +import type { FlatFileEntry } from "@/hooks/use-file-tree" +import { + gitLog, + listAllConversations, + prepareWorkspaceFileSearch, + searchWorkspaceFiles, +} from "@/lib/api" import type { AcpAgentInfo, AssistantInfo, @@ -12,6 +17,7 @@ import type { GitLogEntry, } from "@/lib/types" import { useAssistants } from "@/stores/assistants-store" +import { randomUUID } from "@/lib/utils" import { agentToSuggestion, @@ -108,9 +114,8 @@ export function buildReferenceGroups( ): SuggestionGroup[] { const q = query.trim().toLowerCase() - // Files: filter the (potentially large) list on its pre-lowered fields before - // paying to adapt the survivors. `truncated` is a cheap boolean — set when a - // match is found past the cap — so we never scan the whole list for a count. + // Native search supplies at most MAX_PER_GROUP + 1 ranked matches. Keep the + // mention panel's substring semantics and use the extra row for truncation. const fileItems: SuggestionItem[] = [] let fileTruncated = false const root = sources.workspaceRoot @@ -239,10 +244,11 @@ export interface UseReferenceSearchOptions { * on window focus) updates the refs but leaves `search` identity untouched — the * open panel keeps its results and the user's selection (R7). * - * Files and agents are hook-loaded (and pre-warmed via `enabled`); assistants - * come from the assistants store, which the workspace layout keeps live. Sessions and - * the git log are fetched lazily on the first `@`, key-cached in a ref, and - * awaited by `search` so the first open is populated without an extra keystroke; + * File discovery is pre-warmed in shared, budgeted native storage; only the + * current query's matches are transferred on `@`. Agents are hook-loaded and + * assistants come from the live store. Sessions and the git log are fetched + * lazily on the first `@`, key-cached in a ref, and published independently + * so the first open is populated without an extra keystroke; * window focus busts those caches so they stay fresh. A tab the user hid (the * `kinds` the panel passes leave it out) costs nothing: its source isn't * fetched and its group comes back empty. @@ -254,20 +260,18 @@ export function useReferenceSearch({ }: UseReferenceSearchOptions): ReferenceSearch { const path = defaultPath || null - const { allFiles, loaded } = useFileTree({ - folderPath: path ?? undefined, - enabled, - }) + useEffect(() => { + if (enabled && path) void prepareWorkspaceFileSearch(path).catch(() => {}) + }, [enabled, path]) const { agents } = useAcpAgents() const assistants = useAssistants() // Mirror every changing source into a ref so `search` can stay identity-stable // (see the doc comment). Initialized from the first render so the refs are // sane even before the sync effect below runs. - const filesRef = useRef<{ root: string | null; files: FlatFileEntry[] }>({ - root: null, - files: [], - }) + const fileSearchRef = useRef(null) + const consumerRef = useRef(undefined) + const generationRef = useRef(0) const agentsRef = useRef(agents) const assistantsRef = useRef(assistants) const pathRef = useRef(path) @@ -283,20 +287,17 @@ export function useReferenceSearch({ useIsomorphicLayoutEffect(() => { pathRef.current = path enabledRef.current = enabled + return () => { + generationRef.current += 1 + fileSearchRef.current?.abort() + } }, [path, enabled]) useEffect(() => { - // Only expose files once the tree has loaded for the *current* path, so the - // search never joins the current workspace root onto a previous folder's - // relative paths during a folder switch. - filesRef.current = - loaded && path - ? { root: path, files: allFiles } - : { root: null, files: [] } agentsRef.current = agents assistantsRef.current = assistants labelsRef.current = labels - }, [allFiles, loaded, path, agents, assistants, labels]) + }, [agents, assistants, labels]) // Lazily-fetched network sources, key-cached so repeat searches reuse the // in-flight/resolved promise while a folder switch refetches. @@ -314,6 +315,8 @@ export function useReferenceSearch({ // focus-refresh idiom of the other data hooks, without per-keystroke fetches. useEffect(() => { const onFocus = () => { + generationRef.current += 1 + fileSearchRef.current?.abort() sessionsRef.current = null commitsRef.current = null } @@ -321,86 +324,147 @@ export function useReferenceSearch({ return () => window.removeEventListener("focus", onFocus) }, []) - return useCallback(async (query, signal, kinds) => { - if (!enabledRef.current) return [] + return useCallback( + async (query, signal, kinds, onUpdate) => { + if (!enabledRef.current || signal?.aborted) return [] - const path = pathRef.current - const wants = (kind: ReferenceKind) => !kinds || kinds.includes(kind) + const path = pathRef.current + const wants = (kind: ReferenceKind) => !kinds || kinds.includes(kind) + const generation = ++generationRef.current + fileSearchRef.current?.abort() + const abort = new AbortController() + fileSearchRef.current = abort + const onAbort = () => abort.abort() + signal?.addEventListener("abort", onAbort, { once: true }) + const pendingKinds = new Set() - // Lazy session fetch. On rejection the cache entry is cleared (not cached as - // an empty result) so the next `@` retries instead of wedging on `[]`. - let sessionsPromise = EMPTY_SESSIONS - if (wants("session")) { - const sessionsKey = "all" - let sessionsEntry = sessionsRef.current - if (sessionsEntry?.key !== sessionsKey) { - const created: NonNullable = { - key: sessionsKey, - promise: listAllConversations().catch(() => { - if (sessionsRef.current === created) sessionsRef.current = null - return [] as DbConversationSummary[] - }), - } - sessionsRef.current = created - sessionsEntry = created + // Retain only this query's matches, never the workspace's full file index. + // Ask for one extra row so the existing per-group truncation cue survives. + let filesPromise: Promise = Promise.resolve([]) + if (path && wants("file")) { + pendingKinds.add("file") + filesPromise = searchWorkspaceFiles(path, query, { + limit: MAX_PER_GROUP + 1, + request: { + consumerId: (consumerRef.current ??= randomUUID()), + generation, + }, + signal: abort.signal, + }) + .then((files) => + files.map((file) => ({ + name: file.name, + relativePath: file.path, + kind: file.kind, + lowerName: file.name.toLowerCase(), + lowerPath: file.path.toLowerCase(), + })) + ) + .catch(() => [] as FlatFileEntry[]) } - sessionsPromise = sessionsEntry.promise - } - // Lazy git-log fetch, keyed by path with the same retry-on-rejection policy. - // A hidden commit tab leaves the cache alone: it is still keyed by path, so - // switching the tab back on reuses it or refetches as usual. - let commitsPromise = EMPTY_COMMITS - if (path && wants("commit")) { - let commitsEntry = commitsRef.current - if (commitsEntry?.key !== path) { - const created: NonNullable = { - key: path, - promise: gitLog(path, GIT_LOG_LIMIT) - .then((result) => result.entries) - .catch(() => { - if (commitsRef.current === created) commitsRef.current = null - return [] as GitLogEntry[] + // Lazy session fetch. On rejection the cache entry is cleared (not cached as + // an empty result) so the next `@` retries instead of wedging on `[]`. + let sessionsPromise = EMPTY_SESSIONS + if (wants("session")) { + pendingKinds.add("session") + const sessionsKey = "all" + let sessionsEntry = sessionsRef.current + if (sessionsEntry?.key !== sessionsKey) { + const created: NonNullable = { + key: sessionsKey, + promise: listAllConversations().catch(() => { + if (sessionsRef.current === created) sessionsRef.current = null + return [] as DbConversationSummary[] }), + } + sessionsRef.current = created + sessionsEntry = created } - commitsRef.current = created - commitsEntry = created + sessionsPromise = sessionsEntry.promise } - commitsPromise = commitsEntry.promise - } else if (!path) { - commitsRef.current = null - } - const [sessions, commits] = await Promise.all([ - sessionsPromise, - commitsPromise, - ]) - // Discard this result if it can no longer be trusted for the live panel: a - // newer query aborted us, the composer was disabled, or the workspace folder - // changed while the network fetch was in flight (the popup only aborts on a - // query change, so a folder switch would otherwise leak the old repo's - // commits — built against `path` — into the new folder's panel). The next - // keystroke re-runs the search against the current folder. - if (signal?.aborted || !enabledRef.current || pathRef.current !== path) { - return [] - } + // Lazy git-log fetch, keyed by path with the same retry-on-rejection policy. + // A hidden commit tab leaves the cache alone: it is still keyed by path, so + // switching the tab back on reuses it or refetches as usual. + let commitsPromise = EMPTY_COMMITS + if (path && wants("commit")) { + pendingKinds.add("commit") + let commitsEntry = commitsRef.current + if (commitsEntry?.key !== path) { + const created: NonNullable = { + key: path, + promise: gitLog(path, GIT_LOG_LIMIT) + .then((result) => result.entries) + .catch(() => { + if (commitsRef.current === created) commitsRef.current = null + return [] as GitLogEntry[] + }), + } + commitsRef.current = created + commitsEntry = created + } + commitsPromise = commitsEntry.promise + } else if (!path) { + commitsRef.current = null + } - const fileState = filesRef.current - return buildReferenceGroups( - query, - { - // Already loaded by their hooks, but a hidden tab's rows would only be - // matched and adapted to be thrown away. - files: wants("file") ? fileState.files : [], - workspaceRoot: fileState.root, - agents: wants("agent") ? agentsRef.current : [], - assistants: wants("assistant") ? assistantsRef.current : [], - assistantAgents: wants("assistant") ? agentsRef.current : [], - sessions, - commits, - repoKey: path, - }, - labelsRef.current ?? DEFAULT_GROUP_LABELS - ) - }, []) + let files: FlatFileEntry[] = [] + let sessions: DbConversationSummary[] = [] + let commits: GitLogEntry[] = [] + const current = () => + !signal?.aborted && + !abort.signal.aborted && + enabledRef.current && + pathRef.current === path && + generationRef.current === generation + const groups = () => + buildReferenceGroups( + query, + { + files, + workspaceRoot: path, + agents: wants("agent") ? agentsRef.current : [], + assistants: wants("assistant") ? assistantsRef.current : [], + assistantAgents: wants("assistant") ? agentsRef.current : [], + sessions, + commits, + repoKey: path, + }, + labelsRef.current ?? DEFAULT_GROUP_LABELS + ).map((group) => + pendingKinds.has(group.kind) ? { ...group, loading: true } : group + ) + const publish = () => { + if (current()) onUpdate?.(groups()) + } + // Loaded agents/assistants are useful immediately, even if native file + // discovery takes a minute. Each lazy source then publishes independently. + publish() + try { + await Promise.all([ + filesPromise.then((value) => { + files = value + pendingKinds.delete("file") + publish() + }), + sessionsPromise.then((value) => { + sessions = value + pendingKinds.delete("session") + publish() + }), + commitsPromise.then((value) => { + commits = value + pendingKinds.delete("commit") + publish() + }), + ]) + return current() ? groups() : [] + } finally { + signal?.removeEventListener("abort", onAbort) + if (fileSearchRef.current === abort) fileSearchRef.current = null + } + }, + [] + ) } diff --git a/src/components/conversations/search-command-dialog.tsx b/src/components/conversations/search-command-dialog.tsx index 6dbcd551b77..6726c1cfd70 100644 --- a/src/components/conversations/search-command-dialog.tsx +++ b/src/components/conversations/search-command-dialog.tsx @@ -88,7 +88,8 @@ export function SearchCommandDialog({ reset: resetFileTree, } = useFileTree({ folderPath: folderPath || undefined, - enabled: activeTab === "files", + enabled: open && activeTab === "files", + query, }) // Compute which agent types exist in current folder @@ -96,8 +97,8 @@ export function SearchCommandDialog({ new Set(conversations.map((c) => c.agent_type)) ).sort(compareAgentType) - // Rank files by relevance (name/path tiers + fuzzy subsequence), scanning the - // full list so a deeply nested match isn't crowded out by shallower ones. + // The backend ranks the whole workspace before capping the response, so a + // deeply nested match remains reachable without retaining the full index. const filteredFiles = useMemo( () => rankFileMatches(query, allFiles, 100), [allFiles, query] diff --git a/src/components/message/completed-turn-streaming-mode.test.tsx b/src/components/message/completed-turn-streaming-mode.test.tsx index 00c5edef93c..d93cc5842f4 100644 --- a/src/components/message/completed-turn-streaming-mode.test.tsx +++ b/src/components/message/completed-turn-streaming-mode.test.tsx @@ -28,8 +28,16 @@ vi.mock("streamdown", () => ({ vi.mock("@streamdown/cjk", () => ({ cjk: {} })) vi.mock("@streamdown/math", () => ({ createMathPlugin: () => ({}) })) vi.mock("@streamdown/mermaid", () => ({ mermaid: {} })) -vi.mock("@streamdown/code", () => ({ - code: { highlight: vi.fn(), supportsLanguage: vi.fn(() => true) }, +vi.mock("@/lib/code-highlighter", async (importOriginal) => ({ + ...(await importOriginal()), + createCodePlugin: async () => ({ + name: "shiki", + type: "code-highlighter", + highlight: vi.fn(), + supportsLanguage: vi.fn(() => true), + getSupportedLanguages: () => [], + getThemes: () => ["github-light", "github-dark"], + }), })) vi.mock("@/components/ai-elements/link-safety", () => ({ diff --git a/src/hooks/use-file-tree.test.ts b/src/hooks/use-file-tree.test.ts new file mode 100644 index 00000000000..73b6699cec3 --- /dev/null +++ b/src/hooks/use-file-tree.test.ts @@ -0,0 +1,162 @@ +import { act, renderHook } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import type { WorkspaceFileEntry } from "@/lib/types" +import { useFileTree } from "./use-file-tree" + +const api = vi.hoisted(() => ({ prepare: vi.fn(), search: vi.fn() })) +vi.mock("@/lib/api", () => ({ + prepareWorkspaceFileSearch: api.prepare, + searchWorkspaceFiles: api.search, +})) + +function file(path: string): WorkspaceFileEntry { + return { name: path.split("/").at(-1)!, path, kind: "file" } +} + +function deferred() { + let resolve!: (files: WorkspaceFileEntry[]) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +describe("bounded workspace file search", () => { + beforeEach(() => { + vi.useFakeTimers() + api.prepare.mockReset().mockResolvedValue(undefined) + api.search.mockReset().mockResolvedValue([]) + }) + afterEach(() => { + vi.useRealTimers() + }) + + it("does no discovery while the picker is disabled", async () => { + renderHook(() => + useFileTree({ folderPath: "/repo", enabled: false, query: "index" }) + ) + await act(async () => { + await vi.advanceTimersByTimeAsync(1000) + }) + expect(api.prepare).not.toHaveBeenCalled() + expect(api.search).not.toHaveBeenCalled() + }) + + it("debounces typing and requests bounded matches for only the latest query", async () => { + const { rerender } = renderHook( + (query: string) => + useFileTree({ folderPath: "/repo", enabled: true, query }), + { initialProps: "a" } + ) + rerender("auth") + await act(async () => { + await vi.advanceTimersByTimeAsync(150) + }) + expect(api.prepare).toHaveBeenCalledOnce() + expect(api.search).toHaveBeenCalledExactlyOnceWith("/repo", "auth", { + limit: 100, + filesOnly: false, + refresh: false, + request: { + consumerId: expect.any(String), + generation: expect.any(Number), + }, + signal: expect.any(AbortSignal), + }) + }) + + it("drops a response from the previous workspace even when it resolves last", async () => { + const old = deferred() + api.search.mockImplementation((path: string) => + path === "/old" ? old.promise : Promise.resolve([file("new.ts")]) + ) + const { result, rerender } = renderHook( + (folderPath: string) => + useFileTree({ folderPath, enabled: true, query: "ts" }), + { initialProps: "/old" } + ) + await act(async () => { + await vi.advanceTimersByTimeAsync(150) + }) + rerender("/new") + await act(async () => { + await vi.advanceTimersByTimeAsync(150) + }) + await act(async () => { + old.resolve([file("old.ts")]) + }) + expect(result.current.allFiles.map((f) => f.relativePath)).toEqual([ + "new.ts", + ]) + expect(result.current.loading).toBe(false) + }) + + it("reset invalidates in-flight results and requests a fresh native search", async () => { + const old = deferred() + api.search + .mockReturnValueOnce(old.promise) + .mockResolvedValue([file("fresh.ts")]) + const { result } = renderHook(() => + useFileTree({ folderPath: "/repo", enabled: true, query: "ts" }) + ) + await act(async () => { + await vi.advanceTimersByTimeAsync(150) + }) + act(() => result.current.reset(true)) + await act(async () => { + await vi.advanceTimersByTimeAsync(150) + }) + await act(async () => { + old.resolve([file("obsolete.ts")]) + }) + expect(api.search).toHaveBeenLastCalledWith( + "/repo", + "ts", + expect.objectContaining({ refresh: true }) + ) + expect(result.current.allFiles.map((f) => f.relativePath)).toEqual([ + "fresh.ts", + ]) + }) + + it("closing and reopening clears local results without invalidating a warm index", async () => { + const { result, rerender } = renderHook( + (enabled: boolean) => + useFileTree({ folderPath: "/repo", enabled, query: "ts" }), + { initialProps: false } + ) + act(() => result.current.reset()) + rerender(true) + await act(async () => { + await vi.advanceTimersByTimeAsync(150) + }) + expect(api.search).toHaveBeenLastCalledWith( + "/repo", + "ts", + expect.objectContaining({ refresh: false }) + ) + }) + + it("cancels backend work for the old query and keeps a per-picker consumer", async () => { + api.search.mockReturnValue(new Promise(() => {})) + const { rerender, unmount } = renderHook( + (query: string) => + useFileTree({ folderPath: "/repo", enabled: true, query }), + { initialProps: "a" } + ) + await act(async () => { + await vi.advanceTimersByTimeAsync(150) + }) + const first = api.search.mock.calls[0][2] + rerender("ab") + expect(first.signal.aborted).toBe(true) + await act(async () => { + await vi.advanceTimersByTimeAsync(150) + }) + const second = api.search.mock.calls[1][2] + expect(second.request.consumerId).toBe(first.request.consumerId) + expect(second.request.generation).toBeGreaterThan(first.request.generation) + unmount() + expect(second.signal.aborted).toBe(true) + }) +}) diff --git a/src/hooks/use-file-tree.ts b/src/hooks/use-file-tree.ts index e1d9a9ba35b..57011876fa2 100644 --- a/src/hooks/use-file-tree.ts +++ b/src/hooks/use-file-tree.ts @@ -1,8 +1,9 @@ "use client" import { useState, useEffect, useRef, useCallback } from "react" -import { listWorkspaceFiles } from "@/lib/api" +import { prepareWorkspaceFileSearch, searchWorkspaceFiles } from "@/lib/api" import type { WorkspaceFileEntry } from "@/lib/types" +import { randomUUID } from "@/lib/utils" export interface FlatFileEntry { name: string @@ -18,47 +19,74 @@ export interface FlatFileEntry { interface UseFileTreeOptions { folderPath: string | undefined enabled: boolean + query: string + limit?: number + filesOnly?: boolean } interface UseFileTreeResult { allFiles: FlatFileEntry[] loading: boolean loaded: boolean - /** Clear cached data so the next `enabled=true` triggers a fresh load. */ - reset: () => void + /** Discard local results; an explicit refresh also invalidates native data. */ + reset: (refreshIndex?: boolean) => void } /** - * Loads a flat, gitignore-aware listing of every file/dir under `folderPath` - * (lazily, when `enabled`) for in-memory file search — shared by the search - * dialog and the composer `@`-mention picker. - * - * Discovery and gitignore filtering run on the backend (`list_workspace_files`), - * which prunes ignored directories *during* the walk and applies no depth cap, - * so deeply nested files are reachable while `node_modules`/`target`/… are never - * descended. The result is cached per folder path; a folder switch keeps showing - * the previous list until the new one loads (`loaded` gates that transition). + * Keep only the current query's bounded matches in the renderer. Discovery, + * indexing and ranking are shared in the backend, with a native cache budget. */ export function useFileTree({ folderPath, enabled, + query, + limit = 100, + filesOnly = false, }: UseFileTreeOptions): UseFileTreeResult { const [allFiles, setAllFiles] = useState([]) const [loading, setLoading] = useState(false) const loadedForPathRef = useRef(null) + const [revision, setRevision] = useState(0) + const generation = useRef(0) + const refresh = useRef(false) + const consumer = useRef(undefined) useEffect(() => { - if (!enabled || !folderPath) return - if (loadedForPathRef.current === folderPath) return + if (enabled && folderPath) { + void prepareWorkspaceFileSearch(folderPath).catch(() => {}) + } + }, [enabled, folderPath]) + useEffect(() => { + const current = ++generation.current + loadedForPathRef.current = null + setAllFiles([]) + if (!enabled || !folderPath) { + setLoading(false) + return + } let canceled = false + const abort = new AbortController() setLoading(true) async function load() { try { - const files: WorkspaceFileEntry[] = await listWorkspaceFiles( - folderPath! + const files: WorkspaceFileEntry[] = await searchWorkspaceFiles( + folderPath!, + query, + { + limit, + filesOnly, + refresh: refresh.current, + request: { + consumerId: (consumer.current ??= randomUUID()), + generation: current, + }, + signal: abort.signal, + } ) + if (canceled || generation.current !== current) return + if (query.trim()) refresh.current = false const flat: FlatFileEntry[] = files.map((f) => ({ name: f.name, relativePath: f.path, @@ -67,26 +95,31 @@ export function useFileTree({ lowerName: f.name.toLowerCase(), })) - if (!canceled) { + if (!canceled && generation.current === current) { setAllFiles(flat) loadedForPathRef.current = folderPath! } } catch { - if (!canceled) setAllFiles([]) + if (!canceled && generation.current === current) setAllFiles([]) } finally { - if (!canceled) setLoading(false) + if (!canceled && generation.current === current) setLoading(false) } } - void load() + const timer = setTimeout(() => void load(), query.trim() ? 150 : 0) return () => { canceled = true + abort.abort() + clearTimeout(timer) } - }, [enabled, folderPath]) + }, [enabled, folderPath, query, limit, filesOnly, revision]) - const reset = useCallback(() => { + const reset = useCallback((refreshIndex = false) => { loadedForPathRef.current = null + generation.current += 1 + refresh.current ||= refreshIndex setAllFiles([]) + setRevision((value) => value + 1) }, []) return { diff --git a/src/lib/api-workspace-search.test.ts b/src/lib/api-workspace-search.test.ts new file mode 100644 index 00000000000..09cf6b295d3 --- /dev/null +++ b/src/lib/api-workspace-search.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { searchWorkspaceFiles } from "./api" + +const { call } = vi.hoisted(() => ({ call: vi.fn() })) +vi.mock("@/lib/transport", () => ({ + getTransport: () => ({ call }), +})) + +describe("workspace search transport", () => { + beforeEach(() => call.mockReset().mockResolvedValue([])) + afterEach(() => vi.unstubAllGlobals()) + + it("generates consumer IDs on plain HTTP origins without crypto.randomUUID", async () => { + vi.stubGlobal("crypto", { + getRandomValues: crypto.getRandomValues.bind(crypto), + }) + await searchWorkspaceFiles("/repo", "readme") + expect(call.mock.calls[0][1].request.consumerId).toMatch( + /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/ + ) + }) + + it("covers native queue and traversal deadlines in web and remote desktop calls", async () => { + await searchWorkspaceFiles("/repo", "readme", { limit: 51 }) + expect(call).toHaveBeenCalledExactlyOnceWith( + "search_workspace_files", + { + path: "/repo", + query: "readme", + limit: 51, + request: { consumerId: expect.any(String), generation: 0 }, + }, + { timeoutMs: 330_000 } + ) + }) + + it("propagates abort to the captured backend without serializing the signal", async () => { + let resolve!: (entries: []) => void + call.mockReturnValueOnce( + new Promise((done) => { + resolve = done + }) + ) + const abort = new AbortController() + const request = { consumerId: "picker", generation: 2 } + const pending = searchWorkspaceFiles("/repo", "readme", { + request, + signal: abort.signal, + }) + abort.abort() + expect(call).toHaveBeenLastCalledWith("cancel_workspace_file_search", { + request, + }) + expect(call.mock.calls[0][1]).not.toHaveProperty("signal") + resolve([]) + await pending + }) + + it("skips an already obsolete query and removes abort listeners after completion", async () => { + const old = new AbortController() + old.abort() + expect( + await searchWorkspaceFiles("/repo", "old", { signal: old.signal }) + ).toEqual([]) + expect(call).not.toHaveBeenCalled() + const finished = new AbortController() + await searchWorkspaceFiles("/repo", "new", { signal: finished.signal }) + finished.abort() + expect(call).toHaveBeenCalledOnce() + }) +}) diff --git a/src/lib/api.ts b/src/lib/api.ts index 5c665b098aa..95af71e5e7b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -12,6 +12,7 @@ import { unauthorizedSource, } from "./transport/web-auth" import { notifyWebUnauthorized } from "./transport/web-connection-store" +import { randomUUID } from "./utils" import { getCurrentEffectiveAppLocale } from "./i18n" import { DEFAULT_FORGE_COMMENT_PAGE_SIZE, @@ -5021,9 +5022,9 @@ export async function getFileTree( } /** - * Flat, gitignore-aware listing of every file/dir under `path`. Ignored - * directories are pruned during the backend walk (no depth cap), so deeply - * nested files are reachable while the payload stays small. Used by file search. + * Enumerate every non-ignored file/dir under `path`, without a depth cap. + * This can return millions of entries; search surfaces use searchWorkspaceFiles + * to keep the renderer's payload and retained state bounded. */ export async function listWorkspaceFiles( path: string @@ -5031,6 +5032,56 @@ export async function listWorkspaceFiles( return getTransport().call("list_workspace_files", { path }) } +/** Warm a shared, budgeted native index without sending its entries to JS. */ +export async function prepareWorkspaceFileSearch(path: string): Promise { + return getTransport().call("prepare_workspace_file_search", { path }) +} + +/** Rank across the workspace and transfer only a bounded set of matches. */ +export async function searchWorkspaceFiles( + path: string, + query: string, + options: { + limit?: number + filesOnly?: boolean + refresh?: boolean + request?: { consumerId: string; generation: number } + signal?: AbortSignal + } = {} +): Promise { + const { + signal, + request = { consumerId: randomUUID(), generation: 0 }, + ...params + } = options + if (signal?.aborted) return [] + const transport = getTransport() + const cancel = () => { + void transport + .call("cancel_workspace_file_search", { request }) + .catch(() => {}) + } + signal?.addEventListener("abort", cancel, { once: true }) + try { + return await transport.call( + "search_workspace_files", + { + path, + query, + ...params, + request, + }, + { + // The backend bounds queue wait + traversal to five minutes. Give both + // HTTP transports time to receive that result, including a cold build. + timeoutMs: 330_000, + } + ) + } finally { + signal?.removeEventListener("abort", cancel) + } +} + export async function startWorkspaceStateStream( rootPath: string, wantsTreeGit = true diff --git a/src/lib/code-highlighter.test.ts b/src/lib/code-highlighter.test.ts new file mode 100644 index 00000000000..3893cf8e19e --- /dev/null +++ b/src/lib/code-highlighter.test.ts @@ -0,0 +1,227 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { TokensResult } from "shiki" + +const mocks = vi.hoisted(() => ({ + createHighlighter: vi.fn(), + codeToTokens: vi.fn(), + loadLanguage: vi.fn(), + loadTheme: vi.fn(), +})) + +vi.mock("shiki", () => ({ + createHighlighter: mocks.createHighlighter, + bundledLanguagesInfo: [ + { id: "javascript", aliases: ["js"] }, + { id: "typescript", aliases: ["ts"] }, + ], +})) + +let service: typeof import("./code-highlighter") +let engine: { + codeToTokens: typeof mocks.codeToTokens + loadLanguage: typeof mocks.loadLanguage + loadTheme: typeof mocks.loadTheme + getLoadedLanguages: () => string[] + getLoadedThemes: () => string[] +} + +function options(code: string, isIncomplete = false) { + return { + code, + language: "javascript", + themes: ["github-light", "github-dark"] as [string, string], + isIncomplete, + } +} + +function highlight(request: ReturnType) { + return new Promise((resolve) => { + const result = service.highlightCode(request, resolve) + if (result) resolve(result) + }) +} + +beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + const languages = new Set() + const themes = new Set() + mocks.loadLanguage.mockImplementation(async (name: string) => { + languages.add(name) + }) + mocks.loadTheme.mockImplementation(async (name: string) => { + themes.add(name) + }) + mocks.codeToTokens.mockImplementation((code: string) => ({ + fg: "#000", + bg: "#fff", + tokens: [[{ content: code, color: "#000", offset: 0 }]], + })) + engine = { + codeToTokens: mocks.codeToTokens, + loadLanguage: mocks.loadLanguage, + loadTheme: mocks.loadTheme, + getLoadedLanguages: () => [...languages], + getLoadedThemes: () => [...themes], + } + mocks.createHighlighter.mockResolvedValue(engine) + service = await import("./code-highlighter") +}) + +describe("shared code highlighting memory", () => { + it("does not retain the prefixes of a growing streaming code block", async () => { + for (let i = 1; i <= 128; i++) { + await highlight(options("const row = true;\n".repeat(i), true)) + } + expect(service.getCodeHighlightStats()).toEqual({ + entries: 0, + estimatedBytes: 0, + inFlight: 0, + waiting: 0, + }) + await highlight(options("const row = true;\n".repeat(128))) + expect(service.getCodeHighlightStats().entries).toBe(1) + }) + + it("bounds completed results by bytes and keeps recently used entries", async () => { + const first = options("first:" + "x".repeat(80_000)) + const hot = options("hot:" + "x".repeat(80_000)) + await highlight(first) + await highlight(hot) + for (let i = 0; i < 80; i++) { + expect(service.getCachedHighlightedCode(hot)).not.toBeNull() + await highlight(options(`${i}:` + "x".repeat(80_000))) + expect( + service.getCodeHighlightStats().estimatedBytes + ).toBeLessThanOrEqual(service.HIGHLIGHT_LIMITS.cacheBytes) + } + expect(service.getCachedHighlightedCode(first)).toBeNull() + expect(service.getCachedHighlightedCode(hot)).not.toBeNull() + }) + + it("also bounds the number of small completed snippets", async () => { + for (let i = 0; i < 200; i++) await highlight(options(`const x = ${i}`)) + expect(service.getCodeHighlightStats().entries).toBe( + service.HIGHLIGHT_LIMITS.cacheEntries + ) + expect(service.getCachedHighlightedCode(options("const x = 0"))).toBeNull() + }) + + it("shares one engine, grammar load and computation between subscribers", async () => { + const a = highlight(options("const x = 1")) + const b = highlight(options("const x = 1")) + expect(await a).toBe(await b) + await highlight({ ...options("const x = 2"), language: "typescript" }) + expect(mocks.createHighlighter).toHaveBeenCalledTimes(1) + expect(mocks.loadLanguage).toHaveBeenCalledTimes(2) + expect(mocks.loadTheme).toHaveBeenCalledTimes(2) + expect(mocks.codeToTokens).toHaveBeenCalledTimes(2) + }) + + it("unsubscribes a code view that unmounts before the engine is ready", async () => { + const listener = vi.fn() + const unsubscribe = service.subscribeHighlightedCode( + options("hello"), + listener + ) + unsubscribe() + await highlight(options("hello")) + expect(listener).not.toHaveBeenCalled() + expect(service.getCodeHighlightStats().inFlight).toBe(0) + }) + + it("keeps equal-length snippets with matching ends distinct", async () => { + const a = "x".repeat(101) + "A" + "y".repeat(101) + const b = "x".repeat(101) + "B" + "y".repeat(101) + const first = await highlight(options(a)) + const second = await highlight(options(b)) + expect(first.tokens[0][0].content).toBe(a) + expect(second.tokens[0][0].content).toBe(b) + expect(mocks.codeToTokens).toHaveBeenCalledTimes(2) + }) + + it("preserves large code as plaintext without loading the engine", async () => { + const code = "x".repeat(service.HIGHLIGHT_LIMITS.codeLength + 1) + const result = await highlight(options(code)) + expect(result.tokens[0][0].content).toBe(code) + expect(mocks.createHighlighter).not.toHaveBeenCalled() + expect(service.getCodeHighlightStats().entries).toBe(0) + }) + + it("does not cache a token tree larger than the entire budget", async () => { + mocks.codeToTokens.mockReturnValueOnce({ + tokens: [Array(100_000).fill({ content: "x", offset: 0 })], + }) + await highlight(options("many tokens")) + expect(service.getCodeHighlightStats().estimatedBytes).toBe(0) + }) + + it("bounds cold requests and still shows the content when saturated", async () => { + let release!: (value: typeof engine) => void + mocks.createHighlighter.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve + }) + ) + const requests = Array.from( + { length: service.HIGHLIGHT_LIMITS.inFlight }, + (_, i) => highlight(options(`cold ${i}`)) + ) + const overflow = await highlight(options("still readable")) + expect(overflow.tokens[0][0].content).toBe("still readable") + expect(service.getCodeHighlightStats().inFlight).toBe( + service.HIGHLIGHT_LIMITS.inFlight + ) + release(engine) + await Promise.all(requests) + expect(service.getCodeHighlightStats().inFlight).toBe(0) + }) + + it("normalizes aliases and uses plaintext for unknown languages", async () => { + const plugin = await service.createCodePlugin() + expect(plugin.supportsLanguage(" JS ")).toBe(true) + expect(plugin.supportsLanguage("##")).toBe(false) + await highlight({ ...options("let x = 1"), language: "js" }) + expect(mocks.codeToTokens).toHaveBeenLastCalledWith( + "let x = 1", + expect.objectContaining({ lang: "javascript" }) + ) + await highlight({ ...options("a log line"), language: "log" }) + expect(mocks.codeToTokens).toHaveBeenLastCalledWith( + "a log line", + expect.objectContaining({ lang: "text" }) + ) + }) + + it("retries all mounted overflow subscribers within the concurrency bound", async () => { + let release!: (value: typeof engine) => void + mocks.createHighlighter.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve + }) + ) + const listeners = Array.from({ length: 80 }, () => vi.fn()) + const cleanups = listeners.map((listener, i) => + service.subscribeHighlightedCode(options(`mounted ${i}`), listener) + ) + expect(service.getCodeHighlightStats().inFlight).toBe(16) + expect(service.getCodeHighlightStats().waiting).toBe(64) + // The waiting view remains readable while the engine is cold. + expect(listeners[79].mock.lastCall?.[0].fg).toBe("inherit") + cleanups[79]() + await vi.waitFor(() => + expect(mocks.createHighlighter).toHaveBeenCalledOnce() + ) + release(engine) + await vi.waitFor(() => { + expect(service.getCodeHighlightStats().waiting).toBe(0) + expect(service.getCodeHighlightStats().inFlight).toBe(0) + }) + for (const listener of listeners.slice(0, 79)) { + expect(listener.mock.lastCall?.[0].fg).toBe("#000") + } + expect(listeners[79]).toHaveBeenCalledTimes(1) + expect(mocks.codeToTokens).toHaveBeenCalledTimes(79) + cleanups.forEach((cleanup) => cleanup()) + }) +}) diff --git a/src/lib/code-highlighter.ts b/src/lib/code-highlighter.ts new file mode 100644 index 00000000000..6ab26222f20 --- /dev/null +++ b/src/lib/code-highlighter.ts @@ -0,0 +1,315 @@ +import type { + BundledLanguage, + BundledTheme, + Highlighter, + ThemeRegistrationAny, + TokensResult, +} from "shiki" +import type { + CodeHighlighterPlugin, + HighlightOptions, + ThemeInput, +} from "streamdown" + +type HighlightRequest = Omit & { + language: string +} +type Callback = (result: TokensResult) => void + +export const HIGHLIGHT_LIMITS = { + cacheBytes: 16 * 1024 * 1024, + cacheEntries: 128, + inFlight: 16, + codeLength: 100_000, + lines: 5_000, +} as const + +export const DEFAULT_CODE_THEMES: [ThemeInput, ThemeInput] = [ + "github-light", + "github-dark", +] + +const cache = new Map() +const pending = new Map< + string, + { callbacks: Set; cacheResult: boolean } +>() +const loading = new Map>() +// Only mounted subscribers wait here, without token trees or duplicate cache +// keys. Their cleanup removes them when a view unmounts or streaming advances. +const waiting = new Set<() => void>() +let retryScheduled = false +let cacheBytes = 0 +let shikiPromise: Promise | undefined +let highlighterPromise: Promise | undefined + +function loadShiki() { + shikiPromise ??= import("shiki").catch((error) => { + shikiPromise = undefined + throw error + }) + return shikiPromise +} + +function getHighlighter() { + // One engine and one copy of each theme, shared by Markdown and tool/file + // code views. Importing this module does not eagerly load Shiki. + highlighterPromise ??= loadShiki() + .then(({ createHighlighter }) => + createHighlighter({ langs: [], themes: [] }) + ) + .catch((error) => { + highlighterPromise = undefined + throw error + }) + return highlighterPromise +} + +function once(key: string, load: () => Promise) { + const existing = loading.get(key) + if (existing) return existing + const task = load().finally(() => loading.delete(key)) + loading.set(key, task) + return task +} + +function cacheKey({ code, language, themes }: HighlightRequest) { + // Full content prevents two snippets with equal lengths and ends from + // incorrectly sharing tokens. The key's storage counts against the budget. + return JSON.stringify([language, themes, code]) +} + +function read(key: string) { + const entry = cache.get(key) + if (!entry) return null + cache.delete(key) + cache.set(key, entry) + return entry.result +} + +function remember(key: string, result: TokensResult) { + let bytes = key.length * 2 + 128 + for (const line of result.tokens) { + bytes += 24 + for (const token of line) { + bytes += 192 + token.content.length * 2 + for (const [name, value] of Object.entries(token.htmlStyle ?? {})) { + bytes += 48 + (name.length + String(value).length) * 2 + } + } + } + // A single large result must not evict the entire cache and then exceed it. + if (bytes > HIGHLIGHT_LIMITS.cacheBytes) return + const previous = cache.get(key) + if (previous) { + cacheBytes -= previous.bytes + cache.delete(key) + } + while ( + cache.size >= HIGHLIGHT_LIMITS.cacheEntries || + cacheBytes + bytes > HIGHLIGHT_LIMITS.cacheBytes + ) { + const oldest = cache.entries().next().value + if (!oldest) break + cache.delete(oldest[0]) + cacheBytes -= oldest[1].bytes + } + cache.set(key, { result, bytes }) + cacheBytes += bytes +} + +export function rawCodeTokens(code: string): TokensResult { + return { + fg: "inherit", + bg: "transparent", + tokens: code + .split("\n") + .map((content) => + content ? [{ content, color: "inherit", offset: 0 }] : [] + ), + } +} + +function canHighlight(code: string) { + if (code.length > HIGHLIGHT_LIMITS.codeLength) return false + let lines = 1 + for (let i = 0; i < code.length; i++) { + if (code.charCodeAt(i) === 10 && ++lines > HIGHLIGHT_LIMITS.lines) { + return false + } + } + return true +} + +export function getCachedHighlightedCode(options: HighlightRequest) { + return canHighlight(options.code) ? read(cacheKey(options)) : null +} + +function requestHighlight( + options: HighlightRequest, + callback?: Callback +): TokensResult | null | "busy" { + if (!canHighlight(options.code)) return rawCodeTokens(options.code) + const key = cacheKey(options) + const cached = read(key) + if (cached) return cached + const existing = pending.get(key) + if (existing) { + if (callback) existing.callbacks.add(callback) + existing.cacheResult ||= !options.isIncomplete + return null + } + // Bound requests waiting for a cold grammar/engine too, not just results. + if (pending.size >= HIGHLIGHT_LIMITS.inFlight) { + return "busy" + } + const request = { + callbacks: new Set(callback ? [callback] : []), + cacheResult: !options.isIncomplete, + } + pending.set(key, request) + void (async () => { + let result: TokensResult + try { + const [shiki, highlighter] = await Promise.all([ + loadShiki(), + getHighlighter(), + ]) + const name = options.language.trim().toLowerCase() + const info = shiki.bundledLanguagesInfo.find( + (entry) => entry.id === name || entry.aliases?.includes(name) + ) + const language = info ? (info.id as BundledLanguage) : "text" + // Streamdown deliberately declares permissive structural theme types; + // its custom-theme objects are passed through to Shiki at this boundary. + const themes = options.themes as [ + BundledTheme | ThemeRegistrationAny, + BundledTheme | ThemeRegistrationAny, + ] + await Promise.all([ + ...(language !== "text" && + !highlighter.getLoadedLanguages().includes(language) + ? [ + once(`language:${language}`, () => + highlighter.loadLanguage(language) + ), + ] + : []), + ...themes.map((theme) => { + if ( + typeof theme === "string" && + highlighter.getLoadedThemes().includes(theme) + ) { + return undefined + } + return once(`theme:${JSON.stringify(theme)}`, () => + highlighter.loadTheme(theme) + ) + }), + ]) + result = highlighter.codeToTokens(options.code, { + lang: language, + themes: { light: themes[0], dark: themes[1] }, + }) + // Incomplete blocks are superseded on the next streaming batch. Keeping + // every prefix turns one growing block into quadratic retained tokens. + if (request.cacheResult) remember(key, result) + } catch (error) { + console.error("Failed to highlight code:", error) + result = rawCodeTokens(options.code) + } + pending.delete(key) + for (const listener of request.callbacks) { + try { + listener(result) + } catch (error) { + console.error("Failed to deliver highlighted code:", error) + } + } + request.callbacks.clear() + scheduleRetries() + })() + return null +} + +function scheduleRetries() { + if (retryScheduled || waiting.size === 0) return + retryScheduled = true + queueMicrotask(() => { + retryScheduled = false + let remaining = HIGHLIGHT_LIMITS.inFlight + while ( + remaining-- > 0 && + pending.size < HIGHLIGHT_LIMITS.inFlight && + waiting.size > 0 + ) { + const retry = waiting.values().next().value! + waiting.delete(retry) + try { + retry() + } catch (error) { + console.error("Failed to deliver highlighted code:", error) + } + } + if (pending.size < HIGHLIGHT_LIMITS.inFlight) scheduleRetries() + }) +} + +export function highlightCode( + options: HighlightRequest, + callback?: Callback +): TokensResult | null { + const result = requestHighlight(options, callback) + return result === "busy" ? rawCodeTokens(options.code) : result +} + +export function subscribeHighlightedCode( + options: HighlightRequest, + callback: Callback +) { + let active = true + const deliver: Callback = (result) => { + if (active) callback(result) + } + const retry = () => { + if (!active) return + const result = requestHighlight(options, deliver) + if (result === "busy") { + waiting.add(retry) + } else if (result) { + deliver(result) + } + } + retry() + if (waiting.has(retry)) deliver(rawCodeTokens(options.code)) + return () => { + active = false + waiting.delete(retry) + pending.get(cacheKey(options))?.callbacks.delete(deliver) + } +} + +export function getCodeHighlightStats() { + return { + entries: cache.size, + estimatedBytes: cacheBytes, + inFlight: pending.size, + waiting: waiting.size, + } +} + +export async function createCodePlugin(): Promise { + const { bundledLanguagesInfo } = await loadShiki() + const languages = new Set( + bundledLanguagesInfo.flatMap(({ id, aliases }) => [id, ...(aliases ?? [])]) + ) + return { + name: "shiki", + type: "code-highlighter", + highlight: highlightCode, + supportsLanguage: (language) => + languages.has(language.trim().toLowerCase()), + getSupportedLanguages: () => bundledLanguagesInfo.map(({ id }) => id), + getThemes: () => DEFAULT_CODE_THEMES, + } +} diff --git a/src/lib/overlay-scrollbars-memory.test.ts b/src/lib/overlay-scrollbars-memory.test.ts new file mode 100644 index 00000000000..55d5e62c446 --- /dev/null +++ b/src/lib/overlay-scrollbars-memory.test.ts @@ -0,0 +1,128 @@ +import { execFileSync } from "node:child_process" +import { describe, expect, it } from "vitest" + +const WEBKIT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/605.1.15 (KHTML, like Gecko)" +const IPHONE = + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) " + + "AppleWebKit/605.1.15 (KHTML, like Gecko)" + +// The dependency captures browser APIs at module evaluation. A fresh process +// per browser also exercises both package entrypoints without Node's external +// module cache making every case reuse the first browser's feature detection. +function probe(userAgent: string, supported = true) { + return ["esm", "cjs"].map( + (entrypoint) => + JSON.parse( + execFileSync( + process.execPath, + [ + "--input-type=module", + "-e", + ` + import { createRequire } from "node:module" + import { JSDOM } from "jsdom" + const [userAgent, entrypoint, supported] = process.argv.slice(1) + const dom = new JSDOM("", { + pretendToBeVisual: true, + }) + const { window } = dom + Object.assign(globalThis, { + window, document: window.document, + HTMLElement: window.HTMLElement, Element: window.Element, + }) + Object.defineProperty(window.navigator, "userAgent", { + value: userAgent, + }) + let constructors = 0 + let animations = 0 + if (supported === "true") { + window.ScrollTimeline = class { + constructor() { constructors++ } + } + } + window.Element.prototype.animate = () => { + animations++ + return { cancel() {} } + } + const { OverlayScrollbars } = entrypoint === "cjs" + ? createRequire(import.meta.url)("overlayscrollbars") + : await import("overlayscrollbars") + const host = window.document.createElement("div") + window.document.body.append(host) + const instance = OverlayScrollbars(host, {}) + const result = { + native: OverlayScrollbars.env().scrollTimeline, + constructors, animations, + handle: instance.elements().scrollbarVertical.handle instanceof window.HTMLElement, + webkitClass: instance.elements().scrollbarVertical.scrollbar.classList.contains("os-scrollbar-webkit"), + } + instance.destroy() + dom.window.close() + console.log(JSON.stringify(result)) + `, + userAgent, + entrypoint, + String(supported), + ], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] } + ) + ) as { + native: boolean + constructors: number + animations: number + handle: boolean + webkitClass: boolean + } + ) +} + +describe("OverlayScrollbars native memory workaround", () => { + it.each([ + ["WKWebView", WEBKIT], + ["Safari", `${WEBKIT} Version/18.0 Safari/605.1.15`], + ["iOS Chrome (WebKit)", `${IPHONE} CriOS/130.0.0.0 Mobile/15E148`], + ["iOS Firefox (WebKit)", `${IPHONE} FxiOS/130.0 Mobile/15E148`], + [ + "WebKitGTK", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 " + + "(KHTML, like Gecko) Version/60.5 Safari/605.1.15", + ], + ])("uses the scroll-event fallback in %s", (_name, userAgent) => { + for (const result of probe(userAgent)) { + expect(result).toEqual({ + native: false, + constructors: 0, + animations: 0, + handle: true, + webkitClass: true, + }) + } + }) + + it.each([ + ["Chrome", `${WEBKIT} Chrome/130.0.0.0 Safari/537.36`], + ["Edge", `${WEBKIT} Chrome/130.0.0.0 Safari/537.36 Edg/130.0`], + ["Opera", `${WEBKIT} Chrome/130.0.0.0 Safari/537.36 OPR/115.0`], + ["Chromium", `${WEBKIT} Chromium/130.0.0.0 Safari/537.36`], + ])("keeps native scroll timelines in %s", (_name, userAgent) => { + for (const result of probe(userAgent)) { + expect(result.native).toBe(true) + expect(result.constructors).toBeGreaterThan(0) + expect(result.animations).toBeGreaterThan(0) + expect(result.handle).toBe(true) + expect(result.webkitClass).toBe(false) + } + }) + + it("works when the browser has no ScrollTimeline API", () => { + for (const result of probe(WEBKIT, false)) { + expect(result.native).toBe(false) + expect(result.constructors).toBe(0) + expect(result.animations).toBe(0) + expect(result.handle).toBe(true) + expect(result.webkitClass).toBe(true) + } + }) +}) diff --git a/src/lib/tauri-native-event-memory.test.ts b/src/lib/tauri-native-event-memory.test.ts new file mode 100644 index 00000000000..11ba34e6431 --- /dev/null +++ b/src/lib/tauri-native-event-memory.test.ts @@ -0,0 +1,216 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { + listen, + once, + type Event, + type EventTarget, +} from "@tauri-apps/api/event" +import { getCurrentWebview } from "@tauri-apps/api/webview" +import { getCurrentWindow } from "@tauri-apps/api/window" +import { createRequire } from "node:module" + +const require = createRequire(import.meta.url) + +describe("native Tauri event callback lifetime", () => { + let callbacks: Map) => void> + let registered: Map + let invoke: ReturnType + let nextId: number + + beforeEach(() => { + callbacks = new Map() + registered = new Map() + nextId = 0 + invoke = vi.fn(async (command, args) => { + if (command === "plugin:event|listen") { + registered.set(1000 + args.handler, args.handler) + return 1000 + args.handler + } + }) + vi.stubGlobal("__TAURI_INTERNALS__", { + metadata: { + currentWindow: { label: "main" }, + currentWebview: { label: "main" }, + }, + invoke, + transformCallback: (callback: (event: Event) => void) => { + callbacks.set(++nextId, callback) + return nextId + }, + unregisterCallback: (id: number) => callbacks.delete(id), + }) + vi.stubGlobal("__TAURI_EVENT_PLUGIN_INTERNALS__", { + unregisterListener: (_event: string, eventId: number) => { + const id = registered.get(eventId) + if (id !== undefined) callbacks.delete(id) + }, + }) + }) + + afterEach(() => vi.unstubAllGlobals()) + + const scopes: { scope: string; target: EventTarget }[] = [ + { scope: "global", target: { kind: "Any" } }, + { scope: "webview", target: { kind: "Webview", label: "main" } }, + { scope: "window", target: { kind: "Window", label: "main" } }, + ] + + function subscribe(scope: string, handler: (event: Event) => void) { + if (scope === "webview") { + return getCurrentWebview().listen("tauri://drag-over", handler) + } + if (scope === "window") { + return getCurrentWindow().listen("tauri://drag-over", handler) + } + return listen("tauri://drag-over", handler) + } + + it.each(scopes)( + "releases $scope callback when cleanup beats native registration eval", + async ({ scope, target }) => { + invoke.mockImplementation(async (command) => + command === "plugin:event|listen" ? 1001 : undefined + ) + const handler = vi.fn() + const stop = await subscribe(scope, handler) + expect(callbacks.size).toBe(1) + await stop() + expect(callbacks.size).toBe(0) + registered.set(1001, 1) + callbacks.get(1)?.({ event: "tauri://drag-over", id: 1001, payload: 1 }) + expect(handler).not.toHaveBeenCalled() + expect(invoke).toHaveBeenCalledWith( + "plugin:event|listen", + { event: "tauri://drag-over", target, handler: 1 }, + undefined + ) + expect(invoke).toHaveBeenCalledWith( + "plugin:event|unlisten", + { event: "tauri://drag-over", eventId: 1001 }, + undefined + ) + } + ) + + it.each(scopes)( + "releases $scope callback when native registration fails", + async ({ scope }) => { + const error = new Error("synthetic closed webview") + invoke.mockRejectedValueOnce(error) + await expect(subscribe(scope, vi.fn())).rejects.toBe(error) + expect(callbacks.size).toBe(0) + } + ) + + it("does not retain scoped closures after repeated registration races", async () => { + invoke.mockImplementation(async (command) => + command === "plugin:event|listen" ? 1001 : undefined + ) + for (let i = 0; i < 100; i++) { + const stop = await getCurrentWebview().listen( + "tauri://drag-over", + vi.fn() + ) + await stop() + } + expect(callbacks.size).toBe(0) + }) + + it("still cleans native registration when the JS event registry throws", async () => { + vi.stubGlobal("__TAURI_EVENT_PLUGIN_INTERNALS__", { + unregisterListener: () => { + throw new Error("registration eval unavailable") + }, + }) + const stop = await listen("tauri://drag-over", vi.fn()) + await stop() + expect(callbacks.size).toBe(0) + expect(invoke).toHaveBeenLastCalledWith( + "plugin:event|unlisten", + { event: "tauri://drag-over", eventId: 1001 }, + undefined + ) + }) + + it("keeps independent scopes live and cleanup idempotent", async () => { + const first = vi.fn() + const second = vi.fn() + const stopFirst = await getCurrentWebview().listen( + "tauri://drag-over", + first + ) + const stopSecond = await getCurrentWindow().listen( + "tauri://drag-over", + second + ) + const event = { event: "tauri://drag-over", id: 1002, payload: { x: 1 } } + await stopFirst() + await stopFirst() + callbacks.get(2)?.(event) + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledWith(event) + await stopSecond() + expect(callbacks.size).toBe(0) + expect( + invoke.mock.calls.filter( + ([command]) => command === "plugin:event|unlisten" + ) + ).toHaveLength(2) + }) + + it("releases a once callback when its event beats registration eval", async () => { + invoke.mockImplementation(async (command) => + command === "plugin:event|listen" ? 1001 : undefined + ) + const handler = vi.fn() + const stop = await once("tauri://drag-over", handler) + callbacks.get(1)?.({ event: "tauri://drag-over", id: 1001, payload: 1 }) + await Promise.resolve() + expect(handler).toHaveBeenCalledTimes(1) + expect(callbacks.size).toBe(0) + await stop() + expect( + invoke.mock.calls.filter( + ([command]) => command === "plugin:event|unlisten" + ) + ).toHaveLength(1) + }) + + it("releases once before the listen response and ignores a second event", async () => { + let finishListen!: (eventId: number) => void + invoke.mockImplementation((command) => + command === "plugin:event|listen" + ? new Promise((resolve) => { + finishListen = resolve + }) + : Promise.resolve() + ) + const handler = vi.fn() + const pending = once("tauri://drag-over", handler) + const callback = callbacks.get(1)! + const event = { event: "tauri://drag-over", id: 1001, payload: 1 } + callback(event) + callback(event) + expect(callbacks.size).toBe(0) + expect(handler).toHaveBeenCalledTimes(1) + finishListen(1001) + const stop = await pending + await stop() + expect( + invoke.mock.calls.filter( + ([command]) => command === "plugin:event|unlisten" + ) + ).toHaveLength(1) + }) + + it("also protects CommonJS consumers from failed registrations", async () => { + const cjsEvent = + require("@tauri-apps/api/event") as typeof import("@tauri-apps/api/event") + const error = new Error("synthetic registration rejection") + invoke.mockRejectedValueOnce(error) + await expect(cjsEvent.listen("tauri://drag-over", vi.fn())).rejects.toBe( + error + ) + expect(callbacks.size).toBe(0) + }) +}) diff --git a/src/lib/transport/tauri-transport.test.ts b/src/lib/transport/tauri-transport.test.ts new file mode 100644 index 00000000000..9c9bb6bcaef --- /dev/null +++ b/src/lib/transport/tauri-transport.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { TauriTransport } from "./tauri-transport" + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + transformCallback: vi.fn(), +})) + +vi.mock("@tauri-apps/api/core", () => mocks) + +describe("Tauri event subscription lifetime", () => { + let callbacks: Map void> + let registered: Map + let unregisterCallback: ReturnType + let nextCallback: number + + beforeEach(() => { + callbacks = new Map() + registered = new Map() + nextCallback = 0 + unregisterCallback = vi.fn((id: number) => callbacks.delete(id)) + vi.stubGlobal("__TAURI_INTERNALS__", { unregisterCallback }) + vi.stubGlobal("__TAURI_EVENT_PLUGIN_INTERNALS__", { + // Tauri 2.12.4 can unregister by eventId only after its registration + // eval has supplied handlerId. The invoke response can arrive first. + unregisterListener: (_event: string, eventId: number) => { + const handlerId = registered.get(eventId) + if (handlerId !== undefined) unregisterCallback(handlerId) + }, + }) + mocks.transformCallback.mockReset().mockImplementation((callback) => { + const id = ++nextCallback + callbacks.set(id, callback) + return id + }) + mocks.invoke.mockReset().mockImplementation(async (command, args) => { + if (command === "plugin:event|listen") { + registered.set(100 + args.handler, args.handler) + return 100 + args.handler + } + }) + }) + + afterEach(() => vi.unstubAllGlobals()) + + it("releases a callback when cleanup beats the listener registration eval", async () => { + mocks.invoke.mockImplementation(async (command) => + command === "plugin:event|listen" ? 101 : undefined + ) + const listener = vi.fn() + const unsubscribe = await new TauriTransport().subscribe( + "acp://event", + listener + ) + expect(callbacks.size).toBe(1) + unsubscribe() + expect(callbacks.size).toBe(0) + // A late native registration cannot revive the disposed callback. + registered.set(101, 1) + callbacks.get(1)?.({ payload: "late event" }) + expect(listener).not.toHaveBeenCalled() + expect(mocks.invoke).toHaveBeenCalledWith("plugin:event|unlisten", { + event: "acp://event", + eventId: 101, + }) + }) + + it("releases a callback if native subscription creation rejects", async () => { + const error = new Error("webview closed") + mocks.invoke.mockRejectedValueOnce(error) + await expect( + new TauriTransport().subscribe("acp://event", vi.fn()) + ).rejects.toBe(error) + expect(callbacks.size).toBe(0) + expect(mocks.invoke).toHaveBeenCalledTimes(1) + }) + + it("does not accumulate callbacks across repeated mount and cleanup races", async () => { + mocks.invoke.mockImplementation(async (command) => + command === "plugin:event|listen" ? 101 : undefined + ) + const transport = new TauriTransport() + for (let index = 0; index < 100; index++) { + const unsubscribe = await transport.subscribe("acp://event", vi.fn()) + unsubscribe() + } + expect(callbacks.size).toBe(0) + }) + + it("keeps independent subscriptions live and cleanup idempotent", async () => { + const transport = new TauriTransport() + const first = vi.fn() + const second = vi.fn() + const stopFirst = await transport.subscribe("acp://event", first) + const stopSecond = await transport.subscribe("acp://event", second) + callbacks.get(1)?.({ payload: "first" }) + callbacks.get(2)?.({ payload: "second" }) + expect(first).toHaveBeenCalledWith("first") + expect(second).toHaveBeenCalledWith("second") + stopFirst() + stopFirst() + expect(callbacks.size).toBe(1) + callbacks.get(2)?.({ payload: "still live" }) + expect(second).toHaveBeenLastCalledWith("still live") + stopSecond() + expect(callbacks.size).toBe(0) + expect( + mocks.invoke.mock.calls.filter( + ([command]) => command === "plugin:event|unlisten" + ) + ).toHaveLength(2) + }) + + it("cleans up both callback and native listener if the event registry throws", async () => { + vi.stubGlobal("__TAURI_EVENT_PLUGIN_INTERNALS__", { + unregisterListener: () => { + throw new Error("registration eval not ready") + }, + }) + const unsubscribe = await new TauriTransport().subscribe( + "acp://event", + vi.fn() + ) + unsubscribe() + expect(callbacks.size).toBe(0) + expect(mocks.invoke).toHaveBeenLastCalledWith("plugin:event|unlisten", { + event: "acp://event", + eventId: 101, + }) + }) +}) diff --git a/src/lib/transport/tauri-transport.ts b/src/lib/transport/tauri-transport.ts index 153dae021c1..ed3cba274e5 100644 --- a/src/lib/transport/tauri-transport.ts +++ b/src/lib/transport/tauri-transport.ts @@ -1,6 +1,9 @@ import type { CallOptions, Transport, UnsubscribeFn } from "./types" type TauriEventListenersWindow = { + __TAURI_INTERNALS__?: { + unregisterCallback?: (callbackId: number) => void + } __TAURI_EVENT_PLUGIN_INTERNALS__?: { unregisterListener?: (event: string, eventId: number) => void } @@ -20,17 +23,14 @@ export class TauriTransport implements Transport { return invoke(command, args) } - // Bypasses `@tauri-apps/api/event#listen` to sidestep an intermittent race: - // Tauri's Rust `listen_js` schedules a fire-and-forget eval that populates - // `window.__TAURI_EVENT_LISTENERS__[event][eventId]` and, separately, returns - // `eventId` via the invoke response. On WKWebView the two can arrive out of - // order. When cleanup fires before the eval lands, the built-in `_unlisten` - // throws synchronously on `listeners[eventId].handlerId` — and because that - // throw happens BEFORE `await invoke('plugin:event|unlisten')`, the backend - // listener is never removed (handler + payload buffering leak). + // Tauri registers a callback before awaiting the native listener. The + // listener's registration eval can arrive after its invoke response; cleanup + // then cannot find handlerId in the event registry. A rejected listen never + // populates that registry either. Retain our callback ID so both paths can + // release the callback and everything captured by the subscriber. // - // We own the eventId here so we can always issue the backend unlisten, even - // when the client-side registry entry hasn't appeared yet. + // Native unlisten must still run if the event registry is absent or its + // cleanup throws, as happened in older Tauri releases. async subscribe( event: string, handler: (payload: T) => void @@ -39,16 +39,28 @@ export class TauriTransport implements Transport { const handlerId = transformCallback((e: { payload: T }) => { handler(e.payload) }) - const eventId = await invoke("plugin:event|listen", { - event, - target: { kind: "Any" }, - handler: handlerId, - }) + const releaseCallback = () => { + const internals = (window as unknown as TauriEventListenersWindow) + .__TAURI_INTERNALS__ + internals?.unregisterCallback?.(handlerId) + } + let eventId: number + try { + eventId = await invoke("plugin:event|listen", { + event, + target: { kind: "Any" }, + handler: handlerId, + }) + } catch (error) { + releaseCallback() + throw error + } let unlistened = false return () => { if (unlistened) return unlistened = true + releaseCallback() try { const internals = (window as unknown as TauriEventListenersWindow) .__TAURI_EVENT_PLUGIN_INTERNALS__