feat: finish rust web dual pane document shell
This commit is contained in:
@@ -1,18 +1,17 @@
|
||||
use crate::app::AppState;
|
||||
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::extract::{Extension, Query, State};
|
||||
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::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -22,6 +21,7 @@ pub struct LocalFolderEventsQuery {
|
||||
}
|
||||
|
||||
pub async fn local_folder_events(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<LocalFolderEventsQuery>,
|
||||
) -> Result<
|
||||
@@ -43,78 +43,49 @@ pub async fn local_folder_events(
|
||||
.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 subscription = state
|
||||
.local_folder_watcher_registry()
|
||||
.subscribe(&canonical_root)
|
||||
.map_err(|error| WebError::internal(error).with_context(&context))?;
|
||||
|
||||
let initial = json!({
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": query.root_uri,
|
||||
"rootUri": subscription.root_uri(),
|
||||
"documentId": query.document_id,
|
||||
"revision": system_time_ms(SystemTime::now()),
|
||||
});
|
||||
let stream = stream::unfold((Some(initial), receiver), |(initial, mut receiver)| async move {
|
||||
let stream = stream::unfold(
|
||||
(Some(initial), subscription, document_relative_path),
|
||||
|(initial, mut subscription, document_relative_path)| async move {
|
||||
if let Some(payload) = initial {
|
||||
return Some((Ok(stream_event("ready", &payload)), (None, receiver)));
|
||||
return Some((
|
||||
Ok(stream_event("ready", &payload)),
|
||||
(None, subscription, document_relative_path),
|
||||
));
|
||||
}
|
||||
receiver
|
||||
.recv()
|
||||
.await
|
||||
.map(|payload| (Ok(stream_event("change", &payload)), (None, receiver)))
|
||||
});
|
||||
loop {
|
||||
match subscription.receiver.recv().await {
|
||||
Ok(payload) => {
|
||||
if let Some(expected) = document_relative_path.as_deref() {
|
||||
let relative_path = payload
|
||||
.get("relativePath")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if expected != relative_path {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return Some((
|
||||
Ok(stream_event("change", &payload)),
|
||||
(None, subscription, document_relative_path),
|
||||
));
|
||||
}
|
||||
Err(RecvError::Lagged(_)) => continue,
|
||||
Err(RecvError::Closed) => return None,
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") {
|
||||
@@ -151,55 +122,6 @@ fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option<St
|
||||
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())
|
||||
@@ -237,20 +159,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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,
|
||||
)));
|
||||
fn parse_file_root_uri_requires_file_scheme() {
|
||||
assert!(parse_file_root_uri("file:///tmp/example").is_ok());
|
||||
assert!(parse_file_root_uri("/tmp/example").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2655,6 +2655,15 @@ fn local_markdown_conflict_detection_key(
|
||||
document_id: &str,
|
||||
markdown_path: &Path,
|
||||
) -> Result<String, WebError> {
|
||||
let content = fs::read(markdown_path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_markdown_read_failed",
|
||||
format!(
|
||||
"无法读取本地 Markdown 文件 {}: {error}",
|
||||
markdown_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let meta = fs::metadata(markdown_path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_markdown_stat_failed",
|
||||
@@ -2665,8 +2674,11 @@ fn local_markdown_conflict_detection_key(
|
||||
)
|
||||
})?;
|
||||
let modified_ms = system_time_ms(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH));
|
||||
let mut hasher = DefaultHasher::new();
|
||||
content.hash(&mut hasher);
|
||||
let content_hash = hasher.finish();
|
||||
Ok(format!(
|
||||
"local-md:{document_id}:{modified_ms}:{}",
|
||||
"local-md:{document_id}:{modified_ms}:{}:{content_hash:016x}",
|
||||
meta.len()
|
||||
))
|
||||
}
|
||||
@@ -2965,6 +2977,25 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_conflict_detection_key_changes_when_content_changes_with_same_size() {
|
||||
let root = temp_root("mnote-local-conflict-key-content");
|
||||
let file = root.join("README.md");
|
||||
std::fs::write(&file, "aaaa\n").expect("write first content");
|
||||
|
||||
let first = super::local_markdown_conflict_detection_key("local-md:README.md", &file)
|
||||
.expect("first conflict key");
|
||||
|
||||
std::fs::write(&file, "bbbb\n").expect("write second content");
|
||||
|
||||
let second = super::local_markdown_conflict_detection_key("local-md:README.md", &file)
|
||||
.expect("second conflict key");
|
||||
|
||||
assert_ne!(first, second);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_parser_covers_basic_blocks_and_attachment_refs() {
|
||||
let blocks = crate::routes::local_markdown_parser::markdown_to_blocks(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user