Close workbench P0 resource lifecycle gaps

This commit is contained in:
lix-2026
2026-05-20 20:48:18 +08:00
parent e0b0e70fb8
commit fe13444dbc
14 changed files with 610 additions and 7 deletions
+11
View File
@@ -3742,6 +3742,17 @@ fn build_tree_shell_html(
.map((entry) => entry.nodeId),
});
const buildFileTreeRuntimeEnvironment = () => ({
visibleRowIds: visibleFileTreeRowIds,
rows: Array.from(fileTreeRowById.values()).map((entry) => ({
rowId: entry.rowId,
rowKind: entry.rowKind,
documentId: entry.documentId || null,
assetId: entry.assetId || null,
})),
rootUri: sourceKind === "local_folder" ? rootUri || null : null,
});
const readPageRuntimeState = (action, item) => {
const actionKind = normalizeText(action?.kind).toLowerCase();
const nodeId = normalizeText(action?.nodeId || item?.nodeId);
+89 -1
View File
@@ -3254,6 +3254,81 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
});
};
const showResourceTabCloseGuardNotice = (entry, reason) => {
const nodes = resourceTabHostNodes();
const messages = {
dirty: '当前资源有未保存的修改,保存完成后再关闭。',
saving: '当前资源正在保存中,请稍后再关闭。',
hasExternalConflict: '当前资源存在外部冲突,请先处理冲突。',
};
const message = messages[reason] || '当前资源暂时无法关闭。';
let notice = document.getElementById('mnote-resource-close-guard-notice');
if (!notice) {
notice = document.createElement('div');
notice.id = 'mnote-resource-close-guard-notice';
notice.className = 'mnote-close-guard-notice';
notice.setAttribute('role', 'status');
notice.setAttribute('aria-live', 'polite');
notice.setAttribute('data-mnote-resource-close-guard', '');
const parent = nodes.strip?.parentNode;
if (parent instanceof HTMLElement) {
const panels = parent.querySelector('.mnote-main-tab-panels');
if (panels && panels.parentNode === parent) parent.insertBefore(notice, panels);
else parent.append(notice);
}
}
notice.textContent = `${entry?.title || '资源'}${message}`;
notice.setAttribute('data-mnote-resource-close-guard', reason || 'blocked');
notice.className = `mnote-close-guard-notice is-${reason || 'blocked'}`;
if (notice._mnoteHideTimer) window.clearTimeout(notice._mnoteHideTimer);
notice._mnoteHideTimer = window.setTimeout(() => {
notice.classList.add('is-hiding');
window.setTimeout(() => {
if (notice.parentNode) notice.remove();
}, 260);
}, 4000);
};
const cssSafe = (value) => {
const text = String(value || '');
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(text);
return text.replace(/["\\]/g, '\\$&');
};
const syncActiveResourceFileTreeRow = (activeResource) => {
document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]').forEach((row) => {
if (row instanceof HTMLElement) row.setAttribute('data-active', 'false');
});
const entry = activeResource ? resourceTabRegistry.get(activeResource) : null;
if (!entry) return;
const assetId = String(entry.assetId || entry.session?.assetId || '').trim();
const path = String(entry.path || entry.session?.resourcePath || '').trim();
const identity = String(entry.objectIdentity || activeResource || '').trim();
if (assetId) {
const row = document.querySelector(`#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssSafe(assetId)}"]`);
if (row instanceof HTMLElement) {
row.setAttribute('data-active', 'true');
return;
}
}
const rows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-object-identity]');
for (const row of rows) {
if (!(row instanceof HTMLElement)) continue;
const objectIdentity = row.getAttribute('data-object-identity') || '';
if ((identity && objectIdentity.includes(identity)) || (path && objectIdentity.includes(path))) {
row.setAttribute('data-active', 'true');
return;
}
}
};
const syncActiveResourceUrlState = (activeResource) => {
const url = currentUrl();
if (activeResource) url.searchParams.set('resourceTab', activeResource);
else url.searchParams.delete('resourceTab');
replaceUrlState(url);
};
const activateMainEditorTab = (objectIdentity) => {
const nodes = resourceTabHostNodes();
const activeResource = String(objectIdentity || '').trim();
@@ -3279,6 +3354,8 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
if (entry.panel instanceof HTMLElement) entry.panel.hidden = !active;
syncResourceTabCloseGuard(entry);
});
syncActiveResourceFileTreeRow(activeResource);
syncActiveResourceUrlState(activeResource);
};
const bindMainEditorPageTab = () => {
@@ -3300,6 +3377,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
if (guardReason) {
console.warn(`mnote resource tab 关闭阻止: ${entry.title} (${guardReason})`);
syncResourceTabCloseGuard(entry);
showResourceTabCloseGuardNotice(entry, guardReason);
return;
}
removeFromResourceTabMru(key);
@@ -3358,7 +3436,17 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
panel.hidden = true;
nodes.strip.append(tab);
nodes.panelRoot.append(panel);
return { objectIdentity, title, kind, tab, panel, view: null, session: null };
return {
objectIdentity,
title,
kind,
tab,
panel,
view: null,
session: null,
assetId: String(input.assetId || '').trim(),
path: String(input.path || '').trim(),
};
};
const localResourceReadUrl = (rootUri, path) => {
+29 -4
View File
@@ -3844,7 +3844,7 @@ const SIDEBAR_TREE_JS: &str = r##"
if (folderCount > 0) parts.push(folderCount + ' ');
if (fileCount > 0) parts.push(fileCount + ' 10 ');
if (mindmapCount > 0) parts.push(mindmapCount + ' 10 ');
if (tableCount > 0) parts.push(tableCount + ' 线');
if (tableCount > 0) parts.push(tableCount + (currentSourceKind() === 'local_folder' ? ' 线10 ' : ' 线'));
return ' ' + parts.join(' + ') + ' ';
}
@@ -4006,10 +4006,18 @@ const SIDEBAR_TREE_JS: &str = r##"
var tableRow = plan.tableRows[t];
var tableId = fileTreeRowAssetId(tableRow);
try {
var tableResponse = await fetch('/api/tables/' + encodeURIComponent(tableId), { method: 'DELETE' });
if (!tableResponse.ok) throw new Error('table_delete_failed_' + tableResponse.status);
if (currentSourceKind() === 'local_folder') {
await dispatchTreeCommand(trigger || tableRow, {
action: 'archive',
workspaceId: resolveWorkspaceId(tableRow),
documentId: tableId
});
} else {
var tableResponse = await fetch('/api/tables/' + encodeURIComponent(tableId), { method: 'DELETE' });
if (!tableResponse.ok) throw new Error('table_delete_failed_' + tableResponse.status);
window.dispatchEvent(new CustomEvent('online-table-deleted', { detail: { tableId: tableId } }));
}
removeFileTreeAssetRow(tableId);
window.dispatchEvent(new CustomEvent('online-table-deleted', { detail: { tableId: tableId } }));
} catch (error) {
failures.push(tableId);
}
@@ -9187,6 +9195,23 @@ mod tests {
assert!(SIDEBAR_TREE_JS.contains("确认删除选中的 "));
}
#[test]
fn sidebar_filetree_local_table_bulk_delete_uses_tree_command() {
let table_loop_start = SIDEBAR_TREE_JS
.find("for (var t = 0; t < plan.tableRows.length; t += 1)")
.expect("table bulk delete loop");
let table_loop_end = SIDEBAR_TREE_JS[table_loop_start..]
.find("sidebarFileTreeSelection.selectedRowIds")
.map(|offset| table_loop_start + offset)
.expect("table bulk delete loop end");
let table_loop = &SIDEBAR_TREE_JS[table_loop_start..table_loop_end];
assert!(table_loop.contains("currentSourceKind() === 'local_folder'"));
assert!(table_loop.contains("dispatchTreeCommand(trigger || tableRow"));
assert!(table_loop.contains("action: 'archive'"));
assert!(table_loop.contains("documentId: tableId"));
assert!(table_loop.contains("/api/tables/"));
}
#[test]
fn sidebar_tree_runtime_keeps_pdf_and_code_assets_out_of_onlyoffice() {
assert!(SIDEBAR_TREE_JS.contains("function inferOnlyOfficeFileType"));
+18
View File
@@ -2426,6 +2426,24 @@ body {
opacity: 0.65;
}
.mnote-close-guard-notice {
display: flex;
align-items: center;
min-height: 34px;
padding: 7px 16px;
border-bottom: 1px solid #fecaca;
background: #fff3f3;
color: #991b1b;
font-size: 13px;
line-height: 1.45;
transition: opacity 160ms ease;
}
.mnote-close-guard-notice.is-hiding {
opacity: 0;
pointer-events: none;
}
.mnote-main-tab-badge {
width: 14px;
height: 14px;
@@ -8,6 +8,8 @@ use std::collections::{BTreeMap, BTreeSet};
pub struct FileTreeRuntimeEnvironment {
pub visible_row_ids: Vec<String>,
pub rows: Vec<FileTreeRuntimeRow>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root_uri: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -468,12 +470,12 @@ fn resolve_open_target(
asset_id: row.asset_id.clone()?,
}),
"local-file" | "local_file" => Some(FileTreeOpenTarget::LocalFile {
root_uri: String::new(),
root_uri: env.root_uri.clone().unwrap_or_default(),
path: row.asset_id.clone().unwrap_or_default(),
resource_kind: None,
}),
"directory" => Some(FileTreeOpenTarget::Directory {
root_uri: String::new(),
root_uri: env.root_uri.clone().unwrap_or_default(),
path: row.asset_id.clone().unwrap_or_default(),
}),
"asset" => Some(FileTreeOpenTarget::Asset {
@@ -545,6 +547,7 @@ mod tests {
asset_id: Some("image".into()),
},
],
root_uri: Some("file:///tmp/mnote".into()),
}
}
@@ -865,4 +868,55 @@ mod tests {
})
);
}
#[test]
fn filetree_open_target_uses_environment_root_uri_for_local_targets() {
let mut env = env();
env.visible_row_ids = ids(&["local-file:Page/资源.ext", "local-dir:Page/assets"]);
env.rows = vec![
FileTreeRuntimeRow {
row_id: "local-file:Page/资源.ext".into(),
row_kind: "local-file".into(),
document_id: None,
asset_id: Some("Page/资源.ext".into()),
},
FileTreeRuntimeRow {
row_id: "local-dir:Page/assets".into(),
row_kind: "directory".into(),
document_id: None,
asset_id: Some("Page/assets".into()),
},
];
let open_file = FileTreeRuntimeState::default().reduce(
&env,
FileTreeRuntimeAction::OpenRow {
row_id: "local-file:Page/资源.ext".into(),
},
);
assert!(open_file.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::Open {
target: FileTreeOpenTarget::LocalFile {
root_uri: "file:///tmp/mnote".into(),
path: "Page/资源.ext".into(),
resource_kind: None,
},
},
)));
let open_dir = FileTreeRuntimeState::default().reduce(
&env,
FileTreeRuntimeAction::OpenRow {
row_id: "local-dir:Page/assets".into(),
},
);
assert!(open_dir.outputs.contains(&FileTreeRuntimeOutput::Intent(
FileTreeIntentEvent::Open {
target: FileTreeOpenTarget::Directory {
root_uri: "file:///tmp/mnote".into(),
path: "Page/assets".into(),
},
},
)));
}
}
@@ -605,6 +605,7 @@ mod tests {
asset_id: Some("image".into()),
},
],
root_uri: None,
},
state: FileTreeRuntimeState::default(),
action: FileTreeRuntimeAction::SelectRow {
@@ -645,6 +646,7 @@ mod tests {
asset_id: Some("image".into()),
},
],
root_uri: None,
},
state: FileTreeRuntimeState {
selection: FileTreeSelectionState::from_selected(&["asset:image".to_string()]),
@@ -688,6 +690,7 @@ mod tests {
asset_id: Some("image".into()),
},
],
root_uri: None,
},
state: FileTreeRuntimeState::default(),
action: FileTreeRuntimeAction::UpdateDropTarget {
@@ -726,6 +729,7 @@ mod tests {
asset_id: Some("image".into()),
},
],
root_uri: None,
},
state: FileTreeRuntimeState {
drag_row_ids: ids(&["asset:image"]),
@@ -774,6 +778,7 @@ mod tests {
document_id: Some("root".into()),
asset_id: None,
}],
root_uri: None,
},
state: FileTreeRuntimeState::default(),
action: FileTreeRuntimeAction::DispatchExternalDrop {