feat: add main editor resource tabs

This commit is contained in:
lix-2026
2026-05-20 14:20:48 +08:00
parent 322c7ffdce
commit 03173e2363
9 changed files with 1490 additions and 38 deletions
@@ -206,6 +206,23 @@ struct LocalAssetUploadFields {
kind: String,
}
fn local_folder_ok_response(
context: &RequestContext,
result: Value,
) -> (StatusCode, HeaderMap, Json<Value>) {
(
StatusCode::OK,
HeaderMap::new(),
Json(json!({
"ok": true,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"owner": "mnote-web",
"result": result,
})),
)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalFileOpenQuery {
@@ -215,6 +232,30 @@ pub struct LocalFileOpenQuery {
pub download: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalResourceReadQuery {
pub root_uri: String,
pub path: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalResourceWriteRequest {
#[serde(default, alias = "root_uri")]
pub root_uri: String,
#[serde(default)]
pub path: String,
#[serde(default, alias = "expected_file_version")]
pub expected_file_version: Option<String>,
#[serde(default, alias = "content_format")]
pub content_format: Option<String>,
#[serde(default)]
pub content: Value,
#[serde(default, alias = "tiptap_document")]
pub tiptap_document: Option<Value>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalAccessValidateRootRequest {
@@ -2337,6 +2378,143 @@ pub async fn open_local_file(
Ok((StatusCode::OK, headers, bytes))
}
pub async fn read_local_resource(
Extension(context): Extension<RequestContext>,
Query(query): Query<LocalResourceReadQuery>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
ensure_local_workspace_read_access(&context, &query.root_uri)
.map_err(|error| error.with_context(&context))?;
let root = parse_file_root_uri(&query.root_uri)?
.canonicalize()
.map_err(|error| {
WebError::bad_request_code(
"local_folder_unavailable",
format!("无法访问本地文件夹: {error}"),
)
.with_context(&context)
})?;
let target = resolve_local_file_open_path(&query.root_uri, &query.path)?;
let text = fs::read_to_string(&target).map_err(|error| {
WebError::bad_request_code(
"local_resource_read_failed",
format!("无法读取本地资源文件 {}: {error}", target.display()),
)
.with_context(&context)
})?;
let file_name = target
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("资源")
.to_string();
let content = if is_markdown_file(&file_name) {
crate::routes::local_markdown_parser::markdown_to_blocks(&text)
} else {
text_to_editor_blocks(&text, &file_name)
};
let file_version = local_resource_conflict_detection_key(&root, &target)?;
Ok(local_folder_ok_response(
&context,
json!({
"rootUri": query.root_uri,
"path": query.path,
"fileName": file_name,
"contentType": content_type_for_path(&target).to_str().unwrap_or("application/octet-stream"),
"text": text,
"content": content,
"contentFormat": "editorBlocks",
"fileVersion": file_version,
"conflictDetectionKey": file_version,
"sourceKind": "local_folder",
}),
))
}
pub async fn write_local_resource(
Extension(context): Extension<RequestContext>,
Json(request): Json<LocalResourceWriteRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let root_uri = request.root_uri.trim();
let path = request.path.trim();
if root_uri.is_empty() || path.is_empty() {
return Err(WebError::bad_request_code(
"local_resource_write_required_missing",
"缺少 rootUri 或 path",
)
.with_context(&context));
}
ensure_local_workspace_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
let root = parse_file_root_uri(root_uri)?
.canonicalize()
.map_err(|error| {
WebError::bad_request_code(
"local_folder_unavailable",
format!("无法访问本地文件夹: {error}"),
)
.with_context(&context)
})?;
let target = resolve_local_file_open_path(root_uri, path)?;
let current_key = local_resource_conflict_detection_key(&root, &target)?;
if let Some(expected_key) = request
.expected_file_version
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
if expected_key != current_key {
return Err(WebError::new(
StatusCode::CONFLICT,
"local_resource_external_change",
"本地资源文件已被外部修改,请刷新后再保存",
)
.with_details(json!({
"conflict": {
"code": "local_resource_external_change",
"rootUri": root_uri,
"path": path,
"currentDiskVersion": current_key,
"editorBaseVersion": expected_key,
}
}))
.with_context(&context));
}
}
let content = local_resource_write_editor_blocks(&request);
let file_name = target
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("资源")
.to_string();
let next_text = if is_markdown_file(&file_name) {
editor_blocks_to_markdown_for_file(&content, &root, &target)
} else {
editor_blocks_to_plain_text(&content)
};
fs::write(&target, next_text).map_err(|error| {
WebError::bad_request_code(
"local_resource_write_failed",
format!("无法保存本地资源文件 {}: {error}", target.display()),
)
.with_context(&context)
})?;
let next_key = local_resource_conflict_detection_key(&root, &target)?;
Ok(local_folder_ok_response(
&context,
json!({
"ok": true,
"rootUri": root_uri,
"path": path,
"fileName": file_name,
"fileVersion": next_key,
"conflictDetectionKey": next_key,
"contentFormat": request.content_format.unwrap_or_else(|| "editorBlocks".into()),
"executedCommand": "resource.file.write",
"canonicalCommand": "resource.file.write",
"sourceKind": "local_folder",
}),
))
}
fn resolve_local_file_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| {
@@ -5630,6 +5808,167 @@ fn editor_blocks_to_markdown_for_file(
editor_blocks_to_markdown_with_rewrite(content, Some((root, markdown_path)))
}
fn local_resource_write_editor_blocks(request: &LocalResourceWriteRequest) -> Value {
let format = request
.content_format
.as_deref()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let source = if request.content.is_null() {
request.tiptap_document.as_ref().unwrap_or(&Value::Null)
} else {
&request.content
};
if format == "tiptapdocument" || is_tiptap_document(source) {
tiptap_document_to_editor_blocks(source)
} else {
source.clone()
}
}
fn is_tiptap_document(value: &Value) -> bool {
value.get("type").and_then(Value::as_str) == Some("doc")
&& value.get("content").and_then(Value::as_array).is_some()
}
fn tiptap_document_to_editor_blocks(value: &Value) -> Value {
let nodes = value
.get("content")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let blocks = nodes
.iter()
.enumerate()
.map(|(index, node)| {
let node_type = node
.get("type")
.and_then(Value::as_str)
.unwrap_or("paragraph");
let content = node.get("content").cloned().unwrap_or_else(|| json!([]));
let text = extract_block_text(&content);
let block_type = match node_type {
"heading" => "heading",
"codeBlock" | "code_block" | "code" => "codeBlock",
"blockquote" => "quote",
"bulletListItem" | "listItem" => "bulletListItem",
"orderedListItem" | "numberedListItem" => "numberedListItem",
_ => "paragraph",
};
let mut block = json!({
"id": format!("resource-block-{}-{}", index + 1, stable_hash_hex(&format!("{node_type}:{text}"))),
"type": block_type,
"content": content,
});
if node_type == "heading" {
let level = node
.get("attrs")
.and_then(|attrs| attrs.get("level"))
.and_then(Value::as_u64)
.unwrap_or(1)
.clamp(1, 6);
block["props"] = json!({ "level": level });
}
block
})
.collect::<Vec<_>>();
Value::Array(blocks)
}
fn text_to_editor_blocks(text: &str, file_name: &str) -> Value {
if is_code_like_file(file_name) {
return json!([{
"id": "resource-block-1",
"type": "code_block",
"content": text,
"props": {
"language": file_extension(file_name).unwrap_or_default()
}
}]);
}
let blocks = text
.split('\n')
.enumerate()
.map(|(index, line)| {
json!({
"id": format!("resource-block-{}-{}", index + 1, stable_hash_hex(line)),
"type": "paragraph",
"content": line
})
})
.collect::<Vec<_>>();
Value::Array(blocks)
}
fn editor_blocks_to_plain_text(content: &Value) -> String {
let blocks = if let Some(array) = content.as_array() {
array.clone()
} else {
content
.get("blocks")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
};
let text = blocks
.iter()
.map(extract_block_text)
.collect::<Vec<_>>()
.join("\n");
if text.ends_with('\n') {
text
} else {
format!("{text}\n")
}
}
fn file_extension(file_name: &str) -> Option<String> {
Path::new(file_name)
.extension()
.and_then(|value| value.to_str())
.map(|value| value.to_ascii_lowercase())
}
fn is_code_like_file(file_name: &str) -> bool {
matches!(
file_extension(file_name).as_deref(),
Some(
"rs" | "ts"
| "tsx"
| "js"
| "jsx"
| "json"
| "css"
| "scss"
| "html"
| "xml"
| "py"
| "go"
| "java"
| "kt"
| "swift"
| "c"
| "h"
| "cpp"
| "hpp"
| "sh"
| "bash"
| "zsh"
| "toml"
| "yaml"
| "yml"
| "sql"
)
)
}
fn stable_hash_hex(value: &str) -> String {
let mut hasher = DefaultHasher::new();
value.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
fn editor_blocks_to_markdown_with_rewrite(
content: &Value,
local_file_context: Option<(&Path, &Path)>,
@@ -6201,6 +6540,34 @@ fn local_markdown_conflict_detection_key(
))
}
fn local_resource_conflict_detection_key(root: &Path, target: &Path) -> Result<String, WebError> {
let content = fs::read(target).map_err(|error| {
WebError::bad_request_code(
"local_resource_read_failed",
format!("无法读取本地资源文件 {}: {error}", target.display()),
)
})?;
let meta = fs::metadata(target).map_err(|error| {
WebError::bad_request_code(
"local_resource_stat_failed",
format!("无法读取本地资源文件状态 {}: {error}", target.display()),
)
})?;
let modified_ms = system_time_ms(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH));
let relative_path = target
.strip_prefix(root)
.ok()
.map(|path| path.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|| target.to_string_lossy().to_string());
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
let content_hash = hasher.finish();
Ok(format!(
"local-resource:{relative_path}:{modified_ms}:{}:{content_hash:016x}",
meta.len()
))
}
fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Value {
let outline = content
.as_array()
@@ -6267,19 +6634,20 @@ fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Val
mod tests {
use super::{
add_local_access_grant_for_context, create_default_local_workspace_for_actor_at_base,
create_local_access_grant, create_share_grant, encode_local_id_segment,
ensure_local_path_read_access, ensure_local_workspace_access_for_actor,
ensure_local_workspace_read_access_for_actor, execute_local_tree_command,
get_local_access_policy, get_share_grants, initialize_local_page_id,
initialize_local_workspace_for_actor, load_local_folder_file_tree_snapshot,
load_local_folder_page_tree_snapshot, local_folder_watch_revision, local_workspace_id,
create_local_access_grant, create_share_grant, editor_blocks_to_markdown_for_file,
encode_local_id_segment, ensure_local_path_read_access,
ensure_local_workspace_access_for_actor, ensure_local_workspace_read_access_for_actor,
execute_local_tree_command, get_local_access_policy, get_share_grants,
initialize_local_page_id, initialize_local_workspace_for_actor,
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
local_folder_watch_revision, local_resource_write_editor_blocks, local_workspace_id,
open_local_file, record_shared_cache, record_sync_pending_change,
resolve_local_markdown_page_aggregate, save_local_markdown_page,
update_local_markdown_title, validate_local_access_root, write_local_markdown_asset,
write_local_markdown_page_body, write_local_mindmap_data, write_sync_conflict_report,
LocalAccessGrantRequest, LocalAccessValidateRootRequest, LocalFileOpenQuery,
LocalShareGrantRequest, LocalUploadFile, SharedCacheRecordRequest,
SyncConflictReportRequest, SyncPendingChangeRequest,
LocalResourceWriteRequest, LocalShareGrantRequest, LocalUploadFile,
SharedCacheRecordRequest, SyncConflictReportRequest, SyncPendingChangeRequest,
};
use crate::context::RequestContext;
use axum::extract::{Extension, Path as AxumPath, Query};
@@ -7734,6 +8102,65 @@ fn main() {}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_resource_markdown_write_targets_asset_not_page_body() {
let root = temp_root("mnote-local-resource-md-write");
init_workspace(&root);
std::fs::write(root.join("Page.md"), "# Page\n\n正文\n").expect("write page");
std::fs::create_dir_all(root.join("Page.assets")).expect("create assets");
let asset_path = root.join("Page.assets").join("note.md");
std::fs::write(&asset_path, "# Asset\n\n旧内容\n").expect("write asset");
let blocks = serde_json::json!([
{"type":"heading","props":{"level":1},"content":[{"type":"text","text":"Asset"}]},
{"type":"paragraph","content":[{"type":"text","text":"新内容"}]}
]);
let next = editor_blocks_to_markdown_for_file(&blocks, &root, &asset_path);
std::fs::write(&asset_path, next).expect("write asset next");
let asset_text = std::fs::read_to_string(&asset_path).expect("read asset");
let page_text = std::fs::read_to_string(root.join("Page.md")).expect("read page");
assert!(asset_text.contains("新内容"));
assert!(page_text.contains("正文"));
assert!(!page_text.contains("新内容"));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_resource_write_accepts_tiptap_document_payload() {
let request = LocalResourceWriteRequest {
root_uri: "file:///tmp/mnote".into(),
path: "Page.assets/note.md".into(),
content_format: Some("tiptapDocument".into()),
tiptap_document: Some(serde_json::json!({
"type": "doc",
"content": [
{
"type": "heading",
"attrs": { "level": 2 },
"content": [{ "type": "text", "text": "资源标题" }]
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "资源正文" }]
}
]
})),
..Default::default()
};
let blocks = local_resource_write_editor_blocks(&request);
let markdown = editor_blocks_to_markdown_for_file(
&blocks,
std::path::Path::new("/tmp"),
std::path::Path::new("/tmp/note.md"),
);
assert!(markdown.contains("## 资源标题"));
assert!(markdown.contains("资源正文"));
}
#[test]
fn local_tree_command_delete_folder_moves_directory_to_trash() {
let root = temp_root("mnote-local-delete-folder");
+8
View File
@@ -237,6 +237,14 @@ pub fn build_router(state: AppState) -> Router {
"/api/local-folder/files/open",
get(local_folder_source::open_local_file),
)
.route(
"/api/local-folder/resource/read",
get(local_folder_source::read_local_resource),
)
.route(
"/api/local-folder/resource/write",
post(local_folder_source::write_local_resource),
)
.route(
"/api/tree/runtime/reduce",
post(tree::reduce_tree_shell_runtime),
+355 -25
View File
@@ -1391,6 +1391,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const localFolderEventRegistry = new Map();
const paneViewRegistry = new Map();
const mindmapPaneViewRegistry = new Map();
const resourceTabRegistry = new Map();
let nextViewId = 1;
const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突';
const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突';
@@ -1675,11 +1676,32 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
return nextAggregate;
};
const fetchLatestResourceSnapshot = async (session) => {
const url = new URL('/api/local-folder/resource/read', window.location.origin);
url.searchParams.set('rootUri', session.rootUri || '');
url.searchParams.set('path', session.resourcePath || '');
const response = await fetch(url.toString(), {
cache: 'no-store',
headers: { accept: 'application/json' },
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) throw new Error('resource_conflict_latest_fetch_failed_' + response.status);
const nextResource = payload.result;
if (!nextResource || typeof nextResource !== 'object') throw new Error('resource_conflict_latest_missing_snapshot');
return nextResource;
};
const aggregatePlainText = (aggregate) => {
const body = aggregate?.body || {};
return flattenText(pageBodyTiptapDocument(body)).replace(/\n{3,}/g, '\n\n').trim();
};
const resourceSnapshotPlainText = (snapshot) => (
String(snapshot?.text || flattenText(toTiptapDocument(snapshot?.content, '')) || '')
.replace(/\n{3,}/g, '\n\n')
.trim()
);
const shouldRetryTransientEmptyLocalAggregate = (session, nextAggregate) => {
if (!session || session.sourceKind !== 'local_folder') return false;
if (!sessionHasRecentExternalSignal(session)) return false;
@@ -1746,6 +1768,29 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
setSessionStatus(session, 'synced-external-change');
};
const applyResourceSnapshotToSession = (session, nextResource, source) => {
const nextConflictKey = String(nextResource?.fileVersion || nextResource?.conflictDetectionKey || '').trim();
const nextTiptapDocument = toTiptapDocument(nextResource?.content, nextResource?.text || '');
const nextSerialized = JSON.stringify(nextTiptapDocument);
session.currentTiptapDocument = nextTiptapDocument;
session.currentSerialized = nextSerialized;
session.lastPersistedSerialized = nextSerialized;
if (nextConflictKey) {
session.conflictDetectionKey = nextConflictKey;
session.lastExternalConflictDetectionKey = nextConflictKey;
session.fileVersion = nextConflictKey;
}
session.dirty = false;
session.hasExternalConflict = false;
session.externalChangePending = false;
session.lastUserInputAt = 0;
clearSessionConflictSurface(session);
sessionViews(session).forEach((view) => {
if (view.mountId != null) dispatchSessionContentToView(session, view, source || 'mnote-web-resource-conflict-resolved');
});
setSessionStatus(session, 'synced-external-change');
};
const openConflictDiffPanel = async (session, panel) => {
const diffPanel = panel.querySelector('[data-testid="mnote-conflict-diff-panel"]');
if (!(diffPanel instanceof HTMLElement)) return;
@@ -1756,14 +1801,19 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
loading.textContent = '正在读取磁盘版本...';
diffPanel.appendChild(loading);
try {
const latest = await fetchLatestSessionAggregate(session);
const latest = session.sessionKind === 'resource'
? await fetchLatestResourceSnapshot(session)
: await fetchLatestSessionAggregate(session);
const latestText = session.sessionKind === 'resource'
? resourceSnapshotPlainText(latest)
: aggregatePlainText(latest);
diffPanel.replaceChildren();
const current = document.createElement('pre');
current.setAttribute('data-testid', 'mnote-conflict-current-text');
current.textContent = sessionPlainText(session) || '(当前编辑器为空)';
const disk = document.createElement('pre');
disk.setAttribute('data-testid', 'mnote-conflict-disk-text');
disk.textContent = aggregatePlainText(latest) || '(磁盘版本为空)';
disk.textContent = latestText || '(磁盘版本为空)';
const currentTitle = document.createElement('h3');
currentTitle.textContent = '当前编辑器版本';
const diskTitle = document.createElement('h3');
@@ -1776,7 +1826,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
mergeTitle.textContent = '合并结果';
const mergeText = document.createElement('textarea');
mergeText.setAttribute('data-testid', 'mnote-conflict-merge-text');
mergeText.value = sessionPlainText(session) || aggregatePlainText(latest) || '';
mergeText.value = sessionPlainText(session) || latestText || '';
const mergeActions = document.createElement('div');
mergeActions.className = 'mnote-conflict-actions';
const useCurrent = document.createElement('button');
@@ -1800,7 +1850,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
mergeText.value = sessionPlainText(session) || '';
});
useDisk.addEventListener('click', () => {
mergeText.value = aggregatePlainText(latest) || '';
mergeText.value = latestText || '';
});
saveMerge.addEventListener('click', () => {
writeMergedConflictResult(session, panel, mergeText.value).catch((error) => {
@@ -1816,13 +1866,20 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const acceptDiskVersion = async (session) => {
setSessionStatus(session, 'conflict-resolving', '正在接受磁盘版本...');
if (session.sessionKind === 'resource') {
const latestResource = await fetchLatestResourceSnapshot(session);
applyResourceSnapshotToSession(session, latestResource, 'mnote-web-resource-conflict-accept-disk');
return;
}
const latest = await fetchLatestSessionAggregate(session);
applyAggregateSnapshotToSession(session, latest, 'mnote-web-conflict-accept-disk');
};
const keepCurrentEditorVersion = async (session) => {
setSessionStatus(session, 'conflict-resolving', '正在保留当前编辑器版本...');
const latest = await fetchLatestSessionAggregate(session);
const latest = session.sessionKind === 'resource'
? await fetchLatestResourceSnapshot(session)
: await fetchLatestSessionAggregate(session);
const hydrateView = sessionViews(session).find((item) => item.mountId != null) || sessionViews(session)[0];
if (hydrateView) {
const liveText = normalizePlainText(currentEditorText(hydrateView));
@@ -1834,7 +1891,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
}
session.currentSerialized = JSON.stringify(session.currentTiptapDocument);
}
const nextKey = conflictDetectionKeyFromBody(latest.body || {});
const nextKey = session.sessionKind === 'resource'
? String(latest?.fileVersion || latest?.conflictDetectionKey || '').trim()
: conflictDetectionKeyFromBody(latest.body || {});
if (nextKey) {
session.conflictDetectionKey = nextKey;
session.lastExternalConflictDetectionKey = nextKey;
@@ -1849,8 +1908,12 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const writeMergedConflictResult = async (session, panel, mergedText) => {
setSessionStatus(session, 'conflict-resolving', '正在写回合并结果...');
const latest = await fetchLatestSessionAggregate(session);
const nextKey = conflictDetectionKeyFromBody(latest.body || {});
const latest = session.sessionKind === 'resource'
? await fetchLatestResourceSnapshot(session)
: await fetchLatestSessionAggregate(session);
const nextKey = session.sessionKind === 'resource'
? String(latest?.fileVersion || latest?.conflictDetectionKey || '').trim()
: conflictDetectionKeyFromBody(latest.body || {});
if (nextKey) {
session.conflictDetectionKey = nextKey;
session.lastExternalConflictDetectionKey = nextKey;
@@ -1882,7 +1945,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
text.textContent = message || externalConflictMessage;
const meta = document.createElement('div');
meta.className = 'mnote-conflict-meta';
meta.textContent = `文件:${session.rootUri || session.documentId} · 来源:${conflictSourceLabel(session) || '本地文件变更'}`;
const fileLabel = session.sessionKind === 'resource'
? `${session.rootUri || ''}/${session.resourcePath || session.documentId}`
: (session.rootUri || session.documentId);
meta.textContent = `文件:${fileLabel} · 来源:${conflictSourceLabel(session) || '本地文件变更'}`;
const actions = document.createElement('div');
actions.className = 'mnote-conflict-actions';
const acceptDisk = document.createElement('button');
@@ -1969,21 +2035,33 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const editorDocument = editorDocumentFromTiptapDocument({ documentId: session.documentId }, session.currentTiptapDocument);
const content = legacyBlocksFromEditorDocument(editorDocument);
const saveEndpoint = session.saveEndpoint || '/api/documents/save';
const savePayload = {
documentId: session.documentId,
workspaceId: session.workspaceId,
sourceKind: session.sourceKind,
rootUri: session.rootUri,
revision: session.revision,
expectedFileVersion: session.conflictDetectionKey,
contentFormat: 'editorBlocks',
editorSource: 'tiptap',
editorDocument,
content,
tiptapDocument: session.currentTiptapDocument,
blockCount: editorDocument.blocks.length,
};
if (session.sourceKind !== 'local_folder') {
const savePayload = session.sessionKind === 'resource'
? {
rootUri: session.rootUri,
path: session.resourcePath,
expectedFileVersion: session.conflictDetectionKey,
contentFormat: 'editorBlocks',
editorSource: 'tiptap-resource-tab',
editorDocument,
content,
tiptapDocument: session.currentTiptapDocument,
blockCount: editorDocument.blocks.length,
}
: {
documentId: session.documentId,
workspaceId: session.workspaceId,
sourceKind: session.sourceKind,
rootUri: session.rootUri,
revision: session.revision,
expectedFileVersion: session.conflictDetectionKey,
contentFormat: 'editorBlocks',
editorSource: 'tiptap',
editorDocument,
content,
tiptapDocument: session.currentTiptapDocument,
blockCount: editorDocument.blocks.length,
};
if (session.sourceKind !== 'local_folder' && session.sessionKind !== 'resource') {
savePayload.conflictDetectionKey = session.conflictDetectionKey;
}
const response = await fetch(saveEndpoint, {
@@ -2021,7 +2099,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
syncSessionMetaToViews(session);
session.lastPersistedSerialized = serialized;
session.saving = false;
if (typeof window.__mnoteRecordPageHistorySnapshot === 'function') {
if (session.sessionKind !== 'resource' && typeof window.__mnoteRecordPageHistorySnapshot === 'function') {
const primaryView = sessionViews(session).find((view) => view.runtimeDescriptor.paneRole === 'primary') || sessionViews(session)[0];
if (primaryView) {
const plainText = sessionPlainText(session);
@@ -2887,6 +2965,252 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
return view;
};
const resourceTabHostNodes = () => ({
strip: document.querySelector('[data-mnote-main-tab-strip]'),
pageTab: document.querySelector('[data-mnote-main-tab="page"]'),
pagePanel: document.querySelector('[data-mnote-page-tab-panel]'),
host: document.querySelector('[data-mnote-resource-tab-host]'),
panelRoot: document.querySelector('[data-mnote-resource-tab-panel-root]'),
});
const resourceIconForKind = (kind) => {
if (kind === 'office') return 'article';
if (kind === 'pdf') return 'picture_as_pdf';
if (kind === 'image') return 'image';
if (kind === 'markdown') return 'notes';
if (kind === 'code') return 'code';
return 'draft';
};
const currentWebShellWorkspaceId = () => {
try {
return currentUrl().searchParams.get('workspaceId') || '';
} catch (_) {
return '';
}
};
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 (/\.(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';
if (/\.(md|markdown)$/i.test(title)) return 'markdown';
if (/\.(txt|log)$/i.test(title)) return 'text';
if (/\.(rs|ts|tsx|js|jsx|json|css|scss|html|xml|py|go|java|kt|swift|c|h|cpp|hpp|sh|bash|zsh|toml|yaml|yml|sql)$/i.test(title)) return 'code';
return 'file';
};
const activateMainEditorTab = (objectIdentity) => {
const nodes = resourceTabHostNodes();
const activeResource = String(objectIdentity || '').trim();
if (nodes.pageTab instanceof HTMLElement) {
const activePage = !activeResource;
nodes.pageTab.classList.toggle('is-active', activePage);
nodes.pageTab.setAttribute('aria-selected', activePage ? 'true' : 'false');
}
if (nodes.pagePanel instanceof HTMLElement) nodes.pagePanel.hidden = Boolean(activeResource);
if (nodes.host instanceof HTMLElement) nodes.host.hidden = !activeResource;
resourceTabRegistry.forEach((entry, key) => {
const active = key === activeResource;
if (entry.tab instanceof HTMLElement) {
entry.tab.classList.toggle('is-active', active);
entry.tab.setAttribute('aria-selected', active ? 'true' : 'false');
}
if (entry.panel instanceof HTMLElement) entry.panel.hidden = !active;
});
};
const closeResourceTab = (objectIdentity) => {
const key = String(objectIdentity || '').trim();
const entry = resourceTabRegistry.get(key);
if (!entry) return;
if (entry.view) unmountEditorViewBinding(entry.view, { releaseSession: true });
if (entry.tab instanceof HTMLElement) entry.tab.remove();
if (entry.panel instanceof HTMLElement) entry.panel.remove();
resourceTabRegistry.delete(key);
activateMainEditorTab('');
};
const createResourceTabDom = (input) => {
const nodes = resourceTabHostNodes();
if (!(nodes.strip instanceof HTMLElement) || !(nodes.panelRoot instanceof HTMLElement)) return null;
const objectIdentity = String(input.objectIdentity || '').trim();
const title = String(input.title || input.fileName || input.path || '资源').trim() || '资源';
const kind = normalizeResourceTabKind(input);
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 'mnote-main-tab';
tab.setAttribute('role', 'tab');
tab.setAttribute('data-mnote-main-tab', objectIdentity);
tab.setAttribute('data-mnote-tab-kind', kind);
tab.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">' + resourceIconForKind(kind) + '</span><span class="mnote-main-tab-title"></span><span class="mnote-main-tab-close" role="button" aria-label="关闭标签页">×</span>';
const titleNode = tab.querySelector('.mnote-main-tab-title');
if (titleNode) titleNode.textContent = title;
tab.addEventListener('click', (event) => {
const target = event.target;
if (target instanceof HTMLElement && target.closest('.mnote-main-tab-close')) {
event.preventDefault();
event.stopPropagation();
closeResourceTab(objectIdentity);
return;
}
activateMainEditorTab(objectIdentity);
});
const panel = document.createElement('section');
panel.className = 'mnote-resource-tab-panel';
panel.setAttribute('data-mnote-resource-tab-panel', objectIdentity);
panel.setAttribute('data-resource-kind', kind);
panel.hidden = true;
nodes.strip.append(tab);
nodes.panelRoot.append(panel);
return { objectIdentity, title, kind, tab, panel, view: null, session: null };
};
const localResourceReadUrl = (rootUri, path) => {
const url = new URL('/api/local-folder/resource/read', window.location.origin);
url.searchParams.set('rootUri', rootUri || '');
url.searchParams.set('path', path || '');
return url.toString();
};
const createResourceSession = (entry, input, readResult) => {
const tiptapDocument = toTiptapDocument(readResult?.content, readResult?.text || '');
const conflictDetectionKey = String(readResult?.fileVersion || readResult?.conflictDetectionKey || '').trim();
const session = {
key: `resource:${input.rootUri || ''}:${input.path || entry.objectIdentity}`,
sessionKind: 'resource',
documentId: entry.objectIdentity,
workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || ''),
sourceKind: 'local_folder',
rootUri: String(input.rootUri || ''),
resourcePath: String(input.path || ''),
saveEndpoint: '/api/local-folder/resource/write',
pageAggregateScriptId: '',
latestAggregate: null,
title: entry.title,
currentTiptapDocument: tiptapDocument,
currentSerialized: JSON.stringify(tiptapDocument),
lastPersistedSerialized: JSON.stringify(tiptapDocument),
revision: null,
conflictDetectionKey,
fileVersion: conflictDetectionKey,
lastExternalConflictDetectionKey: conflictDetectionKey,
readOnly: false,
dirty: false,
saving: false,
hasExternalConflict: false,
externalChangePending: false,
externalRefreshSource: '',
lastExternalChangeSignalAt: 0,
lastSelfSaveSignalAt: 0,
lastExternalWriteSource: '',
lastExternalWriteRunId: '',
lastUserInputAt: 0,
saveTimer: 0,
externalRefreshTimer: 0,
releaseTimer: 0,
views: new Map(),
localFolderChannel: null,
status: 'ready',
error: null,
};
documentSessionRegistry.set(session.key, session);
return session;
};
const openTiptapResourceTab = async (entry, input) => {
const runtime = await loadRuntime();
entry.panel.innerHTML = '<main class="document-shell mnote-resource-tab-text-shell" data-editor-host="leptos_tiptap_resource"><div class="mnote-resource-tab-editor-root" data-testid="mnote-leptos-tiptap-island-editor-root" data-editor-host-kind="leptos_tiptap_resource" data-runtime-editor-status="booting" data-pane-role="resource"></div><div class="sr-only" data-editor-host-observability="rust-web-resource-tab" data-pane-role="resource"></div></main>';
const root = entry.panel.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]');
const observability = entry.panel.querySelector('[data-editor-host-observability]');
if (!(root instanceof HTMLElement)) throw new Error('resource_tab_root_missing');
const response = await fetch(localResourceReadUrl(input.rootUri, input.path), { cache: 'no-store', headers: { accept: 'application/json' } });
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) throw new Error(payload?.error?.message || `resource_read_failed_${response.status}`);
const readResult = payload.result || {};
const runtimeDescriptor = {
paneRole: 'resource',
root,
observability,
aggregate: { layout: { pageOptions: {} } },
bootstrap: {
documentId: entry.objectIdentity,
workspaceId: String(input.workspaceId || currentWebShellWorkspaceId() || ''),
sourceKind: 'local_folder',
rootUri: String(input.rootUri || ''),
saveEndpoint: '/api/local-folder/resource/write',
},
};
const session = createResourceSession(entry, input, readResult);
const view = createEditorViewBinding(session, runtime, runtimeDescriptor);
const mountId = runtime.mount(root, {
documentId: session.documentId,
workspaceId: session.workspaceId,
title: session.title,
content: session.currentTiptapDocument,
revision: session.revision,
conflictDetectionKey: session.conflictDetectionKey,
readOnly: false,
editable: true,
pageOptions: {},
});
view.mountId = mountId;
root.setAttribute('data-runtime-mount-id', String(mountId));
root.setAttribute('data-editor-host-kind', 'leptos_tiptap_resource');
setStatus(runtimeDescriptor, 'mounting-editor');
entry.view = view;
entry.session = session;
};
const openPassiveResourceTab = (entry, input) => {
const href = String(input.officeUrl || input.href || '').trim();
if (entry.kind === 'image') {
entry.panel.innerHTML = '<img class="mnote-resource-tab-image" alt="">';
const img = entry.panel.querySelector('img');
if (img instanceof HTMLImageElement) {
img.src = href;
img.alt = entry.title;
}
return;
}
entry.panel.innerHTML = '<iframe class="mnote-resource-tab-frame" title=""></iframe>';
const frame = entry.panel.querySelector('iframe');
if (frame instanceof HTMLIFrameElement) {
frame.title = entry.title;
frame.src = href;
}
};
const openResourceInActiveTab = async (input = {}) => {
const objectIdentity = String(input.objectIdentity || input.assetId || input.href || '').trim();
if (!objectIdentity) return false;
const existing = resourceTabRegistry.get(objectIdentity);
if (existing) {
activateMainEditorTab(objectIdentity);
return true;
}
const entry = createResourceTabDom({ ...input, objectIdentity });
if (!entry) return false;
resourceTabRegistry.set(objectIdentity, entry);
activateMainEditorTab(objectIdentity);
try {
if (entry.kind === 'markdown' || entry.kind === 'text' || entry.kind === 'code') {
await openTiptapResourceTab(entry, input);
} else {
openPassiveResourceTab(entry, input);
}
activateMainEditorTab(objectIdentity);
return true;
} catch (error) {
console.warn('mnote resource tab 打开失败', error);
entry.panel.innerHTML = '<div class="mnote-resource-tab-text-shell" data-resource-tab-error="true">资源打开失败</div>';
return false;
}
};
const mountPane = async (runtimeDescriptor) => {
const runtime = await loadRuntime();
const session = getOrCreateDocumentSession(runtimeDescriptor);
@@ -2948,6 +3272,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
if (url instanceof URL) replaceUrlState(url);
return true;
},
openResourceInActiveTab: openResourceInActiveTab,
closeSecondaryDocument: ({ url } = {}) => {
closeSecondaryPane(url instanceof URL ? url : currentUrl());
return true;
@@ -3624,7 +3949,12 @@ mod tests {
assert!(html.contains("data-document-pane=\"true\""));
assert!(html.contains("data-pane-role=\"primary\""));
assert!(html.contains("data-document-pane-resizer=\"true\""));
assert!(html.contains("data-mnote-main-tab-strip"));
assert!(html.contains("data-mnote-resource-tab-host"));
assert!(html.contains("openPrimaryMindmap"));
assert!(html.contains("openResourceInActiveTab"));
assert!(html.contains("/api/local-folder/resource/read"));
assert!(html.contains("/api/local-folder/resource/write"));
assert!(html.contains("replacePrimaryPaneMindmap"));
assert!(html.contains("__MNOTE_MINDMAP_EDITOR_BOOTSTRAP__"));
assert!(html.contains("data-mnote-object-identity"));