0508 1.2s轮询同步

This commit is contained in:
lix-2026
2026-05-08 11:23:08 +08:00
parent b183d24ba5
commit 83805f9254
8 changed files with 364 additions and 16 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

+78
View File
@@ -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"
+1
View File
@@ -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"] }
@@ -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<String>,
}
pub async fn local_folder_events(
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalFolderEventsQuery>,
) -> Result<
(
HeaderMap,
Sse<impl futures_util::Stream<Item = Result<SseEvent, Infallible>>>,
),
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::<Value>();
let (watch_sender, mut watch_receiver) =
mpsc::unbounded_channel::<notify::Result<Event>>();
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<PathBuf, WebError> {
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<String> {
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<String> {
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,
)));
}
}
@@ -2088,7 +2088,7 @@ fn encode_local_id_segment(value: &str) -> String {
encoded
}
fn decode_local_id_segment(value: &str) -> Result<String, WebError> {
pub(crate) fn decode_local_id_segment(value: &str) -> Result<String, WebError> {
let bytes = value.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
+5
View File
@@ -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),
+23 -15
View File
@@ -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]