chore: align mvp design governance
- 统一 local-first MVP 后阶段架构口径,补充 process 执行总序和 Reasonix 协作记录 - 归档已完成的 design checklist,标注参考型 process,更新 AGENTS/REASONIX/架构文档 - 补充文件树/主编辑器下载与上下文菜单相关实现、bug 记录和 smoke 脚本 验证:git diff --check;codegraph sync .;cargo test -p mnote-web;node --check scripts/task476-filetree-editor-context-menu-download-smoke.js
This commit is contained in:
@@ -204,6 +204,7 @@ struct LocalAssetUploadFields {
|
||||
file: LocalUploadFile,
|
||||
root_uri: String,
|
||||
document_id: String,
|
||||
target_relative_path: Option<String>,
|
||||
kind: String,
|
||||
}
|
||||
|
||||
@@ -2314,12 +2315,21 @@ pub async fn upload_local_markdown_asset(
|
||||
let fields = read_local_asset_upload_multipart(multipart).await?;
|
||||
ensure_local_workspace_access(&context, &fields.root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let asset = write_local_markdown_asset(
|
||||
&fields.root_uri,
|
||||
&fields.document_id,
|
||||
&fields.kind,
|
||||
fields.file,
|
||||
)?;
|
||||
let asset = if fields.target_relative_path.is_some() || fields.document_id.trim().is_empty() {
|
||||
write_local_folder_file_upload(
|
||||
&fields.root_uri,
|
||||
fields.target_relative_path.as_deref().unwrap_or(""),
|
||||
&fields.kind,
|
||||
fields.file,
|
||||
)?
|
||||
} else {
|
||||
write_local_markdown_asset(
|
||||
&fields.root_uri,
|
||||
&fields.document_id,
|
||||
&fields.kind,
|
||||
fields.file,
|
||||
)?
|
||||
};
|
||||
Ok((StatusCode::OK, Json(json!({ "ok": true, "asset": asset }))))
|
||||
}
|
||||
|
||||
@@ -2329,7 +2339,33 @@ pub async fn open_local_file(
|
||||
) -> Result<(StatusCode, HeaderMap, Vec<u8>), WebError> {
|
||||
ensure_local_workspace_read_access(&context, &query.root_uri)
|
||||
.map_err(|error| error.with_context(&context))?;
|
||||
let target = resolve_local_file_open_path(&query.root_uri, &query.path)?;
|
||||
let target = resolve_local_open_path(&query.root_uri, &query.path)?;
|
||||
let mut headers = HeaderMap::new();
|
||||
if target.is_dir() {
|
||||
if !query.download.unwrap_or(false) {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_file_open_is_directory",
|
||||
"不能直接读取本地目录",
|
||||
)
|
||||
.with_context(&context));
|
||||
}
|
||||
let bytes = build_local_directory_tar_archive(&target).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_directory_download_failed",
|
||||
format!("无法打包本地目录 {}: {error}", target.display()),
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/x-tar"),
|
||||
);
|
||||
headers.insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
content_disposition_attachment_for_filename(&local_directory_tar_filename(&target)),
|
||||
);
|
||||
return Ok((StatusCode::OK, headers, bytes));
|
||||
}
|
||||
let bytes = fs::read(&target).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_file_open_read_failed",
|
||||
@@ -2337,12 +2373,11 @@ pub async fn open_local_file(
|
||||
)
|
||||
.with_context(&context)
|
||||
})?;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::CONTENT_TYPE, content_type_for_path(&target));
|
||||
if query.download.unwrap_or(false) {
|
||||
headers.insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
HeaderValue::from_static("attachment"),
|
||||
content_disposition_attachment_for_path(&target),
|
||||
);
|
||||
}
|
||||
Ok((StatusCode::OK, headers, bytes))
|
||||
@@ -2485,7 +2520,7 @@ pub async fn write_local_resource(
|
||||
))
|
||||
}
|
||||
|
||||
fn resolve_local_file_open_path(root_uri: &str, relative_path: &str) -> Result<PathBuf, WebError> {
|
||||
fn resolve_local_open_path(root_uri: &str, relative_path: &str) -> Result<PathBuf, WebError> {
|
||||
let root_path = parse_file_root_uri(root_uri)?;
|
||||
let canonical_root = root_path.canonicalize().map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
@@ -2519,6 +2554,11 @@ fn resolve_local_file_open_path(root_uri: &str, relative_path: &str) -> Result<P
|
||||
"本地文件路径不能越过 root",
|
||||
));
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
fn resolve_local_file_open_path(root_uri: &str, relative_path: &str) -> Result<PathBuf, WebError> {
|
||||
let target = resolve_local_open_path(root_uri, relative_path)?;
|
||||
if target.is_dir() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_file_open_is_directory",
|
||||
@@ -2534,6 +2574,7 @@ async fn read_local_asset_upload_multipart(
|
||||
let mut file: Option<LocalUploadFile> = None;
|
||||
let mut root_uri = String::new();
|
||||
let mut document_id = String::new();
|
||||
let mut target_relative_path: Option<String> = None;
|
||||
let mut kind = String::new();
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|error| {
|
||||
@@ -2583,6 +2624,9 @@ async fn read_local_asset_upload_multipart(
|
||||
match name.as_str() {
|
||||
"rootUri" => root_uri = value.trim().to_string(),
|
||||
"documentId" => document_id = value.trim().to_string(),
|
||||
"targetRelativePath" | "targetDirectoryPath" => {
|
||||
target_relative_path = Some(value.trim().to_string())
|
||||
}
|
||||
"kind" => kind = value.trim().to_string(),
|
||||
_ => {}
|
||||
}
|
||||
@@ -2591,7 +2635,10 @@ async fn read_local_asset_upload_multipart(
|
||||
let file = file.ok_or_else(|| {
|
||||
WebError::bad_request_code("local_asset_upload_file_missing", "缺少 file")
|
||||
})?;
|
||||
if file.bytes.is_empty() || root_uri.is_empty() || document_id.is_empty() {
|
||||
if file.bytes.is_empty()
|
||||
|| root_uri.is_empty()
|
||||
|| (document_id.is_empty() && target_relative_path.is_none())
|
||||
{
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_asset_upload_required_missing",
|
||||
"缺少必要参数",
|
||||
@@ -2601,10 +2648,85 @@ async fn read_local_asset_upload_multipart(
|
||||
file,
|
||||
root_uri,
|
||||
document_id,
|
||||
target_relative_path,
|
||||
kind,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn write_local_folder_file_upload(
|
||||
root_uri: &str,
|
||||
target_relative_path: &str,
|
||||
kind: &str,
|
||||
file: LocalUploadFile,
|
||||
) -> Result<Value, WebError> {
|
||||
let root_path = parse_file_root_uri(root_uri)?;
|
||||
let canonical_root = root_path.canonicalize().map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_folder_unavailable",
|
||||
format!("无法访问本地文件夹: {error}"),
|
||||
)
|
||||
})?;
|
||||
if !canonical_root.is_dir() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_folder_not_directory",
|
||||
"本地 rootUri 必须指向目录",
|
||||
));
|
||||
}
|
||||
let target_relative_path = target_relative_path.trim();
|
||||
let requested = if target_relative_path.is_empty() {
|
||||
Path::new(".")
|
||||
} else {
|
||||
Path::new(target_relative_path)
|
||||
};
|
||||
if requested.is_absolute()
|
||||
|| requested
|
||||
.components()
|
||||
.any(|component| matches!(component, std::path::Component::ParentDir))
|
||||
{
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_folder_upload_root_escape",
|
||||
"上传目标目录不能越过 root",
|
||||
));
|
||||
}
|
||||
let target_dir = canonical_root
|
||||
.join(requested)
|
||||
.canonicalize()
|
||||
.map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_folder_upload_target_missing",
|
||||
format!("找不到上传目标目录: {error}"),
|
||||
)
|
||||
})?;
|
||||
if !target_dir.starts_with(&canonical_root) || !target_dir.is_dir() {
|
||||
return Err(WebError::bad_request_code(
|
||||
"local_folder_upload_target_invalid",
|
||||
"上传目标必须是本地 root 内的目录",
|
||||
));
|
||||
}
|
||||
let sanitized_name = sanitize_file_name(&file.name, "附件");
|
||||
let target = next_available_raw_path(&target_dir, &sanitized_name);
|
||||
fs::write(&target, &file.bytes).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_folder_upload_write_failed",
|
||||
format!("无法写入本地文件 {}: {error}", target.display()),
|
||||
)
|
||||
})?;
|
||||
let root_relative_path = normalize_relative_path(&canonical_root, &target)?;
|
||||
let asset_type = local_upload_asset_type(kind, &file.content_type);
|
||||
Ok(json!({
|
||||
"id": format!("local-file:{root_relative_path}"),
|
||||
"asset_type": asset_type,
|
||||
"file_name": target.file_name().and_then(|value| value.to_str()).unwrap_or(&sanitized_name),
|
||||
"mime_type": file.content_type,
|
||||
"file_size": file.bytes.len(),
|
||||
"file_url": root_relative_path,
|
||||
"sourcePath": root_relative_path,
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": file_uri_for_path(&canonical_root),
|
||||
"rootRelativePath": root_relative_path,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn write_local_markdown_asset(
|
||||
root_uri: &str,
|
||||
document_id: &str,
|
||||
@@ -6532,6 +6654,217 @@ fn content_type_for_path(path: &Path) -> HeaderValue {
|
||||
HeaderValue::from_static(content_type)
|
||||
}
|
||||
|
||||
fn percent_encode_content_disposition_filename(value: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
for byte in value.as_bytes() {
|
||||
match *byte {
|
||||
b'A'..=b'Z'
|
||||
| b'a'..=b'z'
|
||||
| b'0'..=b'9'
|
||||
| b'!'
|
||||
| b'#'
|
||||
| b'$'
|
||||
| b'&'
|
||||
| b'+'
|
||||
| b'-'
|
||||
| b'.'
|
||||
| b'^'
|
||||
| b'_'
|
||||
| b'`'
|
||||
| b'|'
|
||||
| b'~' => encoded.push(*byte as char),
|
||||
_ => encoded.push_str(&format!("%{byte:02X}")),
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn content_disposition_attachment_for_path(path: &Path) -> HeaderValue {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("download");
|
||||
content_disposition_attachment_for_filename(filename)
|
||||
}
|
||||
|
||||
fn content_disposition_attachment_for_filename(filename: &str) -> HeaderValue {
|
||||
let filename = filename.trim();
|
||||
let filename = if filename.is_empty() {
|
||||
"download"
|
||||
} else {
|
||||
filename
|
||||
};
|
||||
let ascii_fallback = filename
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | ' ') {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
let ascii_fallback = if ascii_fallback.trim().is_empty() {
|
||||
"download".to_string()
|
||||
} else {
|
||||
ascii_fallback
|
||||
};
|
||||
let quoted = ascii_fallback.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let encoded = percent_encode_content_disposition_filename(filename);
|
||||
HeaderValue::from_str(&format!(
|
||||
"attachment; filename=\"{quoted}\"; filename*=UTF-8''{encoded}"
|
||||
))
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("attachment"))
|
||||
}
|
||||
|
||||
fn local_directory_tar_filename(path: &Path) -> String {
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("folder");
|
||||
format!("{name}.tar")
|
||||
}
|
||||
|
||||
fn tar_octal(value: u64, width: usize) -> Vec<u8> {
|
||||
let mut encoded = format!("{value:o}").into_bytes();
|
||||
let max_digits = width.saturating_sub(1);
|
||||
if encoded.len() > max_digits {
|
||||
encoded = vec![b'7'; max_digits];
|
||||
}
|
||||
let mut out = vec![b'0'; max_digits.saturating_sub(encoded.len())];
|
||||
out.extend(encoded);
|
||||
out.push(0);
|
||||
out
|
||||
}
|
||||
|
||||
fn split_tar_path(path: &str) -> Result<(&str, &str), std::io::Error> {
|
||||
let bytes = path.as_bytes();
|
||||
if bytes.len() <= 100 {
|
||||
return Ok(("", path));
|
||||
}
|
||||
let mut best: Option<usize> = None;
|
||||
for (index, ch) in path.char_indices() {
|
||||
if ch == '/' {
|
||||
let prefix_len = path[..index].as_bytes().len();
|
||||
let name_len = path[index + 1..].as_bytes().len();
|
||||
if prefix_len <= 155 && name_len <= 100 {
|
||||
best = Some(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(index) = best {
|
||||
return Ok((&path[..index], &path[index + 1..]));
|
||||
}
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"tar entry path is too long",
|
||||
))
|
||||
}
|
||||
|
||||
fn append_tar_header(
|
||||
out: &mut Vec<u8>,
|
||||
path: &str,
|
||||
size: u64,
|
||||
typeflag: u8,
|
||||
mode: u64,
|
||||
mtime: u64,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let normalized = path.trim_start_matches('/').replace('\\', "/");
|
||||
let (prefix, name) = split_tar_path(&normalized)?;
|
||||
let mut header = [0u8; 512];
|
||||
header[0..name.as_bytes().len()].copy_from_slice(name.as_bytes());
|
||||
let mode = tar_octal(mode, 8);
|
||||
header[100..108].copy_from_slice(&mode);
|
||||
header[108..116].copy_from_slice(&tar_octal(0, 8));
|
||||
header[116..124].copy_from_slice(&tar_octal(0, 8));
|
||||
header[124..136].copy_from_slice(&tar_octal(size, 12));
|
||||
header[136..148].copy_from_slice(&tar_octal(mtime, 12));
|
||||
header[148..156].fill(b' ');
|
||||
header[156] = typeflag;
|
||||
header[257..263].copy_from_slice(b"ustar\0");
|
||||
header[263..265].copy_from_slice(b"00");
|
||||
if !prefix.is_empty() {
|
||||
header[345..345 + prefix.as_bytes().len()].copy_from_slice(prefix.as_bytes());
|
||||
}
|
||||
let checksum = header.iter().map(|byte| u32::from(*byte)).sum::<u32>();
|
||||
let checksum_bytes = format!("{checksum:06o}\0 ").into_bytes();
|
||||
header[148..156].copy_from_slice(&checksum_bytes);
|
||||
out.extend_from_slice(&header);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_tar_padding(out: &mut Vec<u8>, size: usize) {
|
||||
let remainder = size % 512;
|
||||
if remainder > 0 {
|
||||
out.extend(std::iter::repeat(0).take(512 - remainder));
|
||||
}
|
||||
}
|
||||
|
||||
fn append_local_directory_tar_entries(
|
||||
out: &mut Vec<u8>,
|
||||
root: &Path,
|
||||
current: &Path,
|
||||
base_name: &str,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let metadata = fs::symlink_metadata(current)?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Ok(());
|
||||
}
|
||||
let relative = current.strip_prefix(root).unwrap_or(current);
|
||||
let mut entry_name = if relative.as_os_str().is_empty() {
|
||||
base_name.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{base_name}/{}",
|
||||
relative.to_string_lossy().replace('\\', "/")
|
||||
)
|
||||
};
|
||||
let mtime = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
if metadata.is_dir() {
|
||||
if !entry_name.ends_with('/') {
|
||||
entry_name.push('/');
|
||||
}
|
||||
append_tar_header(out, &entry_name, 0, b'5', 0o755, mtime)?;
|
||||
let mut children = fs::read_dir(current)?.collect::<Result<Vec<_>, _>>()?;
|
||||
children.sort_by_key(|entry| entry.file_name());
|
||||
for child in children {
|
||||
append_local_directory_tar_entries(out, root, &child.path(), base_name)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if metadata.is_file() {
|
||||
append_tar_header(out, &entry_name, metadata.len(), b'0', 0o644, mtime)?;
|
||||
let bytes = fs::read(current)?;
|
||||
out.extend_from_slice(&bytes);
|
||||
append_tar_padding(out, bytes.len());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_local_directory_tar_archive(directory: &Path) -> Result<Vec<u8>, std::io::Error> {
|
||||
let canonical = directory.canonicalize()?;
|
||||
let base_name = canonical
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("folder")
|
||||
.to_string();
|
||||
let mut out = Vec::new();
|
||||
append_local_directory_tar_entries(&mut out, &canonical, &canonical, &base_name)?;
|
||||
out.extend_from_slice(&[0u8; 1024]);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn editor_block_table_to_markdown(block: &Value) -> String {
|
||||
let table = block
|
||||
.get("props")
|
||||
@@ -8873,6 +9206,90 @@ fn main() {}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_file_open_download_sets_content_disposition_filename() {
|
||||
let root = temp_root("mnote-local-file-open-download-filename");
|
||||
init_workspace(&root);
|
||||
std::fs::write(root.join("报告 2026.docx"), b"docx").expect("write docx");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-mnote-actor-id", "user_test".parse().unwrap());
|
||||
headers.insert("x-mnote-actor-type", "user".parse().unwrap());
|
||||
let context = RequestContext::from_http_parts(
|
||||
&Method::GET,
|
||||
&"/api/local-folder/files/open".parse().expect("uri"),
|
||||
&headers,
|
||||
);
|
||||
|
||||
let (_, response_headers, _) = open_local_file(
|
||||
Extension(context),
|
||||
Query(LocalFileOpenQuery {
|
||||
root_uri,
|
||||
path: "报告 2026.docx".into(),
|
||||
download: Some(true),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("download local file");
|
||||
|
||||
let disposition = response_headers
|
||||
.get(axum::http::header::CONTENT_DISPOSITION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("content disposition");
|
||||
assert!(disposition.starts_with("attachment;"));
|
||||
assert!(disposition.contains("filename=\"__ 2026.docx\""));
|
||||
assert!(
|
||||
disposition.contains("filename*=UTF-8''%E6%8A%A5%E5%91%8A%202026.docx"),
|
||||
"{disposition}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_folder_open_downloads_directory_as_tar() {
|
||||
let root = temp_root("mnote-local-folder-open-download-directory");
|
||||
init_workspace(&root);
|
||||
let reports = root.join("Reports");
|
||||
std::fs::create_dir_all(&reports).expect("create reports");
|
||||
std::fs::write(reports.join("summary.txt"), b"summary").expect("write summary");
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-mnote-actor-id", "user_test".parse().unwrap());
|
||||
headers.insert("x-mnote-actor-type", "user".parse().unwrap());
|
||||
let context = RequestContext::from_http_parts(
|
||||
&Method::GET,
|
||||
&"/api/local-folder/files/open".parse().expect("uri"),
|
||||
&headers,
|
||||
);
|
||||
|
||||
let (_, response_headers, bytes) = open_local_file(
|
||||
Extension(context),
|
||||
Query(LocalFileOpenQuery {
|
||||
root_uri,
|
||||
path: "Reports".into(),
|
||||
download: Some(true),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("download local directory");
|
||||
|
||||
assert_eq!(
|
||||
response_headers
|
||||
.get(axum::http::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("application/x-tar")
|
||||
);
|
||||
let disposition = response_headers
|
||||
.get(axum::http::header::CONTENT_DISPOSITION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.expect("content disposition");
|
||||
assert!(disposition.contains("filename=\"Reports.tar\""));
|
||||
assert!(bytes.windows(5).any(|window| window == b"ustar"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_file_tree_classifies_mindmap_and_office_assets() {
|
||||
let root = temp_root("mnote-local-filetree-resource-kinds");
|
||||
|
||||
@@ -799,7 +799,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
if (typeof runtime.default !== 'function' || typeof runtime.mount !== 'function' || typeof runtime.unmount !== 'function') {
|
||||
throw new Error('island runtime 导出不完整');
|
||||
}
|
||||
await runtime.default(wasmUrl);
|
||||
await runtime.default({ module_or_path: wasmUrl });
|
||||
if (typeof runtime.mount_mindmap_shell === 'function' && typeof runtime.unmount_mindmap_shell === 'function') {
|
||||
window.__MNOTE_MINDMAP_RUST_SHELL__ = {
|
||||
mount: runtime.mount_mindmap_shell,
|
||||
@@ -3260,6 +3260,14 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
}
|
||||
};
|
||||
|
||||
const currentWebShellDocumentId = () => {
|
||||
const fromBody = document.body?.dataset?.documentId || '';
|
||||
if (fromBody) return String(fromBody).trim();
|
||||
const pageTab = document.querySelector('[data-mnote-main-tab="page"]');
|
||||
if (pageTab instanceof HTMLElement) return String(pageTab.getAttribute('data-document-id') || '').trim();
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeResourceTabKind = (input) => {
|
||||
const kind = String(input?.kind || '').trim().toLowerCase();
|
||||
const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase();
|
||||
@@ -3365,7 +3373,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
: '页面';
|
||||
const pageEntry = {
|
||||
objectIdentity: 'page',
|
||||
documentId: String(nodes.pageTab?.getAttribute?.('data-document-id') || currentDocumentId() || '').trim(),
|
||||
documentId: String(nodes.pageTab?.getAttribute?.('data-document-id') || currentWebShellDocumentId() || '').trim(),
|
||||
workspaceId: String(nodes.pageTab?.getAttribute?.('data-workspace-id') || currentWebShellWorkspaceId() || '').trim(),
|
||||
title: pageTitle || '页面',
|
||||
kind: 'page',
|
||||
@@ -3772,7 +3780,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
};
|
||||
|
||||
const openMindmapResourceTab = async (entry, input) => {
|
||||
const documentId = String(input.documentId || currentDocumentId() || '').trim();
|
||||
const documentId = String(input.documentId || currentWebShellDocumentId() || '').trim();
|
||||
const mindmapId = String(input.mindmapId || input.assetId || '').trim();
|
||||
if (!documentId || !mindmapId) throw new Error('mindmap_resource_identity_missing');
|
||||
const targetUrl = new URL(`/mindmap/${encodeURIComponent(documentId)}/${encodeURIComponent(mindmapId)}`, window.location.origin);
|
||||
@@ -4645,6 +4653,10 @@ mod tests {
|
||||
assert!(html.contains("getOpenEditorsSnapshot"));
|
||||
assert!(html.contains("bindMainEditorTabStrip"));
|
||||
assert!(html.contains("data-mnote-tab-strip-bound"));
|
||||
assert!(html.contains("currentWebShellDocumentId"));
|
||||
assert!(!html
|
||||
.contains("nodes.pageTab?.getAttribute?.('data-document-id') || currentDocumentId()"));
|
||||
assert!(html.contains("runtime.default({ module_or_path: wasmUrl })"));
|
||||
assert!(html.contains("positionSlashMenuForRoot"));
|
||||
assert!(html.contains("menu.style.position = 'fixed';"));
|
||||
assert!(html.contains("installGlobalSlashMenuPositioning();"));
|
||||
|
||||
Reference in New Issue
Block a user