feat: consolidate local-first mnote web runtime
This commit is contained in:
@@ -10,16 +10,16 @@ use crate::routes::snapshot_support::ProjectionSnapshot;
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use axum::response::sse::{Event as SseEvent, Sse};
|
||||
use futures_util::stream;
|
||||
use futures_util::StreamExt;
|
||||
use futures_util::stream;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::convert::Infallible;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio::time::{interval, timeout, MissedTickBehavior};
|
||||
use tokio::time::{MissedTickBehavior, interval, timeout};
|
||||
|
||||
type BoxedEventStream =
|
||||
Pin<Box<dyn futures_util::Stream<Item = Result<SseEvent, Infallible>> + Send>>;
|
||||
@@ -29,6 +29,7 @@ type BoxedEventStream =
|
||||
pub struct LocalFolderEventsQuery {
|
||||
pub root_uri: String,
|
||||
pub document_id: Option<String>,
|
||||
pub resource_path: Option<String>,
|
||||
pub tree_live: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -68,7 +69,8 @@ async fn build_document_events_stream(
|
||||
let document_relative_path = query
|
||||
.document_id
|
||||
.as_deref()
|
||||
.and_then(local_markdown_relative_path_from_document_id);
|
||||
.and_then(local_markdown_relative_path_from_document_id)
|
||||
.or_else(|| query.resource_path.as_deref().and_then(normalize_resource_event_path));
|
||||
let subscription = state
|
||||
.local_folder_watcher_registry()
|
||||
.subscribe(&canonical_root)
|
||||
@@ -78,6 +80,7 @@ async fn build_document_events_stream(
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": subscription.root_uri(),
|
||||
"documentId": query.document_id,
|
||||
"resourcePath": query.resource_path,
|
||||
"revision": system_time_ms(SystemTime::now()),
|
||||
});
|
||||
let stream = stream::unfold(
|
||||
@@ -117,7 +120,7 @@ async fn build_document_events_stream(
|
||||
/// with full sidebar + file tree projections.
|
||||
///
|
||||
/// Reuses `LocalFolderWatcherRegistry` — no second watcher created.
|
||||
/// No data is written to Convex command log.
|
||||
/// No data is written to an external/cloud command log.
|
||||
async fn build_tree_live_stream(
|
||||
state: AppState,
|
||||
context: RequestContext,
|
||||
@@ -423,6 +426,18 @@ fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option<St
|
||||
decode_local_id_segment(encoded).ok()
|
||||
}
|
||||
|
||||
fn normalize_resource_event_path(resource_path: &str) -> Option<String> {
|
||||
let trimmed = resource_path.trim().trim_start_matches('/');
|
||||
if trimmed.is_empty()
|
||||
|| PathBuf::from(trimmed)
|
||||
.components()
|
||||
.any(|component| matches!(component, std::path::Component::ParentDir))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(trimmed.replace('\\', "/"))
|
||||
}
|
||||
|
||||
fn document_event_targets_relative_path(payload: &Value, expected: &str) -> bool {
|
||||
let relative_path = payload
|
||||
.get("relativePath")
|
||||
@@ -457,7 +472,7 @@ fn stream_event(event_name: &str, payload: &Value) -> SseEvent {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::app::{AppConfig, AppState, build_app};
|
||||
use crate::routes::local_folder_source::initialize_local_workspace_for_actor;
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
@@ -577,6 +592,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_event_path_filters_to_its_own_relative_path() {
|
||||
let expected = normalize_resource_event_path("README.assets/slides.pptx")
|
||||
.expect("resource path should normalize");
|
||||
let payload = json!({
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": "file:///test",
|
||||
"relativePath": "README.assets/slides.pptx",
|
||||
"documentId": "",
|
||||
"eventKind": "Modify(Data)",
|
||||
"revision": 1,
|
||||
});
|
||||
let other_payload = json!({
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": "file:///test",
|
||||
"relativePath": "README.assets/report.pdf",
|
||||
"documentId": "",
|
||||
"eventKind": "Modify(Data)",
|
||||
"revision": 2,
|
||||
});
|
||||
|
||||
assert!(document_event_targets_relative_path(&payload, &expected));
|
||||
assert!(!document_event_targets_relative_path(&other_payload, &expected));
|
||||
assert!(
|
||||
normalize_resource_event_path("../escape.pdf").is_none(),
|
||||
"resource watch path 不能越过 root"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tree_snapshot_payload_has_required_fields() {
|
||||
let sidebar_projection = json!({
|
||||
@@ -697,11 +741,13 @@ mod tests {
|
||||
&& parent["reason"].as_str() == Some("child-watch")),
|
||||
"watch batch 应声明 docs affected parent: {payload}"
|
||||
);
|
||||
assert!(payload["eventKinds"]
|
||||
.as_array()
|
||||
.expect("event kinds")
|
||||
.iter()
|
||||
.any(|kind| kind.as_str() == Some("Modify(Data)")));
|
||||
assert!(
|
||||
payload["eventKinds"]
|
||||
.as_array()
|
||||
.expect("event kinds")
|
||||
.iter()
|
||||
.any(|kind| kind.as_str() == Some("Modify(Data)"))
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user