Close workbench P0 resource lifecycle gaps
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user