Align resource tabs with workbench review

This commit is contained in:
lix-2026
2026-05-20 19:04:05 +08:00
parent 4c62ea7e35
commit acf1305a7e
14 changed files with 964 additions and 13 deletions
@@ -2964,6 +2964,17 @@ pub fn execute_local_tree_command(
document_id: &str,
parent_id: Option<&str>,
title: Option<&str>,
) -> Result<Value, WebError> {
execute_local_tree_command_with_sort(root_uri, action, document_id, parent_id, title, None)
}
pub fn execute_local_tree_command_with_sort(
root_uri: &str,
action: &str,
document_id: &str,
parent_id: Option<&str>,
title: Option<&str>,
sort_order: Option<i64>,
) -> Result<Value, WebError> {
let root_path = parse_file_root_uri(root_uri)?;
let canonical_root = root_path.canonicalize().map_err(|error| {
@@ -2988,7 +2999,7 @@ pub fn execute_local_tree_command(
parent_id,
title.unwrap_or("外部文件"),
),
"move" => move_local_entry(&canonical_root, document_id, parent_id),
"move" => move_local_entry(&canonical_root, document_id, parent_id, sort_order),
"delete" | "trash" => trash_local_entry(&canonical_root, document_id),
"restore" => restore_local_entry(&canonical_root, document_id),
"purge" => purge_local_entry(&canonical_root, document_id),
@@ -3511,11 +3522,24 @@ fn move_local_entry(
root: &Path,
document_id: &str,
parent_id: Option<&str>,
sort_order: Option<i64>,
) -> Result<Value, WebError> {
if let Some(directory) = resolve_local_directory_id(root, document_id)? {
return move_local_directory(root, &directory, parent_id);
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));
}
}
move_local_markdown_page(root, document_id, parent_id)
Ok(result)
}
fn move_local_markdown_page(
@@ -6637,8 +6661,8 @@ mod tests {
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,
execute_local_tree_command, execute_local_tree_command_with_sort, 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,
@@ -6653,7 +6677,7 @@ mod tests {
use axum::extract::{Extension, Path as AxumPath, Query};
use axum::http::{HeaderMap, Method, StatusCode};
use axum::Json;
use serde_json::Value;
use serde_json::{json, Value};
use std::sync::Mutex;
fn env_lock() -> &'static Mutex<()> {
@@ -8226,6 +8250,73 @@ fn main() {}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_tree_command_delete_chinese_named_asset_in_subdir_uses_trash_index() {
let root = temp_root("mnote-local-delete-chinese-asset");
init_workspace(&root);
std::fs::create_dir_all(root.join("Page")).expect("create Page dir");
std::fs::write(root.join("Page").join("资源.ext"), b"chinese asset content")
.expect("write asset");
let root_uri = format!("file://{}", root.display());
let asset_id = "local-file:Page/资源.ext";
let result = execute_local_tree_command(&root_uri, "delete", asset_id, None, None)
.expect("delete chinese-named asset");
assert_eq!(result["resourceKind"].as_str(), Some("local_file"));
assert_eq!(result["trashEntryId"].as_str(), Some(asset_id));
assert!(!root.join("Page").join("资源.ext").exists());
assert!(root.join(".mnote").join("trash").join("资源.ext").exists());
let trash_index = std::fs::read_to_string(root.join(".mnote").join("trash-index.json"))
.expect("trash index");
assert!(
trash_index.contains(asset_id),
"trash index should contain {asset_id}: {trash_index}"
);
let restored = execute_local_tree_command(&root_uri, "restore", asset_id, None, None)
.expect("restore chinese-named asset");
assert_eq!(restored["documentId"].as_str(), Some(asset_id));
assert!(root.join("Page").join("资源.ext").is_file());
assert!(!root.join(".mnote").join("trash").join("资源.ext").exists());
execute_local_tree_command(&root_uri, "delete", asset_id, None, None)
.expect("re-delete chinese-named asset");
let purged = execute_local_tree_command(&root_uri, "purge", asset_id, None, None)
.expect("purge chinese-named asset");
assert_eq!(purged["ok"].as_bool(), Some(true));
assert!(!root.join(".mnote").join("trash").join("资源.ext").exists());
let trash_index_after_purge =
std::fs::read_to_string(root.join(".mnote").join("trash-index.json"))
.expect("trash index after purge");
assert!(
!trash_index_after_purge.contains(asset_id),
"trash index should not contain purged {asset_id}: {trash_index_after_purge}"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_tree_command_move_with_sort_order_annotates_unsupported() {
let root = temp_root("mnote-local-move-sort-order");
init_workspace(&root);
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))
.expect("move with sort_order should succeed");
assert_eq!(result["_unsupportedFields"]["sortOrder"], json!(42));
assert!(root.join("source.md").is_file());
let result_without_sort = execute_local_tree_command(&root_uri, "move", doc_id, None, None)
.expect("move without sort_order");
assert!(result_without_sort.get("_unsupportedFields").is_none());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_save_refreshes_search_index_after_write() {
let root = temp_root("mnote-local-save-refresh-search-index");
+6 -4
View File
@@ -6,9 +6,10 @@ use crate::routes::command_support::{
execute_runtime_command_via_convex_with_artifacts, read_optional_non_empty,
};
use crate::routes::local_folder_source::{
ensure_local_workspace_access, ensure_local_workspace_read_access, execute_local_tree_command,
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
local_folder_watch_revision, local_workspace_id_from_root_uri,
ensure_local_workspace_access, ensure_local_workspace_read_access,
execute_local_tree_command_with_sort, load_local_folder_file_tree_snapshot,
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
local_workspace_id_from_root_uri,
};
use crate::routes::query_support::{
fetch_documents_meta_via_convex, resolve_effective_workspace_id,
@@ -6798,12 +6799,13 @@ pub async fn tree_command(
})?;
ensure_local_workspace_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
let execution = execute_local_tree_command(
let execution = execute_local_tree_command_with_sort(
root_uri,
action,
&requested_document_id,
requested_parent_id.as_deref(),
requested_title.as_deref(),
requested_sort_order,
)
.map_err(|error| {
error
+72 -2
View File
@@ -1393,6 +1393,9 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const paneViewRegistry = new Map();
const mindmapPaneViewRegistry = new Map();
const resourceTabRegistry = new Map();
const resourceTabMru = [];
const resourceTabMruMax = 20;
const resourceTabCloseGuardAttribute = 'data-resource-tab-close-guarded';
let nextViewId = 1;
const externalConflictMessage = '本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突';
const treeExternalConflictMessage = '当前页面已在其它窗口更新,请刷新或保存前先处理冲突';
@@ -2137,9 +2140,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
setSessionStatus(session, 'dirty');
queueSessionSave(session);
}
syncResourceSessionTabGuards(session);
} catch (error) {
session.saving = false;
setSessionStatus(session, 'error', error instanceof Error ? error.message : String(error));
syncResourceSessionTabGuards(session);
}
};
@@ -2868,6 +2873,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
}
}
session.dirty = session.currentSerialized !== session.lastPersistedSerialized;
syncResourceSessionTabGuards(session);
if (serialized !== previousSerialized) {
broadcastSessionContent(session, view);
}
@@ -3151,7 +3157,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
if (/\.(xls|xlsx|ods|csv)$/i.test(title)) return 'sheet';
return 'word';
}
if (kind === 'pdf') return 'ppt';
if (kind === 'pdf') return 'pdf';
if (kind === 'markdown' || kind === 'text' || kind === 'code') return 'code';
if (kind === 'image') return 'image';
return 'file';
@@ -3178,13 +3184,67 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
return 'file';
};
const touchResourceTabMru = (key) => {
const id = String(key || '').trim();
if (!id) return;
const index = resourceTabMru.indexOf(id);
if (index >= 0) resourceTabMru.splice(index, 1);
resourceTabMru.unshift(id);
if (resourceTabMru.length > resourceTabMruMax) resourceTabMru.length = resourceTabMruMax;
};
const removeFromResourceTabMru = (key) => {
const index = resourceTabMru.indexOf(key);
if (index >= 0) resourceTabMru.splice(index, 1);
};
const lastActiveResourceTabKey = () => {
for (const key of resourceTabMru) {
if (resourceTabRegistry.has(key)) return key;
}
return '';
};
const resourceTabCloseGuardReason = (session) => {
if (!session) return '';
const hasUnsavedChanges = session.dirty
|| Boolean(session.saveTimer)
|| sessionHasRecentLocalInput(session)
|| (session.currentSerialized && session.currentSerialized !== session.lastPersistedSerialized);
if (hasUnsavedChanges) return 'dirty';
if (session.saving) return 'saving';
if (session.hasExternalConflict) return 'hasExternalConflict';
return '';
};
const syncResourceTabCloseGuard = (entry) => {
if (!entry?.tab || !(entry.tab instanceof HTMLElement)) return;
const reason = resourceTabCloseGuardReason(entry.session);
if (reason) {
entry.tab.setAttribute(resourceTabCloseGuardAttribute, reason);
entry.tab.classList.add('is-close-guarded');
} else {
entry.tab.removeAttribute(resourceTabCloseGuardAttribute);
entry.tab.classList.remove('is-close-guarded');
}
};
const syncResourceSessionTabGuards = (session) => {
if (!session || session.sessionKind !== 'resource') return;
resourceTabRegistry.forEach((entry) => {
if (entry.session === session) syncResourceTabCloseGuard(entry);
});
};
const activateMainEditorTab = (objectIdentity) => {
const nodes = resourceTabHostNodes();
const activeResource = String(objectIdentity || '').trim();
if (activeResource) touchResourceTabMru(activeResource);
if (nodes.pageTab instanceof HTMLElement) {
const activePage = !activeResource;
nodes.pageTab.classList.toggle('is-active', activePage);
nodes.pageTab.setAttribute('aria-selected', activePage ? 'true' : 'false');
nodes.pageTab.setAttribute('tabindex', activePage ? '0' : '-1');
}
if (nodes.pagePanel instanceof HTMLElement) nodes.pagePanel.hidden = Boolean(activeResource);
if (nodes.host instanceof HTMLElement) nodes.host.hidden = !activeResource;
@@ -3193,8 +3253,10 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
if (entry.tab instanceof HTMLElement) {
entry.tab.classList.toggle('is-active', active);
entry.tab.setAttribute('aria-selected', active ? 'true' : 'false');
entry.tab.setAttribute('tabindex', active ? '0' : '-1');
}
if (entry.panel instanceof HTMLElement) entry.panel.hidden = !active;
syncResourceTabCloseGuard(entry);
});
};
@@ -3213,11 +3275,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
const key = String(objectIdentity || '').trim();
const entry = resourceTabRegistry.get(key);
if (!entry) return;
const guardReason = resourceTabCloseGuardReason(entry.session);
if (guardReason) {
console.warn(`mnote resource tab 关闭阻止: ${entry.title} (${guardReason})`);
syncResourceTabCloseGuard(entry);
return;
}
removeFromResourceTabMru(key);
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('');
activateMainEditorTab(lastActiveResourceTabKey());
};
const createResourceTabDom = (input) => {
@@ -3233,6 +3302,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
tab.setAttribute('data-mnote-main-tab', objectIdentity);
tab.setAttribute('data-mnote-tab-kind', kind);
tab.setAttribute('data-mnote-tab-badge-kind', resourceTabBadgeKind(input, kind));
tab.setAttribute('tabindex', '-1');
tab.innerHTML = '<span class="mnote-main-tab-badge" aria-hidden="true"></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;
@@ -306,6 +306,7 @@ pub fn DocumentPage(
data-mnote-tab-kind="page"
role="tab"
aria-selected="true"
tabindex="0"
>
<span class="mnote-main-tab-badge" aria-hidden="true"></span>
<span class="mnote-main-tab-title">{title.clone()}</span>
+20
View File
@@ -2417,6 +2417,15 @@ body {
box-shadow: inset 0 1px 0 #E9E9E8, inset 1px 0 0 #E9E9E8, inset -1px 0 0 #E9E9E8;
}
.mnote-main-tab.is-close-guarded {
outline: 2px solid #E03E3E;
outline-offset: -2px;
}
.mnote-main-tab.is-close-guarded .mnote-main-tab-close {
opacity: 0.65;
}
.mnote-main-tab-badge {
width: 14px;
height: 14px;
@@ -2439,6 +2448,14 @@ body {
background: #d94841;
}
.mnote-main-tab[data-mnote-tab-badge-kind="pdf"] .mnote-main-tab-badge {
background: #e74c3c;
}
.mnote-main-tab[data-mnote-tab-badge-kind="file"] .mnote-main-tab-badge {
background: #6b7280;
}
.mnote-main-tab[data-mnote-tab-badge-kind="sheet"] .mnote-main-tab-badge {
background: #2f9e44;
}
@@ -4225,6 +4242,9 @@ mod tests {
assert!(MNOTE_CSS.contains("[data-mnote-tab-badge-kind=\"ppt\"]"));
assert!(MNOTE_CSS.contains("[data-mnote-tab-badge-kind=\"sheet\"]"));
assert!(MNOTE_CSS.contains("[data-mnote-tab-badge-kind=\"code\"]"));
assert!(MNOTE_CSS.contains("[data-mnote-tab-badge-kind=\"pdf\"]"));
assert!(MNOTE_CSS.contains("[data-mnote-tab-badge-kind=\"file\"]"));
assert!(MNOTE_CSS.contains("background: #e74c3c"));
}
#[test]