batch E: close tail checks and refresh smoke evidence
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::document_buffer_store::{self, BufferKey, BufferStore};
|
||||
use crate::error::WebError;
|
||||
use crate::routes::command_support::{
|
||||
execute_runtime_command_via_convex, execute_runtime_command_via_convex_with_artifacts,
|
||||
@@ -38,6 +39,64 @@ pub struct DocumentMetaQuery {
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BufferStateQuery {
|
||||
pub document_id: Option<String>,
|
||||
pub workspace_id: Option<String>,
|
||||
pub source_kind: Option<String>,
|
||||
pub root_uri: Option<String>,
|
||||
pub relative_path: Option<String>,
|
||||
}
|
||||
|
||||
fn find_document_buffer_state(
|
||||
buffer_store: &BufferStore,
|
||||
query: &BufferStateQuery,
|
||||
) -> Option<core_protocol::DocumentBuffer> {
|
||||
if let (Some(root_uri), Some(_source_kind), Some(document_id)) = (
|
||||
query.root_uri.as_deref().filter(|v| !v.is_empty()),
|
||||
query.source_kind.as_deref().filter(|v| !v.is_empty()),
|
||||
query.document_id.as_deref().filter(|v| !v.is_empty()),
|
||||
) {
|
||||
// 本地文件夹查询优先用明确 relativePath;缺省时退回扫描,避免前端只知道 documentId 时查不到已初始化 buffer。
|
||||
if let Some(relative_path) = query.relative_path.as_deref().filter(|v| !v.is_empty()) {
|
||||
let ws_path = document_buffer_store::build_local_folder_workspace_path(
|
||||
query.workspace_id.as_deref().unwrap_or(""),
|
||||
root_uri,
|
||||
relative_path,
|
||||
document_id,
|
||||
);
|
||||
return buffer_store.get_by_path(&ws_path);
|
||||
}
|
||||
return buffer_store.all_buffers().into_iter().find(|buf| {
|
||||
buf.workspace_path.root_uri == root_uri
|
||||
&& buf.workspace_path.object_identity.document_id.as_deref() == Some(document_id)
|
||||
});
|
||||
}
|
||||
if let (Some(document_id), Some(workspace_id), Some(relative_path)) = (
|
||||
query.document_id.as_deref().filter(|v| !v.is_empty()),
|
||||
query.workspace_id.as_deref().filter(|v| !v.is_empty()),
|
||||
query.relative_path.as_deref().filter(|v| !v.is_empty()),
|
||||
) {
|
||||
// 通过完整 workspace path 精确查询。
|
||||
let key = BufferKey::from_parts(
|
||||
workspace_id,
|
||||
"LocalFolder",
|
||||
"",
|
||||
relative_path,
|
||||
Some(document_id.to_string()),
|
||||
);
|
||||
return buffer_store.get(&key);
|
||||
}
|
||||
if let Some(document_id) = query.document_id.as_deref().filter(|v| !v.is_empty()) {
|
||||
// 最后按 documentId 扫描,作为调试和兼容查询兜底。
|
||||
return buffer_store.all_buffers().into_iter().find(|buf| {
|
||||
buf.workspace_path.object_identity.document_id.as_deref() == Some(document_id)
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocumentSaveRequest {
|
||||
@@ -520,6 +579,42 @@ pub async fn content(
|
||||
Ok(ok_response(&context, result))
|
||||
}
|
||||
|
||||
/// 查询指定文档的当前 BufferStore 状态。
|
||||
///
|
||||
/// 支持两种查询方式:
|
||||
/// 1. 通过 documentId + sourceKind + rootUri 自动构造 workspace path
|
||||
/// 2. 通过 documentId + workspaceId + relativePath 精确查询
|
||||
pub async fn buffer_state(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<BufferStateQuery>,
|
||||
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
|
||||
let result = find_document_buffer_state(&state.buffer_store, &query);
|
||||
|
||||
match result {
|
||||
Some(buf) => Ok(ok_response(
|
||||
&context,
|
||||
json!({
|
||||
"documentId": buf.workspace_path.object_identity.document_id,
|
||||
"workspaceId": buf.workspace_path.workspace_id,
|
||||
"dirtyState": format!("{:?}", buf.dirty_state),
|
||||
"fileVersion": buf.file_version,
|
||||
"baseContentHash": buf.base_content_hash,
|
||||
"currentContentHash": buf.current_content_hash,
|
||||
"externalActor": buf.external_actor,
|
||||
"lastLoadedAt": buf.last_loaded_at,
|
||||
"lastSavedAt": buf.last_saved_at,
|
||||
}),
|
||||
)),
|
||||
None => Err(WebError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
"buffer_state_not_found",
|
||||
"未找到该文档的 buffer 状态",
|
||||
)
|
||||
.with_context(&context)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn page_body_write(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -1001,6 +1096,7 @@ pub async fn options(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use crate::document_buffer_store::{build_local_folder_workspace_path, BufferStore};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
|
||||
use serde_json::Value;
|
||||
@@ -1103,6 +1199,30 @@ mod tests {
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_buffer_state_falls_back_to_document_id_without_relative_path() {
|
||||
let store = BufferStore::new();
|
||||
let ws_path = build_local_folder_workspace_path(
|
||||
"ws_local",
|
||||
"file:///tmp/mnote-root",
|
||||
"docs/page.md",
|
||||
"local-md:docs~2Fpage.md",
|
||||
);
|
||||
store.init_buffer(&ws_path, Some("version-1".into()), Some("hash-1".into()));
|
||||
|
||||
let query = super::BufferStateQuery {
|
||||
document_id: Some("local-md:docs~2Fpage.md".into()),
|
||||
workspace_id: Some("ws_local".into()),
|
||||
source_kind: Some("local_folder".into()),
|
||||
root_uri: Some("file:///tmp/mnote-root".into()),
|
||||
relative_path: None,
|
||||
};
|
||||
|
||||
let result = super::find_document_buffer_state(&store, &query).expect("buffer state");
|
||||
assert_eq!(result.file_version.as_deref(), Some("version-1"));
|
||||
assert_eq!(result.workspace_path.relative_path, "docs/page.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convex_auth_cookie_does_not_force_next_proxy() {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
@@ -2127,6 +2127,22 @@ pub fn save_local_markdown_page(
|
||||
document_id: &str,
|
||||
expected_conflict_detection_key: Option<&str>,
|
||||
content: &Value,
|
||||
) -> Result<Value, WebError> {
|
||||
save_local_markdown_page_inner(
|
||||
root_uri,
|
||||
document_id,
|
||||
expected_conflict_detection_key,
|
||||
content,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn save_local_markdown_page_inner(
|
||||
root_uri: &str,
|
||||
document_id: &str,
|
||||
expected_conflict_detection_key: Option<&str>,
|
||||
content: &Value,
|
||||
buffer_store: Option<&crate::document_buffer_store::BufferStore>,
|
||||
) -> Result<Value, WebError> {
|
||||
let root_path = parse_file_root_uri(root_uri)?;
|
||||
let canonical_root = root_path.canonicalize().map_err(|error| {
|
||||
@@ -2143,6 +2159,13 @@ pub fn save_local_markdown_page(
|
||||
"找不到要保存的本地 Markdown 页面",
|
||||
)
|
||||
})?;
|
||||
let workspace_id = local_workspace_id(&canonical_root);
|
||||
let relative_path_for_buffer = markdown_file
|
||||
.path
|
||||
.strip_prefix(&canonical_root)
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
let current = fs::read_to_string(&markdown_file.path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_markdown_read_failed",
|
||||
@@ -2159,11 +2182,21 @@ pub fn save_local_markdown_page(
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if expected_key != current_conflict_key {
|
||||
let buffer_state = buffer_store.and_then(|store| {
|
||||
let ws_path = crate::document_buffer_store::build_local_folder_workspace_path(
|
||||
&workspace_id,
|
||||
root_uri,
|
||||
&relative_path_for_buffer,
|
||||
document_id,
|
||||
);
|
||||
store.get_by_path(&ws_path)
|
||||
});
|
||||
return Err(local_markdown_conflict_error(
|
||||
root_uri,
|
||||
document_id,
|
||||
expected_key,
|
||||
¤t_conflict_key,
|
||||
buffer_state.as_ref(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -2183,13 +2216,6 @@ pub fn save_local_markdown_page(
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let workspace_id = local_workspace_id(&canonical_root);
|
||||
let relative_path_for_buffer = markdown_file
|
||||
.path
|
||||
.strip_prefix(&canonical_root)
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
refresh_local_search_index_best_effort(&canonical_root, root_uri, &workspace_id);
|
||||
let next_conflict_key =
|
||||
local_markdown_conflict_detection_key(document_id, &markdown_file.path)?;
|
||||
@@ -2216,19 +2242,18 @@ fn local_markdown_conflict_error(
|
||||
document_id: &str,
|
||||
editor_base_version: &str,
|
||||
current_disk_version: &str,
|
||||
buffer_state: Option<&core_protocol::DocumentBuffer>,
|
||||
) -> WebError {
|
||||
WebError::new(
|
||||
StatusCode::CONFLICT,
|
||||
"local_markdown_external_change",
|
||||
"本地 Markdown 文件已被外部修改,请刷新后再保存",
|
||||
)
|
||||
.with_details(json!({
|
||||
let mut details = json!({
|
||||
"conflict": {
|
||||
"code": "local_markdown_external_change",
|
||||
"documentId": document_id,
|
||||
"rootUri": root_uri,
|
||||
"currentDiskVersion": current_disk_version,
|
||||
"editorBaseVersion": editor_base_version,
|
||||
"externalActor": Value::Null,
|
||||
"dirtyState": Value::Null,
|
||||
"bufferFileVersion": Value::Null,
|
||||
"suggestedActions": [
|
||||
"accept_disk",
|
||||
"keep_editor",
|
||||
@@ -2236,7 +2261,20 @@ fn local_markdown_conflict_error(
|
||||
"merge"
|
||||
]
|
||||
}
|
||||
}))
|
||||
});
|
||||
if let Some(buf) = buffer_state {
|
||||
if let Some(conflict) = details.get_mut("conflict").and_then(Value::as_object_mut) {
|
||||
conflict.insert("externalActor".into(), json!(&buf.external_actor));
|
||||
conflict.insert("dirtyState".into(), json!(format!("{:?}", buf.dirty_state)));
|
||||
conflict.insert("bufferFileVersion".into(), json!(&buf.file_version));
|
||||
}
|
||||
}
|
||||
WebError::new(
|
||||
StatusCode::CONFLICT,
|
||||
"local_markdown_external_change",
|
||||
"本地 Markdown 文件已被外部修改,请刷新后再保存",
|
||||
)
|
||||
.with_details(details)
|
||||
}
|
||||
|
||||
pub fn write_local_markdown_page_body(
|
||||
@@ -2256,12 +2294,22 @@ pub fn write_local_markdown_page_body(
|
||||
));
|
||||
}
|
||||
|
||||
let mut result = save_local_markdown_page(
|
||||
&request.root_uri,
|
||||
&request.document_id,
|
||||
request.expected_file_version.as_deref(),
|
||||
&request.content,
|
||||
)?;
|
||||
let mut result = if buffer_store.is_some() {
|
||||
save_local_markdown_page_inner(
|
||||
&request.root_uri,
|
||||
&request.document_id,
|
||||
request.expected_file_version.as_deref(),
|
||||
&request.content,
|
||||
buffer_store,
|
||||
)?
|
||||
} else {
|
||||
save_local_markdown_page(
|
||||
&request.root_uri,
|
||||
&request.document_id,
|
||||
request.expected_file_version.as_deref(),
|
||||
&request.content,
|
||||
)?
|
||||
};
|
||||
|
||||
// 保存成功后更新 BufferStore
|
||||
if let Some(store) = buffer_store {
|
||||
@@ -7843,6 +7891,55 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_save_fails_with_error_code_on_readonly_file() {
|
||||
let root = temp_root("mnote-local-markdown-save-readonly-error");
|
||||
std::fs::write(
|
||||
root.join("README.md"),
|
||||
"---\ntitle: Error Test\n---\n# Original\n",
|
||||
)
|
||||
.expect("write md");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
// 把文件设为只读,使 fs::write 失败。
|
||||
let mut perms = std::fs::metadata(root.join("README.md"))
|
||||
.expect("metadata")
|
||||
.permissions();
|
||||
perms.set_readonly(true);
|
||||
std::fs::set_permissions(root.join("README.md"), perms).expect("set readonly");
|
||||
|
||||
let error = save_local_markdown_page(
|
||||
&root_uri,
|
||||
"local-md:README.md",
|
||||
None,
|
||||
&serde_json::json!([
|
||||
{"type":"paragraph","content":[{"type":"text","text":"新内容","styles":{}}]}
|
||||
]),
|
||||
)
|
||||
.expect_err("save 应在只读文件上失败");
|
||||
assert_eq!(
|
||||
error.code(),
|
||||
"local_markdown_write_failed",
|
||||
"错误码应为 local_markdown_write_failed,实际: {}",
|
||||
error.code()
|
||||
);
|
||||
assert!(
|
||||
error.message().contains("无法保存本地 Markdown 文件"),
|
||||
"错误信息应包含中文解释和文件路径,实际: {}",
|
||||
error.message()
|
||||
);
|
||||
|
||||
// 验证磁盘上的原始内容未被清除。
|
||||
let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
|
||||
assert!(
|
||||
saved.contains("# Original"),
|
||||
"原文应保留,实际内容: {}",
|
||||
saved
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_save_writes_table_inline_marks() {
|
||||
let root = temp_root("mnote-local-markdown-save-table-inline-marks");
|
||||
@@ -7930,6 +8027,50 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_save_table_handles_colspan_degradation() {
|
||||
// Tiptap table cell 可能包含 colspan/rowspan 等 GFM pipe table
|
||||
// 无法表达的结构。降级策略:提取文本内容,忽略合并单元格属性,
|
||||
// 输出标准 pipe table,不丢文字。
|
||||
let root = temp_root("mnote-local-markdown-save-table-colspan");
|
||||
std::fs::write(root.join("README.md"), "---\ntitle: Colspan\n---\n# Old\n")
|
||||
.expect("write md");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
|
||||
// 第二行第二个单元格有 colspan=2,GFM pipe table 不支持,应降级为普通单元格。
|
||||
save_local_markdown_page(
|
||||
&root_uri,
|
||||
"local-md:README.md",
|
||||
None,
|
||||
&serde_json::json!([
|
||||
{"type":"table","props":{"tiptapTable":{"type":"table","content":[
|
||||
{"type":"tableRow","content":[
|
||||
{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"A","styles":{}}]}]},
|
||||
{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"B","styles":{}}]}]},
|
||||
{"type":"tableHeader","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"C","styles":{}}]}]}
|
||||
]},
|
||||
{"type":"tableRow","content":[
|
||||
{"type":"tableCell","attrs":{"colspan":1,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"x","styles":{}}]}]},
|
||||
{"type":"tableCell","attrs":{"colspan":2,"rowspan":1,"colwidth":null},"content":[{"type":"paragraph","content":[{"type":"text","text":"y+z","styles":{}}]}]}
|
||||
]}
|
||||
]}}}
|
||||
]),
|
||||
)
|
||||
.expect("save");
|
||||
|
||||
let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
|
||||
// 降级后 colspan=2 的单元格变成普通单列,GFM 表头行 3 列但数据行 2 列 → 右列留空。
|
||||
// 重点:文字不丢。
|
||||
assert!(
|
||||
saved.contains("y+z"),
|
||||
"colspan 单元格文字不应丢失: {}",
|
||||
saved
|
||||
);
|
||||
// 不会 panic,表格行数对齐由 editor_block_table_to_markdown 的 padding 逻辑保证。
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_save_writes_image_blocks_as_markdown_images() {
|
||||
let root = temp_root("mnote-local-markdown-save-image-block");
|
||||
|
||||
@@ -193,6 +193,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
.route("/api/documents/meta", get(documents::meta))
|
||||
.route("/api/documents/content", get(documents::content))
|
||||
.route("/api/documents/buffer-state", get(documents::buffer_state))
|
||||
.route("/api/documents/purge", post(documents::purge))
|
||||
.route("/api/documents/empty-trash", post(documents::empty_trash))
|
||||
.route("/api/documents/title", post(documents::title))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::app::{AppConfig, AppState};
|
||||
use crate::context::RequestContext;
|
||||
use crate::document_buffer_store::{self};
|
||||
use crate::error::WebError;
|
||||
use crate::page_aggregate::PageAggregate;
|
||||
use crate::routes::documents::{
|
||||
@@ -69,6 +70,25 @@ pub async fn document_page_shell(
|
||||
primary_root_uri,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 初始化 BufferStore:local_folder 文档打开时记录 file_version
|
||||
if primary_source_kind == Some("local_folder") {
|
||||
if let Some(root_uri) = primary_root_uri {
|
||||
let relative_path = document_id
|
||||
.strip_prefix("local-md:")
|
||||
.unwrap_or("")
|
||||
.replace("~2F", "/");
|
||||
let file_version = aggregate.body.file_version.as_str().map(|s| s.to_string());
|
||||
let ws_path = document_buffer_store::build_local_folder_workspace_path(
|
||||
&aggregate.identity.workspace_id,
|
||||
root_uri,
|
||||
&relative_path,
|
||||
&document_id,
|
||||
);
|
||||
state.buffer_store.init_buffer(&ws_path, file_version, None);
|
||||
}
|
||||
}
|
||||
|
||||
let title = aggregate.head.title.as_str();
|
||||
let workspace_id = aggregate.identity.workspace_id.clone();
|
||||
let requested_secondary_document_id =
|
||||
@@ -1552,6 +1572,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
workspaceId: session.workspaceId,
|
||||
viewCount: session.views.size,
|
||||
status: session.status,
|
||||
lastExternalConflictEnvelope: session.lastExternalConflictEnvelope || null,
|
||||
dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'),
|
||||
})),
|
||||
localFolderChannelCount: localFolderEventRegistry.size,
|
||||
localFolderRoots: Array.from(localFolderEventRegistry.keys()),
|
||||
@@ -1782,6 +1804,25 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
return sourceLabel ? `${baseMessage}(来源:${sourceLabel})` : baseMessage;
|
||||
};
|
||||
|
||||
const ensureSessionConflictEnvelope = (session, message) => {
|
||||
if (!session) return null;
|
||||
if (session.lastExternalConflictEnvelope) return session.lastExternalConflictEnvelope;
|
||||
const envelope = {
|
||||
code: 'local_markdown_external_change',
|
||||
documentId: session.documentId,
|
||||
rootUri: session.rootUri || null,
|
||||
currentDiskVersion: session.conflictDetectionKey || session.fileVersion || null,
|
||||
editorBaseVersion: session.lastExternalConflictDetectionKey || session.conflictDetectionKey || session.fileVersion || null,
|
||||
externalActor: session.lastExternalWriteSource || null,
|
||||
dirtyState: session.dirty ? 'Dirty' : (session.hasExternalConflict ? 'ExternalModified' : 'Clean'),
|
||||
bufferFileVersion: session.fileVersion || session.conflictDetectionKey || null,
|
||||
message: message || externalConflictMessage,
|
||||
suggestedActions: ['accept_disk', 'keep_editor', 'open_diff', 'merge'],
|
||||
};
|
||||
session.lastExternalConflictEnvelope = envelope;
|
||||
return envelope;
|
||||
};
|
||||
|
||||
const clearSessionConflictSurface = (session) => {
|
||||
sessionViews(session).forEach((view) => {
|
||||
const host = view.runtimeDescriptor.root.closest('.document-pane') || view.runtimeDescriptor.root;
|
||||
@@ -2047,6 +2088,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
|
||||
const markSessionExternalConflict = (session, message) => {
|
||||
session.externalChangePending = false;
|
||||
ensureSessionConflictEnvelope(session, message);
|
||||
session.hasExternalConflict = true;
|
||||
if (session.saveTimer) {
|
||||
window.clearTimeout(session.saveTimer);
|
||||
|
||||
Reference in New Issue
Block a user