feat: add evidence search and stabilize pdf previews

- add document evidence parsing/search/open routes, Hermes tool wiring, local index settings/status, and the document-evidence skill plus design notes
- fix PDF resource tabs by rendering PDFs inline with pdf.js canvases instead of iframe preview pages, release PDF documents on close, and document the fourth-PDF stall bug
- keep PDF preview at 2x rendering while removing the previous lazy-load/placeholder direction, and make dev:hot bind loopback defaults externally reachable

Verification:
- node --check rust/crates/mnote-web/browser/document-resource-tab-runtime.js
- node scripts/task-dev-hot-plan-test.js
- cargo test -p mnote-web --manifest-path rust/Cargo.toml pdf_preview_page_does_not_render_visible_toolbar
- cargo test -p mnote-web --manifest-path rust/Cargo.toml document_shell_returns_page_aggregate_snapshot
- cargo build -p mnote-web --manifest-path rust/Cargo.toml
- browser smoke: sequentially opened the four tea_seed_oil_cosmetic PDFs; fourth PDF rendered 15/15 canvases, iframeCount=0, browser errors=0
This commit is contained in:
lix-2026
2026-06-04 18:51:16 +08:00
parent 9f4b5c4d48
commit 627213dc01
51 changed files with 15960 additions and 1844 deletions
@@ -1,8 +1,10 @@
use crate::document_buffer_store::BufferStore;
use crate::routes::{
local_markdown_conflict_detection_key, local_workspace_id_from_root_uri,
refresh_local_search_index_for_path,
refresh_local_search_index_for_change_path_with_store,
refresh_local_search_index_if_scheduled_due_with_store,
};
use control_plane::{ControlPlaneStore, SqliteControlPlaneStore};
use notify::event::ModifyKind;
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde_json::{json, Value};
@@ -10,13 +12,15 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, Weak};
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::{broadcast, mpsc, oneshot};
use tokio::time::MissedTickBehavior;
#[derive(Clone)]
pub struct LocalFolderWatcherRegistry {
inner: Arc<LocalFolderWatcherRegistryInner>,
buffer_store: BufferStore,
control_plane: Arc<dyn ControlPlaneStore>,
}
impl std::fmt::Debug for LocalFolderWatcherRegistry {
@@ -28,12 +32,13 @@ impl std::fmt::Debug for LocalFolderWatcherRegistry {
}
impl LocalFolderWatcherRegistry {
pub fn new(buffer_store: BufferStore) -> Self {
pub fn new(buffer_store: BufferStore, control_plane: Arc<SqliteControlPlaneStore>) -> Self {
Self {
inner: Arc::new(LocalFolderWatcherRegistryInner {
entries: Mutex::new(HashMap::new()),
}),
buffer_store,
control_plane,
}
}
@@ -43,9 +48,10 @@ impl LocalFolderWatcherRegistry {
) -> Result<LocalFolderWatcherSubscription, String> {
let key = canonical_root_uri(canonical_root);
let buffer_store = self.buffer_store.clone();
let channel = self
.inner
.get_or_create_channel(&key, canonical_root, buffer_store)?;
let control_plane = self.control_plane.clone();
let channel =
self.inner
.get_or_create_channel(&key, canonical_root, buffer_store, control_plane)?;
channel.subscriber_count.fetch_add(1, Ordering::SeqCst);
Ok(LocalFolderWatcherSubscription {
receiver: channel.sender.subscribe(),
@@ -73,6 +79,7 @@ impl LocalFolderWatcherRegistryInner {
key: &str,
canonical_root: &Path,
buffer_store: BufferStore,
control_plane: Arc<dyn ControlPlaneStore>,
) -> Result<Arc<LocalFolderWatchChannel>, String> {
if let Some(existing) = self
.entries
@@ -86,7 +93,12 @@ impl LocalFolderWatcherRegistryInner {
let channel = Arc::new(LocalFolderWatchChannel::new(
key.to_string(),
spawn_local_folder_watcher(key, canonical_root.to_path_buf(), buffer_store)?,
spawn_local_folder_watcher(
key,
canonical_root.to_path_buf(),
buffer_store,
control_plane,
)?,
));
let mut entries = self.entries.lock().expect("registry lock");
@@ -168,6 +180,7 @@ fn spawn_local_folder_watcher(
root_uri: &str,
canonical_root: PathBuf,
buffer_store: BufferStore,
control_plane: Arc<dyn ControlPlaneStore>,
) -> Result<(broadcast::Sender<Value>, oneshot::Sender<()>), String> {
let (event_sender, mut event_receiver) = mpsc::unbounded_channel::<notify::Result<Event>>();
let mut watcher = RecommendedWatcher::new(
@@ -186,13 +199,23 @@ fn spawn_local_folder_watcher(
let sender_for_task = sender.clone();
let root_uri_for_task = root_uri.to_string();
let buffer_store_for_task = buffer_store.clone();
let control_plane_for_task = control_plane.clone();
tokio::spawn(async move {
let _watcher = watcher;
let mut index_schedule_tick = tokio::time::interval(Duration::from_secs(60));
index_schedule_tick.set_missed_tick_behavior(MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = &mut shutdown_rx => {
return;
}
_ = index_schedule_tick.tick() => {
refresh_local_search_index_for_schedule(
control_plane_for_task.as_ref(),
&canonical_root,
&root_uri_for_task,
);
}
maybe_result = event_receiver.recv() => {
let Some(result) = maybe_result else {
return;
@@ -211,6 +234,7 @@ fn spawn_local_folder_watcher(
continue;
};
refresh_local_search_index_for_event(
control_plane_for_task.as_ref(),
&canonical_root,
&root_uri_for_task,
&relative_path,
@@ -298,11 +322,38 @@ fn spawn_local_folder_watcher(
Ok((sender, shutdown_tx))
}
fn refresh_local_search_index_for_event(root: &Path, root_uri: &str, relative_path: &str) {
fn refresh_local_search_index_for_event(
control_plane: &dyn ControlPlaneStore,
root: &Path,
root_uri: &str,
relative_path: &str,
) {
let Ok(workspace_id) = local_workspace_id_from_root_uri(root_uri) else {
return;
};
let _ = refresh_local_search_index_for_path(root, root_uri, &workspace_id, relative_path);
let _ = refresh_local_search_index_for_change_path_with_store(
control_plane,
root,
root_uri,
&workspace_id,
relative_path,
);
}
fn refresh_local_search_index_for_schedule(
control_plane: &dyn ControlPlaneStore,
root: &Path,
root_uri: &str,
) {
let Ok(workspace_id) = local_workspace_id_from_root_uri(root_uri) else {
return;
};
let _ = refresh_local_search_index_if_scheduled_due_with_store(
control_plane,
root,
root_uri,
&workspace_id,
);
}
fn canonical_root_uri(root: &Path) -> String {
@@ -423,8 +474,11 @@ mod tests {
LocalFolderWatcherRegistry,
};
use crate::document_buffer_store::BufferStore;
use crate::routes::write_local_index_settings;
use control_plane::SqliteControlPlaneStore;
use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind};
use notify::EventKind;
use std::sync::Arc;
fn test_root(name: &str) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!(
@@ -441,7 +495,9 @@ mod tests {
#[tokio::test]
async fn same_root_subscribers_share_single_watcher() {
let registry = LocalFolderWatcherRegistry::new(BufferStore::new());
let control_plane =
Arc::new(SqliteControlPlaneStore::in_memory().expect("init control plane"));
let registry = LocalFolderWatcherRegistry::new(BufferStore::new(), control_plane);
let root = test_root("shared");
let first = registry.subscribe(&root).expect("first subscription");
@@ -462,7 +518,9 @@ mod tests {
#[tokio::test]
async fn different_roots_create_independent_watchers() {
let registry = LocalFolderWatcherRegistry::new(BufferStore::new());
let control_plane =
Arc::new(SqliteControlPlaneStore::in_memory().expect("init control plane"));
let registry = LocalFolderWatcherRegistry::new(BufferStore::new(), control_plane);
let first_root = test_root("first");
let second_root = test_root("second");
@@ -532,7 +590,10 @@ mod tests {
.expect("write watched");
let root_uri = format!("file://{}", root.display());
refresh_local_search_index_for_event(&root, &root_uri, "docs/watched.md");
let control_plane = SqliteControlPlaneStore::in_memory().expect("init control plane");
write_local_index_settings(&root, &[String::from(".")], None, None, None, Some(true))
.expect("enable run-on-change indexing");
refresh_local_search_index_for_event(&control_plane, &root, &root_uri, "docs/watched.md");
let index_path = root.join(".mnote").join("index").join("search-index.json");
let index = std::fs::read_to_string(&index_path).expect("index exists");