Align resource tabs with workbench review
This commit is contained in:
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user