Close workbench P0 resource lifecycle gaps
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
# 4-41 FileTree Bulk Resource Command Cutover Checklist v1
|
||||||
|
|
||||||
|
> 状态:process
|
||||||
|
>
|
||||||
|
> 日期:2026-05-20
|
||||||
|
>
|
||||||
|
> Owner:File Tree bulk delete / `layout.rs`
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
补齐 P0-5 剩余项:前端本地文件夹 bulk delete 对 file asset、mindmap、table、raw local file 等资源优先走 `/api/tree/commands` 的 `tree.resource.*` 语义,旧 route 仅保留云端或兼容 fallback。
|
||||||
|
|
||||||
|
## 2. 允许修改范围
|
||||||
|
|
||||||
|
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
|
||||||
|
- `scripts/task464-local-folder-resource-trash-ui-smoke.js`
|
||||||
|
- 可新增聚焦 bulk delete smoke
|
||||||
|
|
||||||
|
## 3. 禁止事项
|
||||||
|
|
||||||
|
- 不删除旧 `/api/mindmap`、`/api/media`、`/api/tables` 兼容 route。
|
||||||
|
- 不改变 Convex 云端 asset lifecycle。
|
||||||
|
- 不把 bulk delete 改成永久删除;默认仍是 archive/trash。
|
||||||
|
|
||||||
|
## 4. Checklist
|
||||||
|
|
||||||
|
- [x] local file asset bulk delete 使用 `dispatchTreeCommand({ action: 'archive', documentId })`。
|
||||||
|
- [x] local mindmap bulk delete 使用同一 tree command,不再走 `/api/mindmap`。
|
||||||
|
- [x] local table bulk delete 使用同一 tree command,不再走 `/api/tables`。
|
||||||
|
- [x] bulk delete 成功后刷新本地 file tree snapshot / trash index 可见。
|
||||||
|
- [x] 失败项保留可见错误,不清空未成功项。
|
||||||
|
- [x] smoke 覆盖至少两个不同 resource kind 的批量删除进入垃圾箱。
|
||||||
|
|
||||||
|
## 5. 验收
|
||||||
|
|
||||||
|
- [x] `node --check scripts/task464-local-folder-resource-trash-ui-smoke.js`
|
||||||
|
- [x] `node scripts/task464-local-folder-resource-trash-ui-smoke.js`
|
||||||
|
- [x] `node --check scripts/task471-local-folder-bulk-resource-trash-smoke.js`
|
||||||
|
- [x] `node scripts/task471-local-folder-bulk-resource-trash-smoke.js`
|
||||||
|
- [x] 新增 bulk delete 合同测试通过:`cargo test -p mnote-web sidebar_filetree_local_table_bulk_delete_uses_tree_command -- --test-threads=1`
|
||||||
|
- [x] `cargo test -p mnote-web tree_command_local_folder -- --test-threads=1`
|
||||||
|
|
||||||
|
## 5.1 本轮执行记录
|
||||||
|
|
||||||
|
- 已合入:local table bulk delete 分支切到 `/api/tree/commands` archive;file asset / mindmap 原先已经走同一命令。
|
||||||
|
- 已保留:云端 table 仍走 `/api/tables/` fallback,旧 route 未删除。
|
||||||
|
- 已新增:`scripts/task471-local-folder-bulk-resource-trash-smoke.js`,覆盖 local folder 两个资源行多选 Delete、两个 archive tree command、文件移入垃圾箱和 trash index。
|
||||||
|
|
||||||
|
## 6. 非目标
|
||||||
|
|
||||||
|
- 不实现 undo。
|
||||||
|
- 不实现云端 bulk lifecycle parity。
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# 4-42 FileTree Open Target RootUri Contract Checklist v1
|
||||||
|
|
||||||
|
> 状态:process
|
||||||
|
>
|
||||||
|
> 日期:2026-05-20
|
||||||
|
>
|
||||||
|
> Owner:FileTree runtime open target / `filetree_runtime.rs`
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
补齐 P0-4 的剩余合同问题:`FileTreeOpenTarget::LocalFile` / `Directory` 已存在,但 `rootUri` 仍为空字符串占位。需要让 runtime 环境携带本地 root,并让 open target 输出真实 `rootUri`。
|
||||||
|
|
||||||
|
## 2. 允许修改范围
|
||||||
|
|
||||||
|
- `rust/crates/mnote-web/src/tree_shell/filetree_runtime.rs`
|
||||||
|
- `rust/crates/mnote-web/src/tree_shell/runtime_api.rs`
|
||||||
|
- `rust/crates/mnote-web/src/routes/tree.rs`
|
||||||
|
- 相关 Rust 单测
|
||||||
|
|
||||||
|
## 3. 禁止事项
|
||||||
|
|
||||||
|
- 不改变 File Tree row 的长期资源真相。
|
||||||
|
- 不改前端打开行为。
|
||||||
|
- 不删除旧 `Asset` JSON 兼容字段。
|
||||||
|
|
||||||
|
## 4. Checklist
|
||||||
|
|
||||||
|
- [x] `FileTreeRuntimeEnvironment` 携带可选 `rootUri`。
|
||||||
|
- [x] `resolve_open_target` 对 `LocalFile` / `Directory` 使用环境中的 `rootUri`。
|
||||||
|
- [x] tree shell SSR app state / runtime reduce 请求填入 local folder `rootUri`。
|
||||||
|
- [x] runtime API 反序列化对缺失 `rootUri` 保持兼容。
|
||||||
|
- [x] Rust 单测覆盖 local file / directory open target 输出真实 `rootUri`。
|
||||||
|
|
||||||
|
## 5. 验收
|
||||||
|
|
||||||
|
- [x] `cargo test -p mnote-web filetree_runtime -- --test-threads=1`
|
||||||
|
- [x] `cargo test -p mnote-web runtime_api -- --test-threads=1`
|
||||||
|
- [x] `cargo test -p mnote-web tree_shell_filetree_mode_embeds_asset_state -- --test-threads=1`
|
||||||
|
|
||||||
|
## 5.1 本轮执行记录
|
||||||
|
|
||||||
|
- 已合入:`FileTreeRuntimeEnvironment.rootUri`,并在 filetree runtime open target 中用于 local file / directory。
|
||||||
|
- 已补充:tree shell 前端 runtime 环境构造会在 local folder 模式传入 `rootUri`。
|
||||||
|
- 已验证:缺失 `rootUri` 的旧 runtime API JSON 仍可反序列化。
|
||||||
|
|
||||||
|
## 6. 非目标
|
||||||
|
|
||||||
|
- 不处理 resource lifecycle。
|
||||||
|
- 不处理 tab URL 同步。
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# 5-20 Resource Tab Close Confirm Checklist v1
|
||||||
|
|
||||||
|
> 状态:process
|
||||||
|
>
|
||||||
|
> 日期:2026-05-20
|
||||||
|
>
|
||||||
|
> Owner:主编辑区 resource tab close guard / `web_shell.rs`
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
补齐 P0-2 的可见关闭防护:resource tab 已能检测 `dirty/saving/hasExternalConflict`,但当前只是 `console.warn` 并阻止关闭。需要补上用户可见的最小确认/提示 UI,避免“点击无反应”。
|
||||||
|
|
||||||
|
## 2. 允许修改范围
|
||||||
|
|
||||||
|
- `rust/crates/mnote-web/src/routes/web_shell.rs`
|
||||||
|
- `rust/crates/mnote-web/src/ssr/styles.rs`
|
||||||
|
- `scripts/task460-resource-tab-close-dirty-smoke.js`
|
||||||
|
- 可新增一个聚焦 smoke
|
||||||
|
|
||||||
|
## 3. 禁止事项
|
||||||
|
|
||||||
|
- 不引入完整 VSCode working copy service。
|
||||||
|
- 不改变 resource markdown / text / code 的保存事实源。
|
||||||
|
- 不实现完整 discard/undo 体系;本轮只做可见提示和明确阻止。
|
||||||
|
|
||||||
|
## 4. Checklist
|
||||||
|
|
||||||
|
- [x] `dirty` 关闭时显示明确提示,说明资源有未保存更改。
|
||||||
|
- [x] `saving` 关闭时显示明确提示,说明正在保存。
|
||||||
|
- [x] `hasExternalConflict` 关闭时显示明确提示,说明存在外部冲突。
|
||||||
|
- [x] tab 上保留 `data-mnote-resource-close-guard` 与可测试状态。
|
||||||
|
- [x] 提示 UI 可被后续关闭尝试更新,不堆积多个弹层。
|
||||||
|
- [x] 新增或更新 smoke 覆盖点击关闭后出现提示,而不是静默无响应。
|
||||||
|
|
||||||
|
## 5. 验收
|
||||||
|
|
||||||
|
- [x] `node --check scripts/task460-resource-tab-close-dirty-smoke.js`
|
||||||
|
- [x] `node scripts/task460-resource-tab-close-dirty-smoke.js`
|
||||||
|
- [x] `cargo test -p mnote-web web_shell -- --test-threads=1`
|
||||||
|
|
||||||
|
## 5.1 本轮执行记录
|
||||||
|
|
||||||
|
- 已合入:`showResourceTabCloseGuardNotice`,对 `dirty` / `saving` / `hasExternalConflict` 输出可见提示。
|
||||||
|
- 已验证:`task460` 断言可见提示存在且重复点击不堆叠。
|
||||||
|
- 保留约束:仍只阻止关闭,不实现 discard/undo。
|
||||||
|
|
||||||
|
## 6. 非目标
|
||||||
|
|
||||||
|
- 不做真正丢弃更改按钮。
|
||||||
|
- 不做保存队列或 retry 策略。
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# 5-21 Editor Tab URL / Active Row / Canonical Identity Checklist v1
|
||||||
|
|
||||||
|
> 状态:process
|
||||||
|
>
|
||||||
|
> 日期:2026-05-20
|
||||||
|
>
|
||||||
|
> Owner:主编辑区 tab runtime / sidebar active state / resource identity
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
在 P0 清零后推进相邻 P1:resource tab 激活目前主要改 DOM 状态,不稳定同步 URL、sidebar active row 与 canonical identity。先做轻量收口,让用户在 resource tab / page tab 之间切换时有可测试的 active resource 状态。
|
||||||
|
|
||||||
|
## 2. 允许修改范围
|
||||||
|
|
||||||
|
- `rust/crates/mnote-web/src/routes/web_shell.rs`
|
||||||
|
- `rust/crates/mnote-web/src/ssr/pages/layout.rs`
|
||||||
|
- `scripts/task457-main-editor-resource-tab-smoke.js`
|
||||||
|
- 可新增聚焦 smoke
|
||||||
|
|
||||||
|
## 3. 禁止事项
|
||||||
|
|
||||||
|
- 不实现 tab 持久化恢复。
|
||||||
|
- 不改变 Page Aggregate / local Markdown 事实源。
|
||||||
|
- 不实现完整 side group。
|
||||||
|
|
||||||
|
## 4. Checklist
|
||||||
|
|
||||||
|
- [x] resource tab 使用统一 canonical identity,优先 `resource:file:<rootUri>:<path>`。
|
||||||
|
- [x] 激活 resource tab 时写入可测试 URL state 或 history state,不触发整页导航。
|
||||||
|
- [x] 切回 page tab 时清除 active resource state。
|
||||||
|
- [x] sidebar file tree 对应 resource row 能显示 active 状态或至少暴露稳定 DOM 标记。
|
||||||
|
- [x] page tab / resource tab 切换后不会卡死在 resource tab,`task457` 继续覆盖。
|
||||||
|
|
||||||
|
## 5. 验收
|
||||||
|
|
||||||
|
- [x] `node --check scripts/task457-main-editor-resource-tab-smoke.js`
|
||||||
|
- [x] `node scripts/task457-main-editor-resource-tab-smoke.js`
|
||||||
|
- [x] `cargo test -p mnote-web web_shell -- --test-threads=1`
|
||||||
|
|
||||||
|
## 5.1 本轮执行记录
|
||||||
|
|
||||||
|
- 已合入:resource tab 激活时写入 `resourceTab=<canonical identity>` URL state,切回 page tab 或关闭全部资源 tab 时清除。
|
||||||
|
- 已合入:对应 filetree row 已渲染时只设置 `data-active=true`,不污染 `data-selected` 多选状态。
|
||||||
|
- 已验证:`task457` 覆盖 resource tab、Office tab、new-window、关闭回退和 URL state 清理。
|
||||||
|
|
||||||
|
## 6. 非目标
|
||||||
|
|
||||||
|
- 不做跨刷新恢复。
|
||||||
|
- 不做多 editor group。
|
||||||
@@ -106,3 +106,10 @@ local office 可进入主编辑区 tab,Convex office 仍可能新窗口;mind
|
|||||||
- `design/05-editor-mainline/process/5-19-resource-open-resolver-and-onlyoffice-checklist-v1.md`
|
- `design/05-editor-mainline/process/5-19-resource-open-resolver-and-onlyoffice-checklist-v1.md`
|
||||||
- `design/04-tree-domain/process/4-39-filetree-open-target-resource-identity-checklist-v1.md`
|
- `design/04-tree-domain/process/4-39-filetree-open-target-resource-identity-checklist-v1.md`
|
||||||
- `design/04-tree-domain/process/4-40-resource-lifecycle-command-cutover-smoke-checklist-v1.md`
|
- `design/04-tree-domain/process/4-40-resource-lifecycle-command-cutover-smoke-checklist-v1.md`
|
||||||
|
|
||||||
|
第三轮继续收口剩余 P0,并在 P0 清零后推进相邻 P1:
|
||||||
|
|
||||||
|
- `design/05-editor-mainline/process/5-20-resource-tab-close-confirm-checklist-v1.md`
|
||||||
|
- `design/04-tree-domain/process/4-41-filetree-bulk-resource-command-cutover-v1.md`
|
||||||
|
- `design/04-tree-domain/process/4-42-filetree-open-target-rooturi-contract-v1.md`
|
||||||
|
- `design/05-editor-mainline/process/5-21-editor-tab-url-active-identity-checklist-v1.md`
|
||||||
|
|||||||
@@ -3742,6 +3742,17 @@ fn build_tree_shell_html(
|
|||||||
.map((entry) => entry.nodeId),
|
.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 readPageRuntimeState = (action, item) => {
|
||||||
const actionKind = normalizeText(action?.kind).toLowerCase();
|
const actionKind = normalizeText(action?.kind).toLowerCase();
|
||||||
const nodeId = normalizeText(action?.nodeId || item?.nodeId);
|
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 activateMainEditorTab = (objectIdentity) => {
|
||||||
const nodes = resourceTabHostNodes();
|
const nodes = resourceTabHostNodes();
|
||||||
const activeResource = String(objectIdentity || '').trim();
|
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;
|
if (entry.panel instanceof HTMLElement) entry.panel.hidden = !active;
|
||||||
syncResourceTabCloseGuard(entry);
|
syncResourceTabCloseGuard(entry);
|
||||||
});
|
});
|
||||||
|
syncActiveResourceFileTreeRow(activeResource);
|
||||||
|
syncActiveResourceUrlState(activeResource);
|
||||||
};
|
};
|
||||||
|
|
||||||
const bindMainEditorPageTab = () => {
|
const bindMainEditorPageTab = () => {
|
||||||
@@ -3300,6 +3377,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
|||||||
if (guardReason) {
|
if (guardReason) {
|
||||||
console.warn(`mnote resource tab 关闭阻止: ${entry.title} (${guardReason})`);
|
console.warn(`mnote resource tab 关闭阻止: ${entry.title} (${guardReason})`);
|
||||||
syncResourceTabCloseGuard(entry);
|
syncResourceTabCloseGuard(entry);
|
||||||
|
showResourceTabCloseGuardNotice(entry, guardReason);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
removeFromResourceTabMru(key);
|
removeFromResourceTabMru(key);
|
||||||
@@ -3358,7 +3436,17 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
|||||||
panel.hidden = true;
|
panel.hidden = true;
|
||||||
nodes.strip.append(tab);
|
nodes.strip.append(tab);
|
||||||
nodes.panelRoot.append(panel);
|
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) => {
|
const localResourceReadUrl = (rootUri, path) => {
|
||||||
|
|||||||
@@ -3844,7 +3844,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
if (folderCount > 0) parts.push(folderCount + ' 个文件夹(删除到垃圾桶)');
|
if (folderCount > 0) parts.push(folderCount + ' 个文件夹(删除到垃圾桶)');
|
||||||
if (fileCount > 0) parts.push(fileCount + ' 个附件(删除,10 分钟内可撤销)');
|
if (fileCount > 0) parts.push(fileCount + ' 个附件(删除,10 分钟内可撤销)');
|
||||||
if (mindmapCount > 0) parts.push(mindmapCount + ' 个思维导图(移入垃圾桶,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(' + ') + ' 吗?';
|
return '确认删除选中的 ' + parts.join(' + ') + ' 吗?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4006,10 +4006,18 @@ const SIDEBAR_TREE_JS: &str = r##"
|
|||||||
var tableRow = plan.tableRows[t];
|
var tableRow = plan.tableRows[t];
|
||||||
var tableId = fileTreeRowAssetId(tableRow);
|
var tableId = fileTreeRowAssetId(tableRow);
|
||||||
try {
|
try {
|
||||||
var tableResponse = await fetch('/api/tables/' + encodeURIComponent(tableId), { method: 'DELETE' });
|
if (currentSourceKind() === 'local_folder') {
|
||||||
if (!tableResponse.ok) throw new Error('table_delete_failed_' + tableResponse.status);
|
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);
|
removeFileTreeAssetRow(tableId);
|
||||||
window.dispatchEvent(new CustomEvent('online-table-deleted', { detail: { tableId: tableId } }));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failures.push(tableId);
|
failures.push(tableId);
|
||||||
}
|
}
|
||||||
@@ -9187,6 +9195,23 @@ mod tests {
|
|||||||
assert!(SIDEBAR_TREE_JS.contains("确认删除选中的 "));
|
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]
|
#[test]
|
||||||
fn sidebar_tree_runtime_keeps_pdf_and_code_assets_out_of_onlyoffice() {
|
fn sidebar_tree_runtime_keeps_pdf_and_code_assets_out_of_onlyoffice() {
|
||||||
assert!(SIDEBAR_TREE_JS.contains("function inferOnlyOfficeFileType"));
|
assert!(SIDEBAR_TREE_JS.contains("function inferOnlyOfficeFileType"));
|
||||||
|
|||||||
@@ -2426,6 +2426,24 @@ body {
|
|||||||
opacity: 0.65;
|
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 {
|
.mnote-main-tab-badge {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use std::collections::{BTreeMap, BTreeSet};
|
|||||||
pub struct FileTreeRuntimeEnvironment {
|
pub struct FileTreeRuntimeEnvironment {
|
||||||
pub visible_row_ids: Vec<String>,
|
pub visible_row_ids: Vec<String>,
|
||||||
pub rows: Vec<FileTreeRuntimeRow>,
|
pub rows: Vec<FileTreeRuntimeRow>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub root_uri: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -468,12 +470,12 @@ fn resolve_open_target(
|
|||||||
asset_id: row.asset_id.clone()?,
|
asset_id: row.asset_id.clone()?,
|
||||||
}),
|
}),
|
||||||
"local-file" | "local_file" => Some(FileTreeOpenTarget::LocalFile {
|
"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(),
|
path: row.asset_id.clone().unwrap_or_default(),
|
||||||
resource_kind: None,
|
resource_kind: None,
|
||||||
}),
|
}),
|
||||||
"directory" => Some(FileTreeOpenTarget::Directory {
|
"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(),
|
path: row.asset_id.clone().unwrap_or_default(),
|
||||||
}),
|
}),
|
||||||
"asset" => Some(FileTreeOpenTarget::Asset {
|
"asset" => Some(FileTreeOpenTarget::Asset {
|
||||||
@@ -545,6 +547,7 @@ mod tests {
|
|||||||
asset_id: Some("image".into()),
|
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()),
|
asset_id: Some("image".into()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
root_uri: None,
|
||||||
},
|
},
|
||||||
state: FileTreeRuntimeState::default(),
|
state: FileTreeRuntimeState::default(),
|
||||||
action: FileTreeRuntimeAction::SelectRow {
|
action: FileTreeRuntimeAction::SelectRow {
|
||||||
@@ -645,6 +646,7 @@ mod tests {
|
|||||||
asset_id: Some("image".into()),
|
asset_id: Some("image".into()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
root_uri: None,
|
||||||
},
|
},
|
||||||
state: FileTreeRuntimeState {
|
state: FileTreeRuntimeState {
|
||||||
selection: FileTreeSelectionState::from_selected(&["asset:image".to_string()]),
|
selection: FileTreeSelectionState::from_selected(&["asset:image".to_string()]),
|
||||||
@@ -688,6 +690,7 @@ mod tests {
|
|||||||
asset_id: Some("image".into()),
|
asset_id: Some("image".into()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
root_uri: None,
|
||||||
},
|
},
|
||||||
state: FileTreeRuntimeState::default(),
|
state: FileTreeRuntimeState::default(),
|
||||||
action: FileTreeRuntimeAction::UpdateDropTarget {
|
action: FileTreeRuntimeAction::UpdateDropTarget {
|
||||||
@@ -726,6 +729,7 @@ mod tests {
|
|||||||
asset_id: Some("image".into()),
|
asset_id: Some("image".into()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
root_uri: None,
|
||||||
},
|
},
|
||||||
state: FileTreeRuntimeState {
|
state: FileTreeRuntimeState {
|
||||||
drag_row_ids: ids(&["asset:image"]),
|
drag_row_ids: ids(&["asset:image"]),
|
||||||
@@ -774,6 +778,7 @@ mod tests {
|
|||||||
document_id: Some("root".into()),
|
document_id: Some("root".into()),
|
||||||
asset_id: None,
|
asset_id: None,
|
||||||
}],
|
}],
|
||||||
|
root_uri: None,
|
||||||
},
|
},
|
||||||
state: FileTreeRuntimeState::default(),
|
state: FileTreeRuntimeState::default(),
|
||||||
action: FileTreeRuntimeAction::DispatchExternalDrop {
|
action: FileTreeRuntimeAction::DispatchExternalDrop {
|
||||||
|
|||||||
@@ -255,6 +255,22 @@ async function main() {
|
|||||||
});
|
});
|
||||||
assert.equal(activeAfterOfficeClose.kind, "markdown", `关闭 office tab 后应回到 markdown tab: ${JSON.stringify(activeAfterOfficeClose)}`);
|
assert.equal(activeAfterOfficeClose.kind, "markdown", `关闭 office tab 后应回到 markdown tab: ${JSON.stringify(activeAfterOfficeClose)}`);
|
||||||
assert(activeAfterOfficeClose.text.includes("resource-note.md"), `markdown tab 标题应包含资源名: ${JSON.stringify(activeAfterOfficeClose)}`);
|
assert(activeAfterOfficeClose.text.includes("resource-note.md"), `markdown tab 标题应包含资源名: ${JSON.stringify(activeAfterOfficeClose)}`);
|
||||||
|
const markdownActiveState = await page.evaluate(() => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const activeResource = url.searchParams.get("resourceTab") || "";
|
||||||
|
const activeRow = document.querySelector('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"]');
|
||||||
|
const resourceRows = Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id]')).map((row) => row.getAttribute("data-asset-id") || "");
|
||||||
|
return {
|
||||||
|
activeResource,
|
||||||
|
activeRowAssetId: activeRow?.getAttribute("data-asset-id") || "",
|
||||||
|
resourceRows,
|
||||||
|
selectedRows: Array.from(document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"]')).map((row) => row.getAttribute("data-asset-id") || row.getAttribute("data-row-id") || ""),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert(markdownActiveState.activeResource.includes("resource-note.md"), `激活 resource tab 应写入 canonical resourceTab URL 状态: ${JSON.stringify(markdownActiveState)}`);
|
||||||
|
if (markdownActiveState.resourceRows.includes(asset.id)) {
|
||||||
|
assert.equal(markdownActiveState.activeRowAssetId, asset.id, `已渲染对应 filetree row 时应标记 data-active: ${JSON.stringify(markdownActiveState)}`);
|
||||||
|
}
|
||||||
|
|
||||||
const mdCloseBtn = page.locator('.mnote-main-tab.is-active[data-mnote-tab-kind="markdown"] .mnote-main-tab-close');
|
const mdCloseBtn = page.locator('.mnote-main-tab.is-active[data-mnote-tab-kind="markdown"] .mnote-main-tab-close');
|
||||||
await mdCloseBtn.click({ timeout: UI_TIMEOUT_MS });
|
await mdCloseBtn.click({ timeout: UI_TIMEOUT_MS });
|
||||||
@@ -270,6 +286,7 @@ async function main() {
|
|||||||
assert.equal(activeAfterAllClosed.kind, "page", `关闭所有 resource tab 后应回到 page tab: ${JSON.stringify(activeAfterAllClosed)}`);
|
assert.equal(activeAfterAllClosed.kind, "page", `关闭所有 resource tab 后应回到 page tab: ${JSON.stringify(activeAfterAllClosed)}`);
|
||||||
assert.equal(activeAfterAllClosed.resourceTabCount, 0, `resource tab 应全部关闭: ${JSON.stringify(activeAfterAllClosed)}`);
|
assert.equal(activeAfterAllClosed.resourceTabCount, 0, `resource tab 应全部关闭: ${JSON.stringify(activeAfterAllClosed)}`);
|
||||||
assert.equal(activeAfterAllClosed.panelHidden, true, `resource tab host 应隐藏: ${JSON.stringify(activeAfterAllClosed)}`);
|
assert.equal(activeAfterAllClosed.panelHidden, true, `resource tab host 应隐藏: ${JSON.stringify(activeAfterAllClosed)}`);
|
||||||
|
assert.equal(new URL(page.url()).searchParams.get("resourceTab"), null, "关闭所有 resource tab 后应清除 resourceTab URL 状态");
|
||||||
|
|
||||||
console.log(JSON.stringify({ ok: true, root, assetPath, assetId: asset.id, officeAssetId: officeAsset.id }, null, 2));
|
console.log(JSON.stringify({ ok: true, root, assetPath, assetId: asset.id, officeAssetId: officeAsset.id }, null, 2));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ async function main() {
|
|||||||
return {
|
return {
|
||||||
guardAttr,
|
guardAttr,
|
||||||
hasGuardClass,
|
hasGuardClass,
|
||||||
|
noticeText: document.querySelector('[data-mnote-resource-close-guard="dirty"]')?.textContent || "",
|
||||||
tabStillPresent: activeTab !== null,
|
tabStillPresent: activeTab !== null,
|
||||||
kind: activeTab?.getAttribute('data-mnote-tab-kind') || '',
|
kind: activeTab?.getAttribute('data-mnote-tab-kind') || '',
|
||||||
};
|
};
|
||||||
@@ -191,6 +192,7 @@ async function main() {
|
|||||||
assert(blockedState.hasGuardClass, `dirty tab 应具有 is-close-guarded class: ${JSON.stringify(blockedState)}`);
|
assert(blockedState.hasGuardClass, `dirty tab 应具有 is-close-guarded class: ${JSON.stringify(blockedState)}`);
|
||||||
assert.equal(blockedState.guardAttr, 'dirty', `close-guard attribute 应为 dirty: ${JSON.stringify(blockedState)}`);
|
assert.equal(blockedState.guardAttr, 'dirty', `close-guard attribute 应为 dirty: ${JSON.stringify(blockedState)}`);
|
||||||
assert.equal(blockedState.kind, 'markdown', `dirty tab 关闭阻止后应仍然激活: ${JSON.stringify(blockedState)}`);
|
assert.equal(blockedState.kind, 'markdown', `dirty tab 关闭阻止后应仍然激活: ${JSON.stringify(blockedState)}`);
|
||||||
|
assert.match(blockedState.noticeText, /未保存|保存完成/, `dirty 关闭阻止应显示可见提示: ${JSON.stringify(blockedState)}`);
|
||||||
|
|
||||||
// 输入后的短保护窗口内再次关闭仍应被阻止,不应静默释放 resource editor。
|
// 输入后的短保护窗口内再次关闭仍应被阻止,不应静默释放 resource editor。
|
||||||
await closeBtn.click({ timeout: UI_TIMEOUT_MS });
|
await closeBtn.click({ timeout: UI_TIMEOUT_MS });
|
||||||
@@ -201,11 +203,13 @@ async function main() {
|
|||||||
kind: activeTab?.getAttribute('data-mnote-tab-kind') || 'none',
|
kind: activeTab?.getAttribute('data-mnote-tab-kind') || 'none',
|
||||||
resourceTabCount: document.querySelectorAll('.mnote-main-tab[data-mnote-tab-kind]:not([data-mnote-tab-kind="page"])').length,
|
resourceTabCount: document.querySelectorAll('.mnote-main-tab[data-mnote-tab-kind]:not([data-mnote-tab-kind="page"])').length,
|
||||||
guardAttr: activeTab?.getAttribute('data-resource-tab-close-guarded') || '',
|
guardAttr: activeTab?.getAttribute('data-resource-tab-close-guarded') || '',
|
||||||
|
noticeCount: document.querySelectorAll('[data-mnote-resource-close-guard="dirty"]').length,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
assert.equal(afterSecondCloseState.kind, 'markdown', `保护窗口内二次关闭后仍应停留在 resource tab: ${JSON.stringify(afterSecondCloseState)}`);
|
assert.equal(afterSecondCloseState.kind, 'markdown', `保护窗口内二次关闭后仍应停留在 resource tab: ${JSON.stringify(afterSecondCloseState)}`);
|
||||||
assert.equal(afterSecondCloseState.resourceTabCount, 1, `保护窗口内 resource tab 不应被移除: ${JSON.stringify(afterSecondCloseState)}`);
|
assert.equal(afterSecondCloseState.resourceTabCount, 1, `保护窗口内 resource tab 不应被移除: ${JSON.stringify(afterSecondCloseState)}`);
|
||||||
assert.equal(afterSecondCloseState.guardAttr, 'dirty', `保护窗口内应保留 dirty guard: ${JSON.stringify(afterSecondCloseState)}`);
|
assert.equal(afterSecondCloseState.guardAttr, 'dirty', `保护窗口内应保留 dirty guard: ${JSON.stringify(afterSecondCloseState)}`);
|
||||||
|
assert.equal(afterSecondCloseState.noticeCount, 1, `关闭提示不应重复堆叠: ${JSON.stringify(afterSecondCloseState)}`);
|
||||||
|
|
||||||
console.log(JSON.stringify({ ok: true, root, assetId: asset.id }, null, 2));
|
console.log(JSON.stringify({ ok: true, root, assetId: asset.id }, null, 2));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
|
||||||
|
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||||
|
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||||
|
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||||
|
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/bin/chromium"]
|
||||||
|
.find((candidate) => fs.existsSync(candidate));
|
||||||
|
|
||||||
|
function fileUrl(localPath) {
|
||||||
|
return `file://${localPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeWorkspaceManifest(root) {
|
||||||
|
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, ".mnote", "workspace.json"),
|
||||||
|
`${JSON.stringify({
|
||||||
|
workspaceId: "local-ws:user_real:task471",
|
||||||
|
ownerId: "user_real",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
capabilities: ["local_files", "tree_commands", "markdown_edit", "asset_upload"],
|
||||||
|
}, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickLogin(page) {
|
||||||
|
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||||
|
if (await quickLoginButton.count()) {
|
||||||
|
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTrashIndex(root) {
|
||||||
|
const indexPath = path.join(root, ".mnote", "trash-index.json");
|
||||||
|
if (!fs.existsSync(indexPath)) return [];
|
||||||
|
const raw = fs.readFileSync(indexPath, "utf8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed)) return parsed;
|
||||||
|
if (parsed && typeof parsed === "object" && parsed.entries && typeof parsed.entries === "object") {
|
||||||
|
return Object.entries(parsed.entries).map(([id, entry]) => ({ id, ...entry }));
|
||||||
|
}
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
return Object.entries(parsed).map(([id, entry]) => ({ id, ...entry }));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task471-bulk-trash-"));
|
||||||
|
const rootUri = fileUrl(root);
|
||||||
|
const assets = [
|
||||||
|
{ assetId: "local-file:docs/附件-a.txt", filePath: path.join(root, "docs", "附件-a.txt") },
|
||||||
|
{ assetId: "local-file:docs/附件-b.json", filePath: path.join(root, "docs", "附件-b.json") },
|
||||||
|
];
|
||||||
|
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
|
||||||
|
writeWorkspaceManifest(root);
|
||||||
|
fs.writeFileSync(path.join(root, "README.md"), "# Bulk Resource Trash\n", "utf8");
|
||||||
|
fs.writeFileSync(assets[0].filePath, "asset a\n", "utf8");
|
||||||
|
fs.writeFileSync(assets[1].filePath, "{\"asset\":\"b\"}\n", "utf8");
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
executablePath: CHROMIUM_EXECUTABLE_PATH,
|
||||||
|
});
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1360, height: 900 },
|
||||||
|
extraHTTPHeaders: {
|
||||||
|
"x-mnote-actor-id": "user_real",
|
||||||
|
"x-mnote-actor-type": "user",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
const treeCommands = [];
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (!request.url().includes("/api/tree/commands")) return;
|
||||||
|
const body = request.postDataJSON?.() || null;
|
||||||
|
treeCommands.push({ method: request.method(), body });
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await quickLogin(page);
|
||||||
|
const url = new URL(`${BASE_URL}/`);
|
||||||
|
url.searchParams.set("sourceKind", "local_folder");
|
||||||
|
url.searchParams.set("rootUri", rootUri);
|
||||||
|
url.searchParams.set("treeView", "filetree");
|
||||||
|
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||||
|
|
||||||
|
const selectors = assets.map((asset) => `#sidebar-file-tree-root .tree-row[data-asset-id="${asset.assetId}"]`);
|
||||||
|
await page.locator(selectors[0]).waitFor({
|
||||||
|
state: "visible",
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
await page.locator(selectors[1]).waitFor({
|
||||||
|
state: "visible",
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const rowIds = await Promise.all(selectors.map((selector) =>
|
||||||
|
page.locator(selector).first().getAttribute("data-row-id", { timeout: UI_TIMEOUT_MS }),
|
||||||
|
));
|
||||||
|
assert(rowIds.every(Boolean), `资源行应有 data-row-id: ${JSON.stringify(rowIds)}`);
|
||||||
|
|
||||||
|
await page.locator(`${selectors[0]} .tree-link`).click({
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
await page.locator(`${selectors[1]} .tree-link`).click({
|
||||||
|
modifiers: [process.platform === "darwin" ? "Meta" : "Control"],
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const selectedBefore = await page.locator('#sidebar-file-tree-root .tree-row[data-selected="true"]').evaluateAll((rows) =>
|
||||||
|
rows.map((row) => row.getAttribute("data-row-id") || ""),
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
selectedBefore.sort(),
|
||||||
|
rowIds.slice().sort(),
|
||||||
|
`bulk delete 前应选中两个资源行: ${JSON.stringify(selectedBefore)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialogPromise = page.waitForEvent("dialog", { timeout: UI_TIMEOUT_MS }).then(async (dialog) => {
|
||||||
|
const message = dialog.message();
|
||||||
|
await dialog.accept();
|
||||||
|
return message;
|
||||||
|
});
|
||||||
|
await page.locator(selectors[1]).press("Delete", {
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
const confirmText = await dialogPromise;
|
||||||
|
assert.match(confirmText, /2 个附件/, `bulk delete 确认文案应包含附件数量: ${confirmText}`);
|
||||||
|
|
||||||
|
await page.waitForFunction(() => document.documentElement.getAttribute("data-mnote-filetree-bulk-delete-applied") === "true", null, {
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
for (const asset of assets) {
|
||||||
|
await page.locator(`#sidebar-file-tree-root .tree-row[data-asset-id="${asset.assetId}"]`).waitFor({
|
||||||
|
state: "detached",
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
assert.equal(fs.existsSync(asset.filePath), false, `${asset.assetId} 应移入垃圾箱`);
|
||||||
|
}
|
||||||
|
const archiveCommands = treeCommands.filter((entry) => entry.body && entry.body.action === "archive");
|
||||||
|
assert.equal(archiveCommands.length, 2, `bulk delete 应发出两个 archive tree command: ${JSON.stringify(treeCommands)}`);
|
||||||
|
assert.deepEqual(
|
||||||
|
archiveCommands.map((entry) => entry.body.documentId).sort(),
|
||||||
|
assets.map((asset) => asset.assetId).sort(),
|
||||||
|
`archive command 应覆盖两个资源: ${JSON.stringify(archiveCommands)}`,
|
||||||
|
);
|
||||||
|
const trashIds = readTrashIndex(root).map((entry) => entry.id || entry.assetId).sort();
|
||||||
|
assert.deepEqual(
|
||||||
|
trashIds.filter((id) => assets.some((asset) => asset.assetId === id)),
|
||||||
|
assets.map((asset) => asset.assetId).sort(),
|
||||||
|
`trash index 应包含两个资源: ${JSON.stringify(trashIds)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ ok: true, root, archived: rowIds }, null, 2));
|
||||||
|
} finally {
|
||||||
|
await context.close().catch(() => undefined);
|
||||||
|
await browser.close().catch(() => undefined);
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error && error.stack ? error.stack : error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user