0508 1.2s轮询同步
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 273 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 335 KiB |
Generated
+78
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user