feat: finish rust web dual pane document shell
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
use crate::middleware::request_context::inject_request_context;
|
||||
use crate::routes::build_router;
|
||||
use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry;
|
||||
use axum::Router;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
@@ -132,18 +133,24 @@ fn read_env_or_dotenv(key: &str) -> Option<String> {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppState {
|
||||
config: Arc<AppConfig>,
|
||||
local_folder_watcher_registry: LocalFolderWatcherRegistry,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: AppConfig) -> Self {
|
||||
Self {
|
||||
config: Arc::new(config),
|
||||
local_folder_watcher_registry: LocalFolderWatcherRegistry::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &AppConfig {
|
||||
self.config.as_ref()
|
||||
}
|
||||
|
||||
pub fn local_folder_watcher_registry(&self) -> &LocalFolderWatcherRegistry {
|
||||
&self.local_folder_watcher_registry
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_app(state: AppState) -> Router {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod app;
|
||||
pub mod context;
|
||||
pub mod error;
|
||||
pub mod local_folder_watcher_registry;
|
||||
pub mod middleware;
|
||||
pub mod page_aggregate;
|
||||
pub mod routes;
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
use notify::event::ModifyKind;
|
||||
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LocalFolderWatcherRegistry {
|
||||
inner: Arc<LocalFolderWatcherRegistryInner>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LocalFolderWatcherRegistry {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("LocalFolderWatcherRegistry")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalFolderWatcherRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(LocalFolderWatcherRegistryInner {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe(
|
||||
&self,
|
||||
canonical_root: &Path,
|
||||
) -> Result<LocalFolderWatcherSubscription, String> {
|
||||
let key = canonical_root_uri(canonical_root);
|
||||
let channel = self.inner.get_or_create_channel(&key, canonical_root)?;
|
||||
channel.subscriber_count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(LocalFolderWatcherSubscription {
|
||||
receiver: channel.sender.subscribe(),
|
||||
guard: LocalFolderWatcherSubscriptionGuard {
|
||||
registry: Arc::downgrade(&self.inner),
|
||||
key,
|
||||
channel,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn active_watcher_count(&self) -> usize {
|
||||
self.inner
|
||||
.entries
|
||||
.lock()
|
||||
.expect("registry lock")
|
||||
.len()
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalFolderWatcherRegistryInner {
|
||||
entries: Mutex<HashMap<String, Arc<LocalFolderWatchChannel>>>,
|
||||
}
|
||||
|
||||
impl LocalFolderWatcherRegistryInner {
|
||||
fn get_or_create_channel(
|
||||
&self,
|
||||
key: &str,
|
||||
canonical_root: &Path,
|
||||
) -> Result<Arc<LocalFolderWatchChannel>, String> {
|
||||
if let Some(existing) = self.entries.lock().expect("registry lock").get(key).cloned() {
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let channel = Arc::new(LocalFolderWatchChannel::new(
|
||||
key.to_string(),
|
||||
spawn_local_folder_watcher(key, canonical_root.to_path_buf())?,
|
||||
));
|
||||
|
||||
let mut entries = self.entries.lock().expect("registry lock");
|
||||
if let Some(existing) = entries.get(key).cloned() {
|
||||
return Ok(existing);
|
||||
}
|
||||
entries.insert(key.to_string(), channel.clone());
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
fn remove_if_idle(&self, key: &str, channel: &Arc<LocalFolderWatchChannel>) {
|
||||
let mut entries = self.entries.lock().expect("registry lock");
|
||||
let should_remove = entries
|
||||
.get(key)
|
||||
.map(|current| {
|
||||
Arc::ptr_eq(current, channel)
|
||||
&& channel.subscriber_count.load(Ordering::SeqCst) == 0
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if should_remove {
|
||||
entries.remove(key);
|
||||
channel.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalFolderWatchChannel {
|
||||
sender: broadcast::Sender<Value>,
|
||||
subscriber_count: AtomicUsize,
|
||||
shutdown_tx: Mutex<Option<oneshot::Sender<()>>>,
|
||||
}
|
||||
|
||||
impl LocalFolderWatchChannel {
|
||||
fn new(
|
||||
_root_uri: String,
|
||||
parts: (broadcast::Sender<Value>, oneshot::Sender<()>),
|
||||
) -> Self {
|
||||
Self {
|
||||
sender: parts.0,
|
||||
subscriber_count: AtomicUsize::new(0),
|
||||
shutdown_tx: Mutex::new(Some(parts.1)),
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown(&self) {
|
||||
if let Some(sender) = self.shutdown_tx.lock().expect("shutdown lock").take() {
|
||||
let _ = sender.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct LocalFolderWatcherSubscription {
|
||||
pub(crate) receiver: broadcast::Receiver<Value>,
|
||||
guard: LocalFolderWatcherSubscriptionGuard,
|
||||
}
|
||||
|
||||
impl LocalFolderWatcherSubscription {
|
||||
pub(crate) fn root_uri(&self) -> &str {
|
||||
self.guard.key.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalFolderWatcherSubscriptionGuard {
|
||||
registry: Weak<LocalFolderWatcherRegistryInner>,
|
||||
key: String,
|
||||
channel: Arc<LocalFolderWatchChannel>,
|
||||
}
|
||||
|
||||
impl Drop for LocalFolderWatcherSubscriptionGuard {
|
||||
fn drop(&mut self) {
|
||||
let previous = self.channel.subscriber_count.fetch_sub(1, Ordering::SeqCst);
|
||||
if previous != 1 {
|
||||
return;
|
||||
}
|
||||
if let Some(registry) = self.registry.upgrade() {
|
||||
registry.remove_if_idle(&self.key, &self.channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_local_folder_watcher(
|
||||
root_uri: &str,
|
||||
canonical_root: PathBuf,
|
||||
) -> Result<(broadcast::Sender<Value>, oneshot::Sender<()>), String> {
|
||||
let (event_sender, mut event_receiver) = mpsc::unbounded_channel::<notify::Result<Event>>();
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |result| {
|
||||
let _ = event_sender.send(result);
|
||||
},
|
||||
Config::default(),
|
||||
)
|
||||
.map_err(|error| format!("本地文件事件监听启动失败: {error}"))?;
|
||||
watcher
|
||||
.watch(&canonical_root, RecursiveMode::Recursive)
|
||||
.map_err(|error| format!("本地文件夹监听失败: {error}"))?;
|
||||
|
||||
let (sender, _) = broadcast::channel::<Value>(256);
|
||||
let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
|
||||
let sender_for_task = sender.clone();
|
||||
let root_uri_for_task = root_uri.to_string();
|
||||
tokio::spawn(async move {
|
||||
let _watcher = watcher;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut shutdown_rx => {
|
||||
return;
|
||||
}
|
||||
maybe_result = event_receiver.recv() => {
|
||||
let Some(result) = maybe_result else {
|
||||
return;
|
||||
};
|
||||
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(&canonical_root, &path) else {
|
||||
continue;
|
||||
};
|
||||
let payload = json!({
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri_for_task,
|
||||
"relativePath": relative_path,
|
||||
"documentId": format!("local-md:{}", encode_local_id_segment(&relative_path)),
|
||||
"eventKind": format!("{:?}", event.kind),
|
||||
"revision": event_revision(&path),
|
||||
});
|
||||
let _ = sender_for_task.send(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok((sender, shutdown_tx))
|
||||
}
|
||||
|
||||
fn canonical_root_uri(root: &Path) -> String {
|
||||
format!("file://{}", root.display())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{should_emit_event_kind, LocalFolderWatcherRegistry};
|
||||
use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind};
|
||||
use notify::EventKind;
|
||||
|
||||
fn test_root(name: &str) -> std::path::PathBuf {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-folder-watcher-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
std::fs::create_dir_all(&root).expect("create temp root");
|
||||
root
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_root_subscribers_share_single_watcher() {
|
||||
let registry = LocalFolderWatcherRegistry::new();
|
||||
let root = test_root("shared");
|
||||
|
||||
let first = registry.subscribe(&root).expect("first subscription");
|
||||
let second = registry.subscribe(&root).expect("second subscription");
|
||||
|
||||
assert_eq!(registry.active_watcher_count(), 1);
|
||||
|
||||
drop(first);
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(registry.active_watcher_count(), 1);
|
||||
|
||||
drop(second);
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(registry.active_watcher_count(), 0);
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn different_roots_create_independent_watchers() {
|
||||
let registry = LocalFolderWatcherRegistry::new();
|
||||
let first_root = test_root("first");
|
||||
let second_root = test_root("second");
|
||||
|
||||
let first = registry.subscribe(&first_root).expect("first root subscription");
|
||||
let second = registry
|
||||
.subscribe(&second_root)
|
||||
.expect("second root subscription");
|
||||
|
||||
assert_eq!(registry.active_watcher_count(), 2);
|
||||
|
||||
drop(first);
|
||||
drop(second);
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(registry.active_watcher_count(), 0);
|
||||
|
||||
let _ = std::fs::remove_dir_all(first_root);
|
||||
let _ = std::fs::remove_dir_all(second_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_kind_filter_ignores_access_events() {
|
||||
assert!(should_emit_event_kind(&EventKind::Create(CreateKind::File)));
|
||||
assert!(should_emit_event_kind(&EventKind::Modify(ModifyKind::Data(
|
||||
DataChange::Content,
|
||||
))));
|
||||
assert!(!should_emit_event_kind(&EventKind::Access(AccessKind::Read)));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -7,6 +7,142 @@ use crate::ssr::pages::layout::PageLayout;
|
||||
use leptos::prelude::*;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DocumentPaneViewModel {
|
||||
pub pane_role: &'static str,
|
||||
pub title: String,
|
||||
pub document_id: String,
|
||||
pub workspace_id: String,
|
||||
pub page_wide_layout: bool,
|
||||
pub page_small_text: bool,
|
||||
pub page_layout_density: String,
|
||||
pub page_font: String,
|
||||
pub page_show_heading_numbers: bool,
|
||||
pub has_page_subtree: bool,
|
||||
pub primary_legacy_ids: bool,
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn DocumentPane(model: DocumentPaneViewModel) -> impl IntoView {
|
||||
let pane_hidden = !model.visible;
|
||||
let pane_role = model.pane_role.to_string();
|
||||
let pane_role_attr = pane_role.clone();
|
||||
let pane_role_attr_two = pane_role.clone();
|
||||
let pane_role_attr_three = pane_role.clone();
|
||||
let pane_role_attr_four = pane_role.clone();
|
||||
let pane_role_attr_five = pane_role.clone();
|
||||
let pane_label = if model.pane_role == "secondary" {
|
||||
"右侧文档"
|
||||
} else {
|
||||
"主文档"
|
||||
};
|
||||
let title_input_id = if model.primary_legacy_ids {
|
||||
Some("mnote-page-title-input".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let editor_island_id = if model.primary_legacy_ids {
|
||||
Some("mnote-editor-island".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let editor_root_id = if model.primary_legacy_ids {
|
||||
Some("mnote-leptos-tiptap-island-editor-root".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
view! {
|
||||
<section
|
||||
class="document-pane"
|
||||
data-document-pane="true"
|
||||
data-pane-role={pane_role_attr}
|
||||
data-pane-document-id={model.document_id.clone()}
|
||||
data-pane-workspace-id={model.workspace_id.clone()}
|
||||
data-pane-visible={model.visible.to_string()}
|
||||
aria-label={pane_label}
|
||||
hidden={pane_hidden}
|
||||
>
|
||||
<main
|
||||
class="document-shell"
|
||||
data-editor-host="leptos_tiptap_island"
|
||||
data-document-id={model.document_id.clone()}
|
||||
data-workspace-id={model.workspace_id.clone()}
|
||||
data-pane-role={pane_role_attr_two}
|
||||
data-page-wide-layout={model.page_wide_layout.to_string()}
|
||||
data-page-small-text={model.page_small_text.to_string()}
|
||||
data-layout-density={model.page_layout_density.clone()}
|
||||
data-page-font={model.page_font.clone()}
|
||||
data-page-show-heading-numbers={model.page_show_heading_numbers.to_string()}
|
||||
>
|
||||
<header class="document-shell-header">
|
||||
<div class="document-page-icon" aria-hidden="true">
|
||||
<span class="material-symbols-outlined material-symbols-filled mnote-material-page-icon" data-icon="home"></span>
|
||||
</div>
|
||||
<div class="document-pane-header-row">
|
||||
<h1 class="document-title-heading">
|
||||
<textarea
|
||||
id={title_input_id}
|
||||
class="document-title-input"
|
||||
aria-label="页面标题"
|
||||
data-page-title-input="true"
|
||||
data-document-id={model.document_id.clone()}
|
||||
data-workspace-id={model.workspace_id.clone()}
|
||||
data-pane-role={pane_role_attr_three}
|
||||
data-title-endpoint="/api/documents/title"
|
||||
rows="1"
|
||||
>{model.title.clone()}</textarea>
|
||||
</h1>
|
||||
<Show when={move || model.pane_role == "secondary"}>
|
||||
<button
|
||||
type="button"
|
||||
class="document-pane-close"
|
||||
data-mnote-pane-close="secondary"
|
||||
aria-label="关闭右侧文档"
|
||||
title="关闭右侧文档"
|
||||
>
|
||||
<span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="document-shell-meta" aria-label="页面元信息">
|
||||
<span data-page-title-current="true">{model.title.clone()}</span>
|
||||
</div>
|
||||
</header>
|
||||
<section data-page-aggregate-snapshot="mnote.page_aggregate.v1"></section>
|
||||
<section
|
||||
data-testid="mnote-page-subtree"
|
||||
data-page-tree-source="page_aggregate.tree.pageSubtree"
|
||||
data-page-subtree-present={model.has_page_subtree.to_string()}
|
||||
data-pane-role={pane_role_attr_four}
|
||||
></section>
|
||||
<section
|
||||
id={editor_island_id}
|
||||
data-editor-host="leptos_tiptap_island"
|
||||
data-pane-role={pane_role_attr_five}
|
||||
>
|
||||
<div
|
||||
id={editor_root_id}
|
||||
data-testid="mnote-leptos-tiptap-island-editor-root"
|
||||
data-editor-host-kind="leptos_tiptap_island"
|
||||
data-runtime-editor-status="booting"
|
||||
data-pane-role={pane_role.clone()}
|
||||
></div>
|
||||
<div
|
||||
class="sr-only"
|
||||
data-editor-host-observability="rust-web-inline-island"
|
||||
data-editor-host-active="leptos_tiptap_island"
|
||||
data-editor-host-requested="leptos_tiptap_island"
|
||||
data-editor-host-status="booting"
|
||||
data-pane-role={pane_role}
|
||||
></div>
|
||||
</section>
|
||||
</main>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
/// MNOTE 文档页面
|
||||
///
|
||||
/// 渲染文档编辑器的 SSR 壳结构:
|
||||
@@ -37,6 +173,21 @@ pub fn DocumentPage(
|
||||
/// Page Aggregate 页面选项 JSON(可选)
|
||||
#[prop(optional)]
|
||||
page_options_json: Option<String>,
|
||||
/// 右侧文档标题(可选)
|
||||
#[prop(optional)]
|
||||
secondary_title: String,
|
||||
/// 右侧文档 id(可选)
|
||||
#[prop(optional)]
|
||||
secondary_document_id: String,
|
||||
/// 右侧 workspace id(可选)
|
||||
#[prop(optional)]
|
||||
secondary_workspace_id: String,
|
||||
/// 右侧 Page Aggregate 子树 JSON(可选)
|
||||
#[prop(optional)]
|
||||
secondary_page_subtree_json: String,
|
||||
/// 右侧页面选项 JSON(可选)
|
||||
#[prop(optional)]
|
||||
secondary_page_options_json: String,
|
||||
) -> impl IntoView {
|
||||
let has_page_subtree = page_subtree_json
|
||||
.as_deref()
|
||||
@@ -71,62 +222,86 @@ pub fn DocumentPage(
|
||||
.unwrap_or("default")
|
||||
.to_string();
|
||||
let page_show_heading_numbers = false;
|
||||
let secondary_has_page_subtree = Some(secondary_page_subtree_json.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && *value != "null")
|
||||
.is_some();
|
||||
let secondary_page_options = Some(secondary_page_options_json.as_str())
|
||||
.and_then(|value| serde_json::from_str::<Value>(value).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
let secondary_page_wide_layout = secondary_page_options
|
||||
.get("wideLayout")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let secondary_page_small_text = secondary_page_options
|
||||
.get("smallText")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let secondary_page_layout_density = secondary_page_options
|
||||
.get("layoutDensity")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("normal")
|
||||
.to_string();
|
||||
let secondary_page_font = secondary_page_options
|
||||
.get("pageFont")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("default")
|
||||
.to_string();
|
||||
let secondary_page_show_heading_numbers = false;
|
||||
let secondary_visible = Some(secondary_document_id.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some();
|
||||
let primary_model = DocumentPaneViewModel {
|
||||
pane_role: "primary",
|
||||
title: title.clone(),
|
||||
document_id: document_id.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
page_wide_layout,
|
||||
page_small_text,
|
||||
page_layout_density: page_layout_density.clone(),
|
||||
page_font: page_font.clone(),
|
||||
page_show_heading_numbers,
|
||||
has_page_subtree,
|
||||
primary_legacy_ids: true,
|
||||
visible: true,
|
||||
};
|
||||
let secondary_model = DocumentPaneViewModel {
|
||||
pane_role: "secondary",
|
||||
title: secondary_title,
|
||||
document_id: secondary_document_id,
|
||||
workspace_id: if secondary_workspace_id.trim().is_empty() {
|
||||
workspace_id.clone()
|
||||
} else {
|
||||
secondary_workspace_id
|
||||
},
|
||||
page_wide_layout: secondary_page_wide_layout,
|
||||
page_small_text: secondary_page_small_text,
|
||||
page_layout_density: secondary_page_layout_density,
|
||||
page_font: secondary_page_font,
|
||||
page_show_heading_numbers: secondary_page_show_heading_numbers,
|
||||
has_page_subtree: secondary_has_page_subtree,
|
||||
primary_legacy_ids: false,
|
||||
visible: secondary_visible,
|
||||
};
|
||||
view! {
|
||||
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={title.clone()}>
|
||||
<main
|
||||
class="document-shell"
|
||||
data-editor-host="leptos_tiptap_island"
|
||||
data-document-id={document_id.clone()}
|
||||
data-workspace-id={workspace_id.clone()}
|
||||
data-page-wide-layout={page_wide_layout.to_string()}
|
||||
data-page-small-text={page_small_text.to_string()}
|
||||
data-layout-density={page_layout_density.clone()}
|
||||
data-page-font={page_font.clone()}
|
||||
data-page-show-heading-numbers={page_show_heading_numbers.to_string()}
|
||||
<div
|
||||
class="document-workspace"
|
||||
data-testid="mnote-document-workspace"
|
||||
data-has-secondary-pane={secondary_visible.to_string()}
|
||||
>
|
||||
<header class="document-shell-header">
|
||||
<div class="document-page-icon" aria-hidden="true">
|
||||
<span class="material-symbols-outlined material-symbols-filled mnote-material-page-icon" data-icon="home"></span>
|
||||
</div>
|
||||
<h1 class="document-title-heading">
|
||||
<textarea
|
||||
id="mnote-page-title-input"
|
||||
class="document-title-input"
|
||||
aria-label="页面标题"
|
||||
data-page-title-input="true"
|
||||
data-document-id={document_id.clone()}
|
||||
data-workspace-id={workspace_id.clone()}
|
||||
data-title-endpoint="/api/documents/title"
|
||||
rows="1"
|
||||
>{title.clone()}</textarea>
|
||||
</h1>
|
||||
<div class="document-shell-meta" aria-label="页面元信息">
|
||||
<span><span aria-hidden="true">"◌"</span>{workspace_label}</span>
|
||||
<span><span aria-hidden="true">"▣"</span>"已同步"</span>
|
||||
</div>
|
||||
</header>
|
||||
<section data-page-aggregate-snapshot="mnote.page_aggregate.v1"></section>
|
||||
<section
|
||||
data-testid="mnote-page-subtree"
|
||||
data-page-tree-source="page_aggregate.tree.pageSubtree"
|
||||
data-page-subtree-present={has_page_subtree.to_string()}
|
||||
></section>
|
||||
<section id="mnote-editor-island" data-editor-host="leptos_tiptap_island">
|
||||
<div
|
||||
id="mnote-leptos-tiptap-island-editor-root"
|
||||
data-testid="mnote-leptos-tiptap-island-editor-root"
|
||||
data-editor-host-kind="leptos_tiptap_island"
|
||||
data-runtime-editor-status="booting"
|
||||
></div>
|
||||
<div
|
||||
class="sr-only"
|
||||
data-editor-host-observability="rust-web-inline-island"
|
||||
data-editor-host-active="leptos_tiptap_island"
|
||||
data-editor-host-requested="leptos_tiptap_island"
|
||||
data-editor-host-status="booting"
|
||||
></div>
|
||||
</section>
|
||||
</main>
|
||||
<DocumentPane model={primary_model} />
|
||||
<div
|
||||
class="document-pane-resizer"
|
||||
data-testid="mnote-secondary-pane-resizer"
|
||||
data-document-pane-resizer="true"
|
||||
aria-hidden="true"
|
||||
hidden={!secondary_visible}
|
||||
></div>
|
||||
<DocumentPane model={secondary_model} />
|
||||
</div>
|
||||
<div class="sr-only" data-mnote-workspace-label>{workspace_label}</div>
|
||||
</PageLayout>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
function copyWorkspaceSourceParams(targetUrl) {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
['sourceKind', 'rootUri'].forEach(function(name) {
|
||||
['sourceKind', 'rootUri', 'secondaryDocumentId', 'secondarySourceKind', 'secondaryRootUri'].forEach(function(name) {
|
||||
var value = (params.get(name) || '').trim();
|
||||
if (value) targetUrl.searchParams.set(name, value);
|
||||
});
|
||||
|
||||
@@ -1828,12 +1828,68 @@ body {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.document-workspace {
|
||||
--mnote-secondary-pane-width: minmax(320px, 42%);
|
||||
--mnote-secondary-pane-resizer-width: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.document-workspace[data-has-secondary-pane="true"] {
|
||||
grid-template-columns: minmax(0, 1fr) var(--mnote-secondary-pane-resizer-width) var(--mnote-secondary-pane-width);
|
||||
}
|
||||
|
||||
.document-pane {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.document-pane[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.document-pane-resizer {
|
||||
display: none;
|
||||
width: var(--mnote-secondary-pane-resizer-width);
|
||||
min-height: calc(100vh - 40px);
|
||||
cursor: col-resize;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.document-workspace[data-has-secondary-pane="true"] .document-pane-resizer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.document-pane-resizer::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 64px;
|
||||
bottom: 48px;
|
||||
width: 2px;
|
||||
border-radius: 999px;
|
||||
background: rgba(27, 28, 28, 0.08);
|
||||
}
|
||||
|
||||
.document-shell-header {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
margin: 0 0 50px;
|
||||
}
|
||||
|
||||
.document-pane-header-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.document-pane-header-row .document-title-heading {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.document-page-icon {
|
||||
display: none;
|
||||
}
|
||||
@@ -1886,6 +1942,26 @@ body {
|
||||
text-underline-offset: 6px;
|
||||
}
|
||||
|
||||
.document-pane-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #6D6A65;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.document-pane-close:hover {
|
||||
background: #F4F3F3;
|
||||
color: #1B1C1C;
|
||||
}
|
||||
|
||||
.document-shell-meta {
|
||||
display: none;
|
||||
}
|
||||
@@ -2048,6 +2124,16 @@ body {
|
||||
width: min(100%, 980px);
|
||||
}
|
||||
|
||||
.document-pane[data-pane-role="secondary"] .document-shell {
|
||||
width: min(100%, 820px);
|
||||
padding-left: 28px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.document-pane[data-pane-role="secondary"] .document-shell-header {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.document-shell[data-page-small-text="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror,
|
||||
.document-shell[data-page-small-text="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror p,
|
||||
.document-shell[data-page-small-text="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror li {
|
||||
@@ -2601,6 +2687,20 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.document-workspace[data-has-secondary-pane="true"] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.document-workspace[data-has-secondary-pane="true"] .document-pane-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.document-pane[data-pane-role="secondary"] .document-shell {
|
||||
padding-top: 32px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.document-shell {
|
||||
width: 100%;
|
||||
padding: 48px 28px 120px;
|
||||
@@ -2711,7 +2811,7 @@ mod tests {
|
||||
fn mnote_css_is_reasonably_sized() {
|
||||
// 至少 2000 字符才能包含完整样式
|
||||
assert!(MNOTE_CSS.len() > 2000);
|
||||
// 菜单与本地 SVG mask 图标会增加体积,仍保持在单文件可审阅范围内。
|
||||
assert!(MNOTE_CSS.len() < 46000);
|
||||
// 当前整合了工作区壳、编辑器样式、树菜单和双 pane 布局,仍保持在单文件可审阅范围内。
|
||||
assert!(MNOTE_CSS.len() < 70000);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user