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:
lix-2026
2026-05-21 09:04:13 +08:00
parent fd94792ea6
commit 1956a8a21a
69 changed files with 3390 additions and 111 deletions
@@ -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");
+15 -3
View File
@@ -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();"));
+250 -11
View File
@@ -1764,6 +1764,18 @@ const SIDEBAR_TREE_JS: &str = r##"
return String(item && item.title || '').trim() || '';
}
function fileWorkspaceRelativePath(item) {
var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {};
var workspacePath = meta.workspacePath && typeof meta.workspacePath === 'object' ? meta.workspacePath : {};
var fromWorkspacePath = String(workspacePath.relativePath || '').trim();
if (fromWorkspacePath) return fromWorkspacePath;
var extra = meta.extra && typeof meta.extra === 'object' ? meta.extra : {};
var source = extra.source && typeof extra.source === 'object' ? extra.source : {};
var fromSource = String(source.relativePath || '').trim();
if (fromSource) return fromSource;
return String(item && (item.relativePath || item.rootRelativePath) || '').trim();
}
function groupRowsByParent(rows) {
var ids = new Set(rows.map(nodeIdOf).filter(Boolean));
var grouped = new Map();
@@ -1890,6 +1902,7 @@ const SIDEBAR_TREE_JS: &str = r##"
var parent = parentIdOf(item);
var documentId = fileDocumentId(item);
var assetId = fileAssetId(item);
var relativePath = fileWorkspaceRelativePath(item);
var objectIdentity = fileObjectIdentity(item);
var iconKind = iconKindOf(item);
var title = isFileTreeProjectionPageRow(rowKind, assetId)
@@ -1909,7 +1922,7 @@ const SIDEBAR_TREE_JS: &str = r##"
var childHtml = expandable
? '<ul class="tree-children' + (expanded ? '' : ' tree-children--collapsed') + '">' + renderFileRows(nodeId, grouped, activeId, activeRowId) + '</ul>'
: '';
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-object-identity="' + escapeHtml(objectIdentityAttr(objectIdentity)) + '" data-object-kind="' + escapeHtml(String(objectIdentity.objectKind || '')) + '" data-capabilities="' + escapeHtml(fileCapabilitiesAttr(item)) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKind) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作"></button></div></div>' + childHtml + '</li>';
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '" data-object-identity="' + escapeHtml(objectIdentityAttr(objectIdentity)) + '" data-object-kind="' + escapeHtml(String(objectIdentity.objectKind || '')) + '" data-capabilities="' + escapeHtml(fileCapabilitiesAttr(item)) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKind) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-local-relative-path="' + escapeHtml(relativePath) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作"></button></div></div>' + childHtml + '</li>';
}).join('');
}
@@ -2581,6 +2594,15 @@ const SIDEBAR_TREE_JS: &str = r##"
function fallbackFileTreeUploadTarget(detail) {
var workspaceId = String(detail && detail.workspaceId || resolveWorkspaceId(document.body) || '').trim();
var documentId = String(detail && detail.documentId || currentDocumentId() || '').trim();
if (detail && Object.prototype.hasOwnProperty.call(detail, 'targetRelativePath')) {
return {
workspaceId: workspaceId,
targetDocumentId: documentId,
targetMindmapId: null,
targetSubPath: null,
targetRelativePath: String(detail.targetRelativePath || '')
};
}
if (!workspaceId || !documentId) {
throw new Error('');
}
@@ -2593,8 +2615,15 @@ const SIDEBAR_TREE_JS: &str = r##"
}
async function resolveFileTreeUploadTarget(detail) {
if (detail && Object.prototype.hasOwnProperty.call(detail, 'targetRelativePath')) {
return fallbackFileTreeUploadTarget(detail || {});
}
try {
return await preflightFileTreeUploadTarget(detail || {});
var plan = await preflightFileTreeUploadTarget(detail || {});
if (detail && Object.prototype.hasOwnProperty.call(detail, 'targetRelativePath')) {
plan.targetRelativePath = String(detail.targetRelativePath || '');
}
return plan;
} catch (error) {
console.warn('[mnote upload] upload target preflight fallback', error);
return fallbackFileTreeUploadTarget(detail || {});
@@ -2869,6 +2898,16 @@ const SIDEBAR_TREE_JS: &str = r##"
var detail = detailFromEditorAttachmentLink(link);
var assetId = String(detail && detail.assetId || '').trim();
if (!assetId) return;
var localFilePath = localFilePathFromAssetId(assetId);
if (localFilePath) {
var localMeta = {
assetId: assetId,
fileSize: String(detail && detail.fileSize || '').trim()
};
attachmentMetaCache[assetId] = localMeta;
applyEditorAttachmentMeta(link, localMeta);
return;
}
if (attachmentMetaCache[assetId]) {
applyEditorAttachmentMeta(link, attachmentMetaCache[assetId]);
return;
@@ -3258,13 +3297,15 @@ const SIDEBAR_TREE_JS: &str = r##"
if (currentSourceKind() === 'local_folder') {
var rootUri = (new URLSearchParams(window.location.search).get('rootUri') || '').trim();
var documentId = String(plan && plan.targetDocumentId || currentDocumentId() || '').trim();
if (!rootUri || !documentId) {
var hasFolderTarget = plan && Object.prototype.hasOwnProperty.call(plan, 'targetRelativePath');
if (!rootUri || (!documentId && !hasFolderTarget)) {
throw new Error(' Markdown rootUri documentId');
}
var localForm = new FormData();
localForm.append('file', file);
localForm.append('rootUri', rootUri);
localForm.append('documentId', documentId);
if (documentId) localForm.append('documentId', documentId);
if (hasFolderTarget) localForm.append('targetRelativePath', String(plan.targetRelativePath || ''));
localForm.append('kind', file && String(file.type || '').indexOf('image/') === 0 ? 'image' : 'attachment');
var localResponse = await fetch('/api/local-folder/assets/upload', {
method: 'POST',
@@ -3546,6 +3587,28 @@ const SIDEBAR_TREE_JS: &str = r##"
return Promise.resolve();
}
function triggerBrowserDownload(url) {
if (!url) return false;
var link = document.createElement('a');
link.href = url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.download = '';
link.style.position = 'fixed';
link.style.left = '-9999px';
link.style.top = '0';
document.body.appendChild(link);
try {
link.click();
} catch (_) {
if (typeof window.open === 'function') window.open(url, '_blank', 'noopener,noreferrer');
}
window.setTimeout(function() {
if (link.parentElement) link.parentElement.removeChild(link);
}, 1000);
return true;
}
function recordFileTreeAction(action, detail) {
var normalized = String(action || '').trim() || 'unknown';
var rowId = String(detail && detail.rowId || '').trim();
@@ -3698,6 +3761,10 @@ const SIDEBAR_TREE_JS: &str = r##"
var workspaceId = detail.workspaceId || resolveWorkspaceId(trigger || document.body);
var title = detail.title || '';
var isAsset = detail.contextKind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index';
if (detail.contextKind === 'filetree' && action === 'download') {
downloadSelectedFileTreeAssetRows(detail, trigger);
return;
}
if (isAsset && action === 'new-window') {
recordFileTreeAction('new-window', detail);
void openConvexAssetFromFileTree({ ...detail, openTarget: 'new-window' });
@@ -3903,6 +3970,7 @@ const SIDEBAR_TREE_JS: &str = r##"
menu.setAttribute('data-kind', kind);
var isAttachment = kind === 'attachment';
var isAsset = kind === 'filetree' && detail.assetId && detail.rowKind !== 'document' && detail.rowKind !== 'index';
var isFileTreeDownload = kind === 'filetree' && detail.downloadable;
var items = isAttachment ? [
{ action: 'duplicate', icon: 'file_copy', label: '', shortcut: 'Ctrl + D' },
{ action: 'delete-trash', icon: 'delete', label: '', shortcut: 'Del', danger: true },
@@ -3926,6 +3994,7 @@ const SIDEBAR_TREE_JS: &str = r##"
] : isAsset ? [
{ action: 'open-edit-mode', icon: 'edit_note', label: '使' },
{ action: 'new-window', icon: 'open_in_new', label: '' },
{ action: 'download', icon: 'download', label: Number(detail.selectedDownloadCount || detail.selectedAssetCount || 0) > 1 ? ' ' + Number(detail.selectedDownloadCount || detail.selectedAssetCount || 0) + ' ' : '' },
{ action: 'rename', icon: 'edit', label: '', shortcut: 'F2' },
{ action: 'copy-id', icon: 'tag', label: ' ID' },
{ action: 'delete-trash', icon: 'delete', label: '', shortcut: 'Del', danger: true },
@@ -3935,6 +4004,7 @@ const SIDEBAR_TREE_JS: &str = r##"
{ action: 'reveal', icon: 'my_location', label: 'Reveal' }
] : kind === 'filetree' ? [
{ action: 'open-right', icon: 'right_panel_open', label: '', shortcut: 'Alt+' },
{ action: 'download', icon: 'download', label: Number(detail.selectedDownloadCount || 0) > 1 ? ' ' + Number(detail.selectedDownloadCount || 0) + ' ' : '', disabled: !isFileTreeDownload, title: isFileTreeDownload ? '' : '' },
{ separator: true },
{ action: 'copy-link', icon: 'link', label: '访' },
{ action: 'copy-reference-inline', icon: 'content_copy', label: '' },
@@ -3987,19 +4057,31 @@ const SIDEBAR_TREE_JS: &str = r##"
function openFileTreeContextMenu(row, x, y, trigger) {
if (!(row instanceof HTMLElement)) return;
var documentId = row.getAttribute('data-document-id') || row.getAttribute('data-doc-id') || '';
var selectedDownloadRows = selectedSidebarFileTreeRowsForDownload(row);
var selectedAssetRows = selectedDownloadRows.filter(isFileTreeDownloadableAssetRow);
openTreeContextMenu('filetree', {
documentId: documentId,
rowId: row.getAttribute('data-row-id') || '',
rowKind: row.getAttribute('data-row-kind') || '',
assetId: row.getAttribute('data-asset-id') || '',
localRelativePath: fileTreeRowLocalRelativePath(row),
title: rowTitle(row),
workspaceId: resolveWorkspaceId(row)
workspaceId: resolveWorkspaceId(row),
downloadable: isFileTreeDownloadableRow(row),
selectedDownloadCount: selectedDownloadRows.length,
selectedDownloadRowIds: selectedDownloadRows.map(function(downloadRow) { return downloadRow.getAttribute('data-row-id') || ''; }).filter(Boolean),
selectedAssetCount: selectedAssetRows.length,
selectedAssetRowIds: selectedAssetRows.map(function(assetRow) { return assetRow.getAttribute('data-row-id') || ''; }).filter(Boolean)
}, x, y, trigger || row);
}
function visibleFileTreeRows() {
return Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"]'))
.filter(function(row) { return row instanceof HTMLElement && row.offsetParent !== null; });
.filter(function(row) {
if (!(row instanceof HTMLElement)) return false;
if (row.closest('.tree-children--collapsed')) return false;
return row.offsetParent !== null || row.getClientRects().length > 0;
});
}
function syncSidebarFileTreeSelection() {
@@ -4116,11 +4198,130 @@ const SIDEBAR_TREE_JS: &str = r##"
return '';
}
function decodeLocalEncodedPath(value) {
var path = String(value || '').trim().replace(/~2F/g, '/');
if (!path) return '';
try {
return decodeURIComponent(path);
} catch (_) {
return path;
}
}
function fileTreeRowLocalRelativePath(row) {
if (!(row instanceof HTMLElement)) return '';
var direct = String(row.getAttribute('data-local-relative-path') || '').trim();
if (direct) return direct;
var assetPath = localFilePathFromAssetId(fileTreeRowAssetId(row));
if (assetPath) return assetPath;
var rowId = String(row.getAttribute('data-row-id') || '').trim();
var nodeId = String(row.getAttribute('data-node-id') || '').trim();
var documentId = fileTreeRowDocumentId(row);
var kind = fileTreeRowKind(row);
if (rowId.indexOf('local:asset:') === 0) return rowId.slice('local:asset:'.length);
if (rowId.indexOf('local:markdown:') === 0) return rowId.slice('local:markdown:'.length);
if (rowId.indexOf('local:folder:') === 0) return rowId.slice('local:folder:'.length);
if (rowId.indexOf('local:node:') === 0) return rowId.slice('local:node:'.length);
if (nodeId.indexOf('local:node:') === 0) return nodeId.slice('local:node:'.length);
if ((kind === 'document' || kind === 'doc' || kind === 'markdown') && documentId.indexOf('local-md:') === 0) {
return decodeLocalEncodedPath(documentId.slice('local-md:'.length));
}
if ((kind === 'folder' || kind === 'directory' || kind === 'index') && documentId.indexOf('local-dir:') === 0) {
return decodeLocalEncodedPath(documentId.slice('local-dir:'.length));
}
return '';
}
function fileTreeRowKind(row) {
if (!(row instanceof HTMLElement)) return '';
return String(row.getAttribute('data-row-kind') || '').trim();
}
function isFileTreeDownloadableAssetRow(row) {
if (!(row instanceof HTMLElement)) return false;
var kind = fileTreeRowKind(row);
if (kind === 'document' || kind === 'doc' || kind === 'index' || kind === 'markdown' || kind === 'folder' || kind === 'directory') return false;
return Boolean(fileTreeRowAssetId(row));
}
function isFileTreeDownloadableRow(row) {
if (!(row instanceof HTMLElement)) return false;
if (currentSourceKind() !== 'local_folder') return isFileTreeDownloadableAssetRow(row);
return Boolean(fileTreeRowLocalRelativePath(row) || isFileTreeDownloadableAssetRow(row));
}
function fileTreeAssetDownloadDetail(row) {
if (!isFileTreeDownloadableRow(row)) return null;
var title = rowTitle(row);
var relativePath = fileTreeRowLocalRelativePath(row);
var assetId = fileTreeRowAssetId(row) || (relativePath ? 'local-file:' + relativePath : '');
return {
contextKind: 'filetree',
documentId: fileTreeRowDocumentId(row),
rowId: row.getAttribute('data-row-id') || '',
rowKind: fileTreeRowKind(row),
assetId: assetId,
localRelativePath: relativePath,
title: title,
fileName: title,
workspaceId: resolveWorkspaceId(row)
};
}
function selectedSidebarFileTreeRowsForDownload(contextRow) {
var contextRowId = contextRow instanceof HTMLElement ? contextRow.getAttribute('data-row-id') || '' : '';
var selectedRows = selectedSidebarFileTreeRows().filter(isFileTreeDownloadableRow);
if (contextRowId && sidebarFileTreeSelection.selectedRowIds.has(contextRowId) && selectedRows.length > 0) return selectedRows;
return isFileTreeDownloadableRow(contextRow) ? [contextRow] : selectedRows;
}
function selectedSidebarFileTreeAssetRowsForDownload(contextRow) {
return selectedSidebarFileTreeRowsForDownload(contextRow).filter(isFileTreeDownloadableAssetRow);
}
function downloadSelectedFileTreeAssetRows(detail, trigger) {
var contextRow = trigger && trigger.closest ? trigger.closest('.tree-row[data-shell-mode="filetree"]') : trigger;
if (!(contextRow instanceof HTMLElement) && detail && detail.rowId) {
contextRow = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="' + cssEscape(detail.rowId) + '"]');
}
var rows = selectedSidebarFileTreeRowsForDownload(contextRow);
var seen = new Set();
var downloads = [];
rows.forEach(function(row) {
var item = fileTreeAssetDownloadDetail(row);
var key = item && (item.localRelativePath || item.assetId || item.rowId);
if (!item || !key || seen.has(key)) return;
seen.add(key);
downloads.push(item);
});
if (downloads.length === 0 && detail && detail.assetId) {
downloads.push(Object.assign({}, detail, { fileName: detail.fileName || detail.title || '' }));
} else if (downloads.length === 0 && detail && detail.localRelativePath) {
downloads.push(Object.assign({}, detail, {
assetId: detail.assetId || 'local-file:' + detail.localRelativePath,
fileName: detail.fileName || detail.title || ''
}));
}
if (downloads.length === 0) {
recordFileTreeActionStatus('blocked', Object.assign({}, detail || {}, { reason: 'no-downloadable-assets' }));
return false;
}
var primary = Object.assign({}, downloads[0], {
count: downloads.length,
assetIds: downloads.map(function(item) { return item.assetId || ''; }).filter(Boolean),
localRelativePaths: downloads.map(function(item) { return item.localRelativePath || ''; }).filter(Boolean)
});
recordFileTreeAction(downloads.length > 1 ? 'bulk-download' : 'download', primary);
recordFileTreeActionStatus('requested', primary);
document.documentElement.setAttribute('data-mnote-filetree-download-count', String(downloads.length));
document.documentElement.setAttribute('data-mnote-filetree-download-asset-ids', downloads.map(function(item) { return item.assetId || ''; }).filter(Boolean).join(','));
document.documentElement.setAttribute('data-mnote-filetree-download-paths', downloads.map(function(item) { return item.localRelativePath || ''; }).filter(Boolean).join(','));
downloads.forEach(function(item) {
void openEditorAttachmentDownload(item);
});
return true;
}
function hasSelectedDocumentAncestor(row, selectedDocRowIds) {
var node = row instanceof HTMLElement ? row.closest('.tree-node') : null;
while (node && node.parentElement) {
@@ -8010,7 +8211,7 @@ const SIDEBAR_TREE_JS: &str = r##"
if (localFilePath) {
var localDownloadUrl = buildLocalFileOpenUrl(localFilePath, true);
if (localDownloadUrl) {
window.open(localDownloadUrl, '_blank', 'noopener,noreferrer');
triggerBrowserDownload(localDownloadUrl);
return;
}
}
@@ -8028,15 +8229,25 @@ const SIDEBAR_TREE_JS: &str = r##"
}
} catch (_) {}
}
var localRelativePath = String(detail.localRelativePath || '').trim();
if (localRelativePath) {
var localPathDownloadUrl = buildLocalFileOpenUrl(localRelativePath, true);
if (localPathDownloadUrl) {
triggerBrowserDownload(localPathDownloadUrl);
return;
}
}
var target = detail.fileUrl || detail.href;
if (!target) return;
window.open(target, '_blank', 'noopener,noreferrer');
}
function openEditorAttachmentMenu(link, trigger) {
function openEditorAttachmentMenu(link, trigger, point) {
var detail = detailFromEditorAttachmentLink(link);
var rect = trigger && trigger.getBoundingClientRect ? trigger.getBoundingClientRect() : link.getBoundingClientRect();
openTreeContextMenu('attachment', detail, rect.right, rect.bottom + 4, trigger || link);
var x = point && typeof point.x === 'number' ? point.x : rect.right;
var y = point && typeof point.y === 'number' ? point.y : rect.bottom + 4;
openTreeContextMenu('attachment', detail, x, y, trigger || link);
}
function openEditorAttachmentLink(link) {
@@ -8485,7 +8696,9 @@ const SIDEBAR_TREE_JS: &str = r##"
}
if (fileAction === 'menu') {
e.preventDefault();
selectSidebarFileTreeRow(fileRow, { ctrlKey: false, metaKey: false, shiftKey: false });
if (rowId && !sidebarFileTreeSelection.selectedRowIds.has(rowId)) {
selectSidebarFileTreeRow(fileRow, { ctrlKey: false, metaKey: false, shiftKey: false });
}
var point = rowCenter(fileBtn || fileRow);
dispatchSidebarEvent('tree.filetree.context-menu', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId || null });
openFileTreeContextMenu(fileRow, point.x, point.y, fileBtn || fileRow);
@@ -8559,6 +8772,14 @@ const SIDEBAR_TREE_JS: &str = r##"
});
document.addEventListener('contextmenu', function(event) {
var editorAttachmentLink = closestAction(event.target, '.editor-surface .ProseMirror a[data-mnote-attachment-link="true"], .editor-surface .ProseMirror a.mnote-uploaded-attachment-row');
if (editorAttachmentLink instanceof HTMLAnchorElement) {
event.preventDefault();
event.stopPropagation();
enhanceEditorAttachmentLink(editorAttachmentLink);
openEditorAttachmentMenu(editorAttachmentLink, editorAttachmentLink, { x: event.clientX, y: event.clientY });
return;
}
var fileRow = closestAction(event.target, '.tree-row[data-shell-mode="filetree"]');
if (fileRow) {
event.preventDefault();
@@ -8904,7 +9125,10 @@ const SIDEBAR_TREE_JS: &str = r##"
targetRowId: targetRow ? targetRow.getAttribute('data-row-id') : null,
targetRowKind: targetRow ? targetRow.getAttribute('data-row-kind') : 'root',
documentId: targetRow ? targetRow.getAttribute('data-document-id') || targetRow.getAttribute('data-doc-id') : null,
assetId: targetRow ? targetRow.getAttribute('data-asset-id') : null
assetId: targetRow ? targetRow.getAttribute('data-asset-id') : null,
targetRelativePath: targetRow && (targetRow.getAttribute('data-row-kind') === 'folder' || targetRow.getAttribute('data-row-kind') === 'directory')
? fileTreeRowLocalRelativePath(targetRow)
: null
};
if (activeFileTreeDropRow instanceof HTMLElement) activeFileTreeDropRow.setAttribute('data-drop-target', 'false');
activeFileTreeDropRow = null;
@@ -9718,6 +9942,21 @@ mod tests {
assert!(SIDEBAR_TREE_JS.contains("function fileTreeMenuTargetParentId"));
assert!(SIDEBAR_TREE_JS.contains("if (action === 'new-folder')"));
assert!(SIDEBAR_TREE_JS.contains("fileTreeCopyPath(detail, trigger)"));
assert!(SIDEBAR_TREE_JS
.contains("if (detail.contextKind === 'filetree' && action === 'download')"));
assert!(SIDEBAR_TREE_JS.contains("downloadSelectedFileTreeAssetRows(detail, trigger)"));
assert!(SIDEBAR_TREE_JS.contains("function selectedSidebarFileTreeRowsForDownload"));
assert!(SIDEBAR_TREE_JS.contains("function selectedSidebarFileTreeAssetRowsForDownload"));
assert!(SIDEBAR_TREE_JS.contains("data-local-relative-path"));
assert!(SIDEBAR_TREE_JS.contains("targetRelativePath"));
assert!(SIDEBAR_TREE_JS.contains(
"recordFileTreeAction(downloads.length > 1 ? 'bulk-download' : 'download', primary)"
));
assert!(SIDEBAR_TREE_JS.contains("data-mnote-filetree-download-count"));
assert!(SIDEBAR_TREE_JS.contains("function triggerBrowserDownload"));
assert!(SIDEBAR_TREE_JS.contains("openEditorAttachmentMenu(editorAttachmentLink, editorAttachmentLink, { x: event.clientX, y: event.clientY });"));
assert!(SIDEBAR_TREE_JS.contains("var localFilePath = localFilePathFromAssetId(assetId);"));
assert!(SIDEBAR_TREE_JS.contains("attachmentMetaCache[assetId] = localMeta;"));
assert!(SIDEBAR_TREE_JS.contains(
"var pageRow = closestAction(e.target, '.tree-row[data-shell-mode=\"page\"]');"
));