收口工作台资源 tab 与本地文件树 P1

This commit is contained in:
lix-2026
2026-05-20 21:45:11 +08:00
parent fe13444dbc
commit a29d9868f6
13 changed files with 1595 additions and 214 deletions
@@ -43,6 +43,7 @@ struct LocalFolderMetadata {
page_options: BTreeMap<String, Value>,
trash_entries: BTreeMap<String, LocalTrashEntry>,
uploaded_assets: BTreeMap<String, LocalUploadedAssetEntry>,
file_order: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -3300,6 +3301,7 @@ fn rename_local_markdown_page(
})?;
let new_relative_path = normalize_relative_path(root, &target)?;
apply_file_order_path_rewrite(root, &old_relative_path, &new_relative_path)?;
let new_document_id = local_markdown_path_page_id(&new_relative_path);
Ok(json!({
"ok": true,
@@ -3384,6 +3386,13 @@ fn rename_nested_bundle_markdown_page(
}
}
let new_relative_path = normalize_relative_path(root, &new_markdown)?;
let old_bundle_relative_path = markdown_file
.path
.parent()
.and_then(|parent| normalize_relative_path(root, parent).ok())
.unwrap_or_else(|| old_relative_path.clone());
let new_bundle_relative_path = normalize_relative_path(root, &new_bundle_dir)?;
apply_file_order_path_rewrite(root, &old_bundle_relative_path, &new_bundle_relative_path)?;
let new_document_id = local_markdown_path_page_id(&new_relative_path);
Ok(json!({
"ok": true,
@@ -3416,6 +3425,8 @@ fn rename_local_directory(root: &Path, directory: &Path, title: &str) -> Result<
)
})?;
let new_relative_path = normalize_relative_path(root, &target)?;
let old_relative_path = normalize_relative_path(root, directory)?;
apply_file_order_path_rewrite(root, &old_relative_path, &new_relative_path)?;
Ok(json!({
"ok": true,
"id": local_directory_group_id(&new_relative_path),
@@ -3524,19 +3535,36 @@ fn move_local_entry(
parent_id: Option<&str>,
sort_order: Option<i64>,
) -> Result<Value, WebError> {
let original_relative_path =
if let Some(directory) = resolve_local_directory_id(root, document_id)? {
Some(normalize_relative_path(root, &directory)?)
} else {
load_local_folder_metadata(root)
.ok()
.and_then(|metadata| {
find_markdown_by_page_id(root, &metadata, document_id)
.ok()
.flatten()
})
.and_then(|entry| {
markdown_page_bundle_directory(&entry.path)
.and_then(|directory| normalize_relative_path(root, &directory).ok())
.or(Some(entry.relative_path))
})
};
let result = if let Some(directory) = resolve_local_directory_id(root, document_id)? {
move_local_directory(root, &directory, parent_id)?
} else {
move_local_markdown_page(root, document_id, parent_id)?
};
if let Some(sort_order) = sort_order.filter(|value| *value >= 0) {
if let Some(object) = result.as_object() {
let mut enriched = object.clone();
enriched.insert(
"_unsupportedFields".to_string(),
json!({ "sortOrder": sort_order }),
);
return Ok(Value::Object(enriched));
if let Some(relative_path) = result.get("orderRelativePath").and_then(Value::as_str) {
update_local_file_order_after_move(
root,
original_relative_path.as_deref(),
relative_path,
sort_order,
)?;
}
}
Ok(result)
@@ -3571,6 +3599,7 @@ fn move_local_markdown_page(
"id": document_id,
"documentId": document_id,
"relativePath": markdown_file.relative_path,
"orderRelativePath": markdown_file.relative_path,
"action": "move",
"sourceKind": "local_folder",
}));
@@ -3588,6 +3617,7 @@ fn move_local_markdown_page(
"id": document_id,
"documentId": document_id,
"relativePath": markdown_file.relative_path,
"orderRelativePath": markdown_file.relative_path,
"action": "move",
"sourceKind": "local_folder",
}));
@@ -3615,6 +3645,7 @@ fn move_local_markdown_page(
"id": new_document_id,
"documentId": new_document_id,
"relativePath": new_relative_path,
"orderRelativePath": new_relative_path,
"previousDocumentId": document_id,
"previousRelativePath": old_relative_path,
"action": "move",
@@ -3635,6 +3666,7 @@ fn move_local_markdown_bundle(
"id": document_id,
"documentId": document_id,
"relativePath": markdown_file.relative_path,
"orderRelativePath": normalize_relative_path(root, bundle_dir)?,
"action": "move",
"sourceKind": "local_folder",
}));
@@ -3680,12 +3712,14 @@ fn move_local_markdown_bundle(
})?;
}
let new_relative_path = normalize_relative_path(root, &target_markdown)?;
let new_bundle_relative_path = normalize_relative_path(root, &target_bundle)?;
let new_document_id = local_markdown_path_page_id(&new_relative_path);
Ok(json!({
"ok": true,
"id": new_document_id,
"documentId": new_document_id,
"relativePath": new_relative_path,
"orderRelativePath": new_bundle_relative_path,
"previousDocumentId": document_id,
"previousRelativePath": markdown_file.relative_path,
"action": "move",
@@ -3717,6 +3751,7 @@ fn move_local_directory(
"ok": true,
"id": local_directory_group_id(&normalize_relative_path(root, directory)?),
"relativePath": normalize_relative_path(root, directory)?,
"orderRelativePath": normalize_relative_path(root, directory)?,
"action": "move",
"sourceKind": "local_folder",
}));
@@ -3738,6 +3773,7 @@ fn move_local_directory(
"ok": true,
"id": local_directory_group_id(&new_relative_path),
"relativePath": new_relative_path,
"orderRelativePath": new_relative_path,
"action": "move",
"sourceKind": "local_folder",
}))
@@ -3838,6 +3874,7 @@ fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, We
markdown_file.relative_path.clone()
};
let trash_relative_path = normalize_relative_path(root, &target)?;
remove_file_order_path(&mut metadata.file_order, &original_relative_path);
metadata.trash_entries.insert(
document_id.to_string(),
LocalTrashEntry {
@@ -3855,6 +3892,7 @@ fn trash_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, We
},
);
write_trash_index_metadata(root, &metadata.trash_entries)?;
write_file_order_metadata(root, &metadata.file_order)?;
Ok(json!({
"ok": true,
"id": document_id,
@@ -3896,6 +3934,7 @@ fn trash_local_raw_file(root: &Path, entry_id: &str, file: &Path) -> Result<Valu
let trash_relative_path = normalize_relative_path(root, &target)?;
let trash_entry_id = local_file_trash_entry_id(&relative_path);
let now = now_ms();
remove_file_order_path(&mut metadata.file_order, &relative_path);
metadata.trash_entries.insert(
trash_entry_id.clone(),
LocalTrashEntry {
@@ -3913,6 +3952,7 @@ fn trash_local_raw_file(root: &Path, entry_id: &str, file: &Path) -> Result<Valu
},
);
write_trash_index_metadata(root, &metadata.trash_entries)?;
write_file_order_metadata(root, &metadata.file_order)?;
Ok(json!({
"ok": true,
"id": entry_id,
@@ -3967,6 +4007,7 @@ fn trash_local_directory(root: &Path, entry_id: &str, directory: &Path) -> Resul
let trash_relative_path = normalize_relative_path(root, &target)?;
let trash_entry_id = local_directory_trash_entry_id(&relative_path);
let now = now_ms();
remove_file_order_path(&mut metadata.file_order, &relative_path);
metadata.trash_entries.insert(
trash_entry_id.clone(),
LocalTrashEntry {
@@ -3984,6 +4025,7 @@ fn trash_local_directory(root: &Path, entry_id: &str, directory: &Path) -> Resul
},
);
write_trash_index_metadata(root, &metadata.trash_entries)?;
write_file_order_metadata(root, &metadata.file_order)?;
Ok(json!({
"ok": true,
"id": entry_id,
@@ -4276,6 +4318,9 @@ fn restore_local_directory(root: &Path, entry_id: &str) -> Result<Value, WebErro
fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, WebError> {
let mut metadata = load_local_folder_metadata(root)?;
if let Some(markdown_file) = find_markdown_by_page_id(root, &metadata, document_id)? {
let order_relative_path = markdown_page_bundle_directory(&markdown_file.path)
.and_then(|bundle_dir| normalize_relative_path(root, &bundle_dir).ok())
.unwrap_or_else(|| markdown_file.relative_path.clone());
if let Some(bundle_dir) = markdown_page_bundle_directory(&markdown_file.path) {
fs::remove_dir_all(&bundle_dir).map_err(|error| {
WebError::bad_request_code(
@@ -4294,6 +4339,8 @@ fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, We
)
})?;
}
remove_file_order_path(&mut metadata.file_order, &order_relative_path);
write_file_order_metadata(root, &metadata.file_order)?;
return Ok(json!({
"ok": true,
"id": document_id,
@@ -4308,6 +4355,10 @@ fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, We
"找不到要永久删除的本地 Markdown 页面",
)
})?;
remove_file_order_path(
&mut metadata.file_order,
&trash_entry.original_relative_path,
);
let trash_path = resolve_metadata_relative_path(root, &trash_entry.trash_relative_path)?;
if trash_path.exists() {
let remove_result = if trash_path.is_dir() {
@@ -4326,6 +4377,7 @@ fn purge_local_markdown_page(root: &Path, document_id: &str) -> Result<Value, We
})?;
}
write_trash_index_metadata(root, &metadata.trash_entries)?;
write_file_order_metadata(root, &metadata.file_order)?;
Ok(json!({
"ok": true,
"id": document_id,
@@ -4349,6 +4401,10 @@ fn purge_local_directory(root: &Path, entry_id: &str) -> Result<Value, WebError>
"找不到要永久删除的本地文件夹回收站记录",
)
})?;
remove_file_order_path(
&mut metadata.file_order,
&trash_entry.original_relative_path,
);
let trash_relative_path = local_trash_file_path(&trash_entry);
let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?;
if trash_path.exists() {
@@ -4363,6 +4419,7 @@ fn purge_local_directory(root: &Path, entry_id: &str) -> Result<Value, WebError>
})?;
}
write_trash_index_metadata(root, &metadata.trash_entries)?;
write_file_order_metadata(root, &metadata.file_order)?;
Ok(json!({
"ok": true,
"id": entry_id,
@@ -4390,6 +4447,10 @@ fn purge_local_raw_file(root: &Path, entry_id: &str) -> Result<Value, WebError>
"找不到要永久删除的本地资源回收站记录",
)
})?;
remove_file_order_path(
&mut metadata.file_order,
&trash_entry.original_relative_path,
);
let trash_relative_path = local_trash_file_path(&trash_entry);
let trash_path = resolve_metadata_relative_path(root, &trash_relative_path)?;
if trash_path.exists() {
@@ -4740,6 +4801,7 @@ fn load_local_folder_metadata(root: &Path) -> Result<LocalFolderMetadata, WebErr
uploaded_assets: load_uploaded_asset_index_map(
&root.join(".mnote").join("uploaded-assets.json"),
)?,
file_order: load_file_order_metadata(&root.join(".mnote").join("file-order.json"))?,
})
}
@@ -4815,6 +4877,25 @@ fn load_uploaded_asset_index_map(
Ok(entries)
}
fn load_file_order_metadata(path: &Path) -> Result<BTreeMap<String, Vec<String>>, WebError> {
if !path.exists() {
return Ok(BTreeMap::new());
}
let value = read_metadata_json(path)?;
let source = value.get("parents").unwrap_or(&value);
let orders: BTreeMap<String, Vec<String>> = serde_json::from_value(source.clone())
.map_err(|error| metadata_invalid(path, format!("文件树排序索引损坏: {error}")))?;
Ok(orders
.into_iter()
.map(|(parent, children)| {
(
normalize_file_order_parent_key(&parent),
normalize_file_order_children(children),
)
})
.collect())
}
fn metadata_invalid(path: &Path, message: impl Into<String>) -> WebError {
WebError::bad_request_code(
"local_metadata_invalid",
@@ -4907,6 +4988,25 @@ fn write_uploaded_asset_index_metadata(
write_json_atomic(&path, &value)
}
fn write_file_order_metadata(
root: &Path,
file_order: &BTreeMap<String, Vec<String>>,
) -> Result<(), WebError> {
let mnote_dir = root.join(".mnote");
fs::create_dir_all(&mnote_dir).map_err(|error| {
WebError::bad_request_code(
"local_metadata_write_failed",
format!("无法创建本地元数据目录 {}: {error}", mnote_dir.display()),
)
})?;
let path = mnote_dir.join("file-order.json");
let value = json!({
"version": 1,
"parents": file_order,
});
write_json_atomic(&path, &value)
}
#[allow(dead_code)]
fn write_json_atomic(path: &Path, value: &Value) -> Result<(), WebError> {
let tmp_path = path.with_extension("json.tmp");
@@ -4937,7 +5037,9 @@ fn scan_directory(
metadata: &LocalFolderMetadata,
rows: &mut Vec<LocalFolderRow>,
) -> Result<(), WebError> {
let entries = read_sorted_entries(directory, root)?;
let mut entries = read_sorted_entries(directory, root)?;
let parent_key = file_order_parent_key_for_directory(root, directory)?;
sort_entries_with_file_order(&mut entries, metadata, &parent_key);
let entry_count = entries.len();
for (position, entry) in entries.into_iter().enumerate() {
let node_id = local_node_id(if entry.relative_path.is_empty() {
@@ -5119,6 +5221,199 @@ fn compare_local_entries(a: &LocalFolderEntry, b: &LocalFolderEntry) -> Ordering
}
}
fn normalize_file_order_parent_key(parent: &str) -> String {
let trimmed = parent.trim().trim_matches('/');
if trimmed.is_empty() || trimmed == "." {
".".to_string()
} else {
trimmed.to_string()
}
}
fn normalize_file_order_children(children: Vec<String>) -> Vec<String> {
let mut seen = std::collections::BTreeSet::new();
children
.into_iter()
.map(|child| child.trim().trim_matches('/').to_string())
.filter(|child| !child.is_empty() && seen.insert(child.clone()))
.collect()
}
fn file_order_parent_key_for_directory(root: &Path, directory: &Path) -> Result<String, WebError> {
if directory == root {
return Ok(".".to_string());
}
Ok(normalize_file_order_parent_key(&normalize_relative_path(
root, directory,
)?))
}
fn parent_key_for_relative_path(relative_path: &str) -> String {
Path::new(relative_path)
.parent()
.and_then(|parent| {
let value = parent
.components()
.map(|component| component.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("/");
if value.is_empty() {
None
} else {
Some(value)
}
})
.map(|value| normalize_file_order_parent_key(&value))
.unwrap_or_else(|| ".".to_string())
}
fn sort_entries_with_file_order(
entries: &mut [LocalFolderEntry],
metadata: &LocalFolderMetadata,
parent_key: &str,
) {
let Some(order) = metadata.file_order.get(parent_key) else {
return;
};
let index = order
.iter()
.enumerate()
.map(|(position, relative_path)| (relative_path.as_str(), position))
.collect::<BTreeMap<_, _>>();
entries.sort_by(|a, b| {
let a_index = index.get(a.relative_path.as_str()).copied();
let b_index = index.get(b.relative_path.as_str()).copied();
match (a_index, b_index) {
(Some(left), Some(right)) => left.cmp(&right),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => compare_local_entries(a, b),
}
});
}
fn ordered_child_paths_for_parent(
root: &Path,
parent_directory: &Path,
) -> Result<Vec<String>, WebError> {
let mut entries = read_sorted_entries(parent_directory, root)?;
let parent_key = file_order_parent_key_for_directory(root, parent_directory)?;
let metadata = load_local_folder_metadata(root)?;
sort_entries_with_file_order(&mut entries, &metadata, &parent_key);
Ok(entries
.into_iter()
.map(|entry| entry.relative_path)
.collect::<Vec<_>>())
}
fn reorder_child_paths(children: &mut Vec<String>, child_path: &str, sort_order: i64) {
children.retain(|candidate| candidate != child_path);
let index = usize::try_from(sort_order)
.unwrap_or(usize::MAX)
.min(children.len());
children.insert(index, child_path.to_string());
}
fn update_local_file_order_after_move(
root: &Path,
original_relative_path: Option<&str>,
new_relative_path: &str,
sort_order: i64,
) -> Result<(), WebError> {
let mut metadata = load_local_folder_metadata(root)?;
if let Some(original) = original_relative_path
.map(str::trim)
.filter(|value| !value.is_empty())
{
let original_parent = parent_key_for_relative_path(original);
if original_parent != parent_key_for_relative_path(new_relative_path) {
if let Some(children) = metadata.file_order.get_mut(&original_parent) {
children.retain(|candidate| candidate != original);
}
}
}
let parent_key = parent_key_for_relative_path(new_relative_path);
let parent_directory = if parent_key == "." {
root.to_path_buf()
} else {
resolve_metadata_relative_path(root, &parent_key)?
};
let mut children = ordered_child_paths_for_parent(root, &parent_directory)?;
reorder_child_paths(&mut children, new_relative_path, sort_order);
metadata.file_order.insert(parent_key, children);
write_file_order_metadata(root, &metadata.file_order)
}
fn rewrite_file_order_path(
file_order: &mut BTreeMap<String, Vec<String>>,
old_relative_path: &str,
new_relative_path: &str,
) {
for children in file_order.values_mut() {
for child in children.iter_mut() {
if child == old_relative_path {
*child = new_relative_path.to_string();
} else if child.starts_with(&format!("{old_relative_path}/")) {
*child = format!("{}{}", new_relative_path, &child[old_relative_path.len()..]);
}
}
}
let old_parent_prefix = format!("{old_relative_path}/");
let parent_rewrites = file_order
.keys()
.filter_map(|parent| {
if parent == old_relative_path {
Some((parent.clone(), new_relative_path.to_string()))
} else if parent.starts_with(&old_parent_prefix) {
Some((
parent.clone(),
format!(
"{}{}",
new_relative_path,
&parent[old_relative_path.len()..]
),
))
} else {
None
}
})
.collect::<Vec<_>>();
for (old_parent, new_parent) in parent_rewrites {
if let Some(children) = file_order.remove(&old_parent) {
file_order.insert(new_parent, children);
}
}
}
fn remove_file_order_path(file_order: &mut BTreeMap<String, Vec<String>>, relative_path: &str) {
let child_prefix = format!("{relative_path}/");
for children in file_order.values_mut() {
children.retain(|child| child != relative_path && !child.starts_with(&child_prefix));
}
let parent_keys = file_order
.keys()
.filter(|parent| parent.as_str() == relative_path || parent.starts_with(&child_prefix))
.cloned()
.collect::<Vec<_>>();
for parent in parent_keys {
file_order.remove(&parent);
}
}
fn apply_file_order_path_rewrite(
root: &Path,
old_relative_path: &str,
new_relative_path: &str,
) -> Result<(), WebError> {
let mut metadata = load_local_folder_metadata(root)?;
rewrite_file_order_path(
&mut metadata.file_order,
old_relative_path,
new_relative_path,
);
write_file_order_metadata(root, &metadata.file_order)
}
fn scan_markdown_page_tree(
root: &Path,
directory: &Path,
@@ -5130,7 +5425,9 @@ fn scan_markdown_page_tree(
workspace_id: &str,
root_source_uri: &str,
) -> Result<bool, WebError> {
let entries = read_sorted_entries(directory, root)?;
let mut entries = read_sorted_entries(directory, root)?;
let parent_key = file_order_parent_key_for_directory(root, directory)?;
sort_entries_with_file_order(&mut entries, metadata, &parent_key);
let mut directory_rows = Vec::<LocalFolderRow>::new();
let mut contains_markdown = false;
for (position, entry) in entries.into_iter().enumerate() {
@@ -6677,7 +6974,7 @@ mod tests {
use axum::extract::{Extension, Path as AxumPath, Query};
use axum::http::{HeaderMap, Method, StatusCode};
use axum::Json;
use serde_json::{json, Value};
use serde_json::Value;
use std::sync::Mutex;
fn env_lock() -> &'static Mutex<()> {
@@ -8297,18 +8594,41 @@ fn main() {}
}
#[test]
fn local_tree_command_move_with_sort_order_annotates_unsupported() {
fn local_tree_command_move_with_sort_order_persists_file_order() {
let root = temp_root("mnote-local-move-sort-order");
init_workspace(&root);
std::fs::write(root.join("alpha.md"), "# Alpha\n").expect("write alpha");
std::fs::write(root.join("beta.md"), "# Beta\n").expect("write beta");
std::fs::write(root.join("source.md"), "# Source\n").expect("write source");
let root_uri = format!("file://{}", root.display());
let doc_id = "local-md:source.md";
let result =
execute_local_tree_command_with_sort(&root_uri, "move", doc_id, None, None, Some(42))
execute_local_tree_command_with_sort(&root_uri, "move", doc_id, None, None, Some(0))
.expect("move with sort_order should succeed");
assert_eq!(result["_unsupportedFields"]["sortOrder"], json!(42));
assert!(result.get("_unsupportedFields").is_none());
assert!(root.join("source.md").is_file());
let order_index =
std::fs::read_to_string(root.join(".mnote").join("file-order.json")).expect("order");
assert!(
order_index.contains("\"source.md\""),
"file-order 应记录排序后的 source.md: {order_index}"
);
let snapshot =
load_local_folder_file_tree_snapshot(&root_uri).expect("file tree snapshot after move");
let items = snapshot.projection["items"]
.as_array()
.expect("projection items");
let root_rows = items
.iter()
.filter(|item| item["parentNodeId"].is_null())
.map(|item| {
item["resourceMeta"]["extra"]["source"]["relativePath"]
.as_str()
.unwrap_or("")
})
.collect::<Vec<_>>();
assert_eq!(root_rows.first().copied(), Some("source.md"));
let result_without_sort = execute_local_tree_command(&root_uri, "move", doc_id, None, None)
.expect("move without sort_order");
+151 -22
View File
@@ -664,28 +664,52 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
};
if (panesBootstrap.secondaryInvalid === true) clearSecondaryParams();
const secondaryUrlForDocument = (documentId, detail = {}) => {
const url = currentUrl();
url.searchParams.set(paneRouteConfig.secondary.documentIdParam, documentId);
const detailSourceKind = typeof detail.sourceKind === 'string' ? detail.sourceKind.trim() : '';
const detailRootUri = typeof detail.rootUri === 'string' ? detail.rootUri.trim() : '';
const primarySourceKind = (url.searchParams.get(paneRouteConfig.primary.sourceKindParam) || '').trim();
const primaryRootUri = (url.searchParams.get(paneRouteConfig.primary.rootUriParam) || '').trim();
const secondarySourceKind = detailSourceKind || primarySourceKind;
const secondaryRootUri = detailRootUri || primaryRootUri;
if (secondarySourceKind) url.searchParams.set(paneRouteConfig.secondary.sourceKindParam, secondarySourceKind);
else url.searchParams.delete(paneRouteConfig.secondary.sourceKindParam);
if (secondaryRootUri) url.searchParams.set(paneRouteConfig.secondary.rootUriParam, secondaryRootUri);
else url.searchParams.delete(paneRouteConfig.secondary.rootUriParam);
return { url, sourceKind: secondarySourceKind, rootUri: secondaryRootUri };
};
const openDocumentInSecondaryPane = (documentId, detail = {}) => {
const id = typeof documentId === 'string' ? documentId.trim() : '';
if (!id) return false;
const target = secondaryUrlForDocument(id, detail);
if (typeof window.__mnoteDocumentPaneRuntime?.openSecondaryDocument === 'function') {
void window.__mnoteDocumentPaneRuntime.openSecondaryDocument({
documentId: id,
workspaceId: typeof detail.workspaceId === 'string' ? detail.workspaceId.trim() : '',
sourceKind: target.sourceKind || null,
rootUri: target.rootUri || null,
url: target.url,
});
return true;
}
window.location.assign(target.url.pathname + target.url.search + target.url.hash);
return true;
};
window.addEventListener('tree.page.open-right', (event) => {
const detail = event?.detail && typeof event.detail === 'object' ? event.detail : {};
const documentId = typeof detail.documentId === 'string' ? detail.documentId.trim() : '';
if (!documentId) return;
const url = currentUrl();
url.searchParams.set(paneRouteConfig.secondary.documentIdParam, documentId);
const primarySourceKind = (url.searchParams.get(paneRouteConfig.primary.sourceKindParam) || '').trim();
const primaryRootUri = (url.searchParams.get(paneRouteConfig.primary.rootUriParam) || '').trim();
if (primarySourceKind) url.searchParams.set(paneRouteConfig.secondary.sourceKindParam, primarySourceKind);
else url.searchParams.delete(paneRouteConfig.secondary.sourceKindParam);
if (primaryRootUri) url.searchParams.set(paneRouteConfig.secondary.rootUriParam, primaryRootUri);
else url.searchParams.delete(paneRouteConfig.secondary.rootUriParam);
if (typeof window.__mnoteDocumentPaneRuntime?.openSecondaryDocument === 'function') {
void window.__mnoteDocumentPaneRuntime.openSecondaryDocument({
documentId,
sourceKind: primarySourceKind || null,
rootUri: primaryRootUri || null,
url,
});
return;
}
window.location.assign(url.pathname + url.search + url.hash);
openDocumentInSecondaryPane(documentId, detail);
});
window.addEventListener('tree.page.open', (event) => {
const detail = event?.detail && typeof event.detail === 'object' ? event.detail : {};
const openTarget = typeof detail.openTarget === 'string' ? detail.openTarget.trim().toLowerCase() : '';
if (openTarget !== 'side') return;
const documentId = typeof detail.documentId === 'string' ? detail.documentId.trim() : '';
openDocumentInSecondaryPane(documentId, detail);
});
document.querySelectorAll('[data-mnote-pane-close="secondary"]').forEach((button) => {
@@ -2559,6 +2583,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
pane.setAttribute('data-pane-document-id', documentId);
pane.setAttribute('data-pane-workspace-id', workspaceId);
pane.setAttribute('data-pane-visible', 'true');
pane.removeAttribute('data-mnote-side-target');
pane.hidden = false;
const shell = pane.querySelector('.document-shell');
if (shell instanceof HTMLElement) {
@@ -2591,6 +2616,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
document.title = title;
const topbarCurrent = document.querySelector('.wolai-breadcrumb-current [data-page-title-current]');
if (topbarCurrent instanceof HTMLElement) topbarCurrent.textContent = title;
document.documentElement.removeAttribute('data-mnote-side-target-unsupported');
document.documentElement.removeAttribute('data-mnote-side-target-asset-id');
document.querySelectorAll('.tree-row[data-active="true"], .tree-row[data-selected="true"]').forEach((row) => {
if (row instanceof HTMLElement) {
row.setAttribute('data-active', 'false');
@@ -2610,6 +2637,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
runtimeDescriptor.root.removeAttribute('data-mnote-object-editor');
runtimeDescriptor.root.removeAttribute('data-mnote-object-identity');
runtimeDescriptor.root.removeAttribute('data-mnote-mindmap-id');
runtimeDescriptor.root.removeAttribute('data-mnote-side-target-unsupported');
runtimeDescriptor.root.removeAttribute('data-mnote-side-target-asset-id');
};
const fetchPageAggregateForPane = async (descriptor) => {
@@ -2711,15 +2740,24 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
if (pane instanceof HTMLElement) {
pane.hidden = true;
pane.setAttribute('data-pane-visible', 'false');
pane.removeAttribute('data-mnote-side-target');
pane.removeAttribute('data-pane-document-id');
pane.removeAttribute('data-pane-workspace-id');
}
const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="secondary"]`);
if (root instanceof HTMLElement) root.replaceChildren();
if (root instanceof HTMLElement) {
root.replaceChildren();
root.removeAttribute('data-mnote-side-target-unsupported');
root.removeAttribute('data-mnote-side-target-asset-id');
}
if (workspace instanceof HTMLElement) {
workspace.setAttribute('data-has-secondary-pane', 'false');
workspace.style.removeProperty('grid-template-columns');
}
const resizerNode = document.querySelector('[data-document-pane-resizer="true"]');
if (resizerNode instanceof HTMLElement) resizerNode.hidden = true;
document.documentElement.removeAttribute('data-mnote-side-target-unsupported');
document.documentElement.removeAttribute('data-mnote-side-target-asset-id');
replaceUrlState(url);
};
@@ -3152,6 +3190,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const resourceTabBadgeKind = (input, kind) => {
const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase();
if (kind === 'mindmap') return 'mindmap';
if (kind === 'office') {
if (/\.(ppt|pptx|odp)$/i.test(title)) return 'ppt';
if (/\.(xls|xlsx|ods|csv)$/i.test(title)) return 'sheet';
@@ -3174,7 +3213,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const normalizeResourceTabKind = (input) => {
const kind = String(input?.kind || '').trim().toLowerCase();
const title = String(input?.title || input?.fileName || input?.path || '').trim().toLowerCase();
if (kind === 'office' || kind === 'pdf' || kind === 'image' || kind === 'markdown' || kind === 'text' || kind === 'code') return kind;
if (kind === 'mindmap' || kind === 'office' || kind === 'pdf' || kind === 'image' || kind === 'markdown' || kind === 'text' || kind === 'code') return kind;
if (/\.(doc|docx|ppt|pptx|xls|xlsx|odt|odp|ods)$/i.test(title)) return 'office';
if (/\.pdf$/i.test(title)) return 'pdf';
if (/\.(png|jpg|jpeg|gif|webp|svg)$/i.test(title)) return 'image';
@@ -3382,6 +3421,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
}
removeFromResourceTabMru(key);
if (entry.view) unmountEditorViewBinding(entry.view, { releaseSession: true });
if (entry.mindmapRuntime?.runtime && entry.mindmapRuntime?.mountId != null) {
try {
entry.mindmapRuntime.runtime.unmount(entry.mindmapRuntime.mountId);
} catch (error) {
console.warn('mnote mindmap resource tab unmount failed', error);
}
}
if (entry.tab instanceof HTMLElement) entry.tab.remove();
if (entry.panel instanceof HTMLElement) entry.panel.remove();
resourceTabRegistry.delete(key);
@@ -3565,6 +3611,80 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
}
};
const openMindmapResourceTab = async (entry, input) => {
const documentId = String(input.documentId || currentDocumentId() || '').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);
const runtime = await loadRuntime();
const { bootstrap, title } = await fetchMindmapShellBootstrap(targetUrl);
entry.title = String(input.title || title || '思维导图').trim() || '思维导图';
const titleNode = entry.tab?.querySelector?.('.mnote-main-tab-title');
if (titleNode) titleNode.textContent = entry.title;
entry.panel.innerHTML = '<main class="document-shell mnote-resource-tab-mindmap-shell" data-editor-host="mindmap_resource_tab"><div class="mnote-resource-tab-mindmap-root" data-testid="mnote-mindmap-editor-root" data-editor-host-kind="mindmap_resource_tab" data-runtime-editor-status="booting" data-pane-role="resource"></div></main>';
entry.panel.setAttribute('data-mnote-object-editor', 'mindmap');
entry.panel.setAttribute('data-mnote-object-identity', entry.objectIdentity);
entry.panel.setAttribute('data-mnote-mindmap-id', mindmapId);
const root = entry.panel.querySelector('[data-testid="mnote-mindmap-editor-root"]');
if (!(root instanceof HTMLElement)) throw new Error('mindmap_resource_root_missing');
root.setAttribute('data-mnote-object-editor', 'mindmap');
root.setAttribute('data-mnote-object-identity', entry.objectIdentity);
root.setAttribute('data-mnote-mindmap-id', mindmapId);
root.setAttribute('data-document-id', documentId);
const mountId = runtime.mount(root, bootstrap);
root.setAttribute('data-runtime-mount-id', String(mountId));
root.setAttribute('data-editor-host-kind', 'mindmap_resource_tab');
root.setAttribute('data-runtime-editor-status', 'mounted');
entry.view = null;
entry.session = null;
entry.mindmapRuntime = { runtime, mountId, root };
};
const openUnsupportedSideTarget = (input = {}) => {
const url = currentUrl();
secondaryQueryParamNames.forEach((name) => url.searchParams.delete(name));
const previousView = paneViewRegistry.get('secondary');
if (previousView) {
unmountEditorViewBinding(previousView);
paneViewRegistry.delete('secondary');
}
unmountMindmapPane('secondary');
const workspace = document.querySelector('.mnote-document-workspace');
if (workspace instanceof HTMLElement) {
workspace.setAttribute('data-has-secondary-pane', 'true');
applyStoredSecondaryWidth();
}
const resizerNode = document.querySelector('[data-document-pane-resizer="true"]');
if (resizerNode instanceof HTMLElement) resizerNode.hidden = false;
const pane = document.querySelector('[data-document-pane="true"][data-pane-role="secondary"]');
if (pane instanceof HTMLElement) {
pane.hidden = false;
pane.setAttribute('data-pane-visible', 'true');
pane.setAttribute('data-mnote-side-target', 'unsupported-resource');
pane.removeAttribute('data-pane-document-id');
pane.removeAttribute('data-pane-workspace-id');
}
const root = document.querySelector(`${ROOT_SELECTOR}[data-pane-role="secondary"]`);
const title = String(input.title || input.fileName || input.assetId || '资源').trim() || '资源';
if (root instanceof HTMLElement) {
root.replaceChildren();
root.setAttribute('data-editor-host-kind', 'unsupported_resource_side_target');
root.setAttribute('data-mnote-side-target-unsupported', 'true');
root.setAttribute('data-mnote-side-target-asset-id', String(input.assetId || ''));
const placeholder = document.createElement('div');
placeholder.className = 'mnote-resource-tab-error';
placeholder.setAttribute('data-mnote-side-target-placeholder', 'true');
placeholder.innerHTML = '<div class="mnote-resource-tab-error-inner"><h1>暂不支持在侧栏打开此资源</h1><p></p></div>';
const text = placeholder.querySelector('p');
if (text) text.textContent = title;
root.append(placeholder);
}
document.documentElement.setAttribute('data-mnote-side-target-unsupported', 'true');
document.documentElement.setAttribute('data-mnote-side-target-asset-id', String(input.assetId || ''));
replaceUrlState(url);
return true;
};
const openResourceInActiveTab = async (input = {}) => {
bindMainEditorPageTab();
const objectIdentity = String(input.objectIdentity || input.assetId || input.href || '').trim();
@@ -3579,7 +3699,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
resourceTabRegistry.set(objectIdentity, entry);
activateMainEditorTab(objectIdentity);
try {
if (entry.kind === 'markdown' || entry.kind === 'text' || entry.kind === 'code') {
if (entry.kind === 'mindmap') {
await openMindmapResourceTab(entry, input);
} else if (entry.kind === 'markdown' || entry.kind === 'text' || entry.kind === 'code') {
await openTiptapResourceTab(entry, input);
} else {
openPassiveResourceTab(entry, input);
@@ -3656,6 +3778,13 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
},
resolveResourceOpen: (input) => resolveResourceOpen(input),
openResourceInActiveTab: openResourceInActiveTab,
openResourceAsSideTarget: async (input = {}) => {
const resolved = resolveResourceOpen({ ...input, openTarget: 'side' });
if (resolved.editorKind === 'markdown' || resolved.editorKind === 'text' || resolved.editorKind === 'code') {
return openUnsupportedSideTarget(input);
}
return openUnsupportedSideTarget(input);
},
closeSecondaryDocument: ({ url } = {}) => {
closeSecondaryPane(url instanceof URL ? url : currentUrl());
return true;