diff --git a/local-markdown-gfm-design-checklist.png b/local-markdown-gfm-design-checklist.png new file mode 100644 index 00000000..10a67727 Binary files /dev/null and b/local-markdown-gfm-design-checklist.png differ diff --git a/local-markdown-gfm-design-page.png b/local-markdown-gfm-design-page.png new file mode 100644 index 00000000..2e95c291 Binary files /dev/null and b/local-markdown-gfm-design-page.png differ diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 5ffd306e..cfccded4 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -670,6 +670,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.32" @@ -1140,6 +1149,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "interpolator" version = "0.5.0" @@ -1225,6 +1254,26 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7b65860415f949f23fa882e669f2dbd4a0f0eeb1acdd56790b30494afd7da2f" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1438,6 +1487,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1476,6 +1526,7 @@ dependencies = [ "futures-util", "leptos", "mnote-editor-core", + "notify", "reqwest", "serde", "serde_json", @@ -1494,6 +1545,33 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" diff --git a/rust/crates/mnote-web/Cargo.toml b/rust/crates/mnote-web/Cargo.toml index 24ce3352..169d6fbd 100644 --- a/rust/crates/mnote-web/Cargo.toml +++ b/rust/crates/mnote-web/Cargo.toml @@ -23,4 +23,5 @@ tracing-subscriber = { version = "0.3", features = ["fmt"] } tower = "0.5" base64 = "0.22" comrak = { version = "0.52", default-features = false } +notify = "8.2.0" time = { version = "0.3", features = ["formatting"] } diff --git a/rust/crates/mnote-web/src/routes/local_folder_events.rs b/rust/crates/mnote-web/src/routes/local_folder_events.rs new file mode 100644 index 00000000..74c6c753 --- /dev/null +++ b/rust/crates/mnote-web/src/routes/local_folder_events.rs @@ -0,0 +1,256 @@ +use crate::context::RequestContext; +use crate::error::WebError; +use crate::routes::local_folder_source::decode_local_id_segment; +use axum::extract::{Extension, Query}; +use axum::http::{HeaderMap, HeaderName, HeaderValue}; +use axum::response::sse::{Event as SseEvent, KeepAlive, Sse}; +use futures_util::stream; +use notify::event::ModifyKind; +use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::convert::Infallible; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::sync::mpsc; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalFolderEventsQuery { + pub root_uri: String, + pub document_id: Option, +} + +pub async fn local_folder_events( + Extension(context): Extension, + Query(query): Query, +) -> Result< + ( + HeaderMap, + Sse>>, + ), + WebError, +> { + let root = parse_file_root_uri(&query.root_uri)?; + let canonical_root = root.canonicalize().map_err(|error| { + WebError::bad_request_code( + "local_folder_unavailable", + format!("无法访问本地文件夹: {error}"), + ) + .with_context(&context) + })?; + let document_relative_path = query + .document_id + .as_deref() + .and_then(local_markdown_relative_path_from_document_id); + + let (sender, receiver) = mpsc::unbounded_channel::(); + let (watch_sender, mut watch_receiver) = + mpsc::unbounded_channel::>(); + let mut watcher = RecommendedWatcher::new( + move |result| { + let _ = watch_sender.send(result); + }, + Config::default(), + ) + .map_err(|error| { + WebError::internal(format!("本地文件事件监听启动失败: {error}")).with_context(&context) + })?; + watcher + .watch(&canonical_root, RecursiveMode::Recursive) + .map_err(|error| { + WebError::internal(format!("本地文件夹监听失败: {error}")).with_context(&context) + })?; + + let root_for_task = canonical_root.clone(); + let root_uri = query.root_uri.clone(); + tokio::spawn(async move { + let _watcher = watcher; + while let Some(result) = watch_receiver.recv().await { + let Ok(event) = result else { + continue; + }; + if !should_emit_event_kind(&event.kind) { + continue; + } + for path in event.paths { + if !is_markdown_path(&path) { + continue; + } + let Some(relative_path) = relative_path_string(&root_for_task, &path) else { + continue; + }; + if let Some(expected) = document_relative_path.as_deref() { + if expected != relative_path { + continue; + } + } + let payload = json!({ + "sourceKind": "local_folder", + "rootUri": root_uri, + "relativePath": relative_path, + "documentId": format!("local-md:{}", encode_local_id_segment(&relative_path)), + "eventKind": format!("{:?}", event.kind), + "revision": event_revision(&path), + }); + if sender.send(payload).is_err() { + return; + } + } + } + }); + + let initial = json!({ + "sourceKind": "local_folder", + "rootUri": query.root_uri, + "documentId": query.document_id, + "revision": system_time_ms(SystemTime::now()), + }); + let stream = stream::unfold((Some(initial), receiver), |(initial, mut receiver)| async move { + if let Some(payload) = initial { + return Some((Ok(stream_event("ready", &payload)), (None, receiver))); + } + receiver + .recv() + .await + .map(|payload| (Ok(stream_event("change", &payload)), (None, receiver))) + }); + + let mut headers = HeaderMap::new(); + if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") { + headers.insert(name, HeaderValue::from_static("mnote-web")); + } + if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-local-folder-events-owner") { + headers.insert(name, HeaderValue::from_static("rust-web")); + } + + Ok(( + headers, + Sse::new(stream).keep_alive( + KeepAlive::new() + .interval(Duration::from_secs(15)) + .text("keepalive"), + ), + )) +} + +fn parse_file_root_uri(root_uri: &str) -> Result { + let trimmed = root_uri.trim(); + let Some(path) = trimmed.strip_prefix("file://") else { + return Err(WebError::bad_request_code( + "local_folder_root_invalid", + "本地文件夹 rootUri 必须是 file:// URI", + )); + }; + Ok(PathBuf::from(path)) +} + +fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option { + let trimmed = document_id.trim(); + let encoded = trimmed.strip_prefix("local-md:")?; + decode_local_id_segment(encoded).ok() +} + +fn relative_path_string(root: &Path, path: &Path) -> Option { + path.strip_prefix(root) + .ok() + .map(|relative| relative.to_string_lossy().replace('\\', "/")) + .filter(|relative| !relative.is_empty()) +} + +fn is_markdown_path(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .map(|extension| { + extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown") + }) + .unwrap_or(false) +} + +fn should_emit_event_kind(kind: &EventKind) -> bool { + match kind { + EventKind::Create(_) | EventKind::Remove(_) => true, + EventKind::Modify(modify_kind) => matches!( + modify_kind, + ModifyKind::Any | ModifyKind::Data(_) | ModifyKind::Name(_) | ModifyKind::Metadata(_) + ), + _ => false, + } +} + +fn encode_local_id_segment(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.as_bytes() { + let character = *byte as char; + if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') { + encoded.push(character); + } else { + encoded.push('~'); + encoded.push_str(&format!("{byte:02X}")); + } + } + encoded +} + +fn event_revision(path: &Path) -> u128 { + std::fs::metadata(path) + .ok() + .and_then(|metadata| metadata.modified().ok()) + .map(system_time_ms) + .unwrap_or_else(|| system_time_ms(SystemTime::now())) +} + +fn system_time_ms(time: SystemTime) -> u128 { + time.duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0) +} + +fn stream_event(event_name: &str, payload: &Value) -> SseEvent { + let id = payload + .get("revision") + .and_then(|value| { + value + .as_str() + .map(ToOwned::to_owned) + .or_else(|| value.as_u64().map(|number| number.to_string())) + }) + .unwrap_or_else(|| "0".to_string()); + SseEvent::default() + .event(event_name) + .id(id) + .json_data(payload) + .expect("SSE 事件必须可序列化") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_markdown_document_id_maps_to_relative_path() { + assert_eq!( + local_markdown_relative_path_from_document_id("local-md:docs~2FREADME.md") + .as_deref(), + Some("docs/README.md") + ); + } + + #[test] + fn markdown_path_filter_accepts_markdown_files_only() { + assert!(is_markdown_path(Path::new("README.md"))); + assert!(is_markdown_path(Path::new("README.markdown"))); + assert!(!is_markdown_path(Path::new("image.png"))); + } + + #[test] + fn event_kind_filter_ignores_access_events() { + assert!(should_emit_event_kind(&EventKind::Create(notify::event::CreateKind::File))); + assert!(should_emit_event_kind(&EventKind::Modify(ModifyKind::Data( + notify::event::DataChange::Content, + )))); + assert!(!should_emit_event_kind(&EventKind::Access( + notify::event::AccessKind::Read, + ))); + } +} diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index d8662f2d..efa51f8d 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -2088,7 +2088,7 @@ fn encode_local_id_segment(value: &str) -> String { encoded } -fn decode_local_id_segment(value: &str) -> Result { +pub(crate) fn decode_local_id_segment(value: &str) -> Result { let bytes = value.as_bytes(); let mut decoded = Vec::with_capacity(bytes.len()); let mut index = 0; diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index b6499112..067e91b5 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -8,6 +8,7 @@ mod health; mod hermes; mod kernel; mod local_folder_source; +mod local_folder_events; mod local_markdown_parser; mod mindmap_shell; mod query_support; @@ -97,6 +98,10 @@ pub fn build_router(state: AppState) -> Router { "/api/tree/local-folder-watch", get(tree::local_folder_watch), ) + .route( + "/api/local-folder/events", + get(local_folder_events::local_folder_events), + ) .route( "/api/tree/runtime/reduce", post(tree::reduce_tree_shell_runtime), diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index c2b17dcf..e48cb724 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -693,8 +693,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { let lastSavedSerialized = ''; let hasPendingLocalChanges = false; let suppressNextHostSyncChange = false; - let localExternalPollTimer = 0; - let localExternalPollInFlight = false; + let localExternalEvents = null; let lastExternalConflictDetectionKey = editorMeta.conflictDetectionKey || ''; const normalizeBridgeValue = (value) => { if (value instanceof Map) { @@ -858,10 +857,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { setStatus('synced-external-change'); }; - const pollLocalMarkdownExternalChange = async () => { + const refreshFromExternalFileChange = async () => { if (bootstrap.sourceKind !== 'local_folder' || !bootstrap.rootUri || document.hidden) return; - if (localExternalPollInFlight) return; - localExternalPollInFlight = true; try { const response = await fetch(pageAggregateUrl().toString(), { cache: 'no-store', @@ -883,11 +880,25 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { dispatchReplaceContent(nextAggregate); } catch (error) { console.warn('mnote local folder 外部更新检测失败', error); - } finally { - localExternalPollInFlight = false; } }; + const connectLocalMarkdownExternalEvents = () => { + if (bootstrap.sourceKind !== 'local_folder' || !bootstrap.rootUri || typeof window.EventSource !== 'function') { + return; + } + const url = new URL('/api/local-folder/events', window.location.origin); + url.searchParams.set('rootUri', bootstrap.rootUri); + url.searchParams.set('documentId', bootstrap.documentId); + localExternalEvents = new EventSource(url.toString()); + localExternalEvents.addEventListener('change', () => { + void refreshFromExternalFileChange(); + }); + localExternalEvents.onerror = () => { + console.warn('mnote local folder 外部更新事件流中断,将等待浏览器自动重连'); + }; + }; + const start = async () => { setStatus('loading-assets'); const manifestResponse = await fetch('/api/leptos-tiptap-runtime/manifest.json', { cache: 'no-store' }); @@ -909,12 +920,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { if (typeof window.__mnoteApplyPageOptionsToShell === 'function') { window.__mnoteApplyPageOptionsToShell(); } - if (bootstrap.sourceKind === 'local_folder' && bootstrap.rootUri) { - void pollLocalMarkdownExternalChange(); - localExternalPollTimer = window.setInterval(() => { - void pollLocalMarkdownExternalChange(); - }, 1200); - } + connectLocalMarkdownExternalEvents(); setStatus('ready'); }; @@ -1640,10 +1646,12 @@ mod tests { assert!(html.contains("asset.png")); assert!(html.contains("data-row-kind=\"markdown\"")); assert!(html.contains("data-mnote-action=\"open-local-folder\"")); - assert!(html.contains("pollLocalMarkdownExternalChange")); - assert!(html.contains("/api/page-aggregate/${encodeURIComponent(bootstrap.documentId)}")); + assert!(html.contains("refreshFromExternalFileChange")); + assert!(html.contains("/api/local-folder/events")); + assert!(html.contains("new EventSource(url.toString())")); assert!(html.contains("command: 'replaceContent'")); assert!(html.contains("external-change-conflict")); + assert!(!html.contains("setInterval(() => {\n void pollLocalMarkdownExternalChange();\n }, 1200);")); } #[tokio::test]