refactor: extract filetree projection helpers

This commit is contained in:
lix-2026
2026-05-25 04:02:36 +08:00
parent 50ed7456ca
commit 9798888909
4 changed files with 183 additions and 14 deletions
@@ -147,3 +147,10 @@ UI / 浏览器可见项必须有真实浏览器截图或结构化 smoke 证据
- 2026-05-25Batch L Worker B `reasonix-2026-05-24T19-30-30-509Z-18f62949` 只读审计 `refreshLocalFolderSidebarSnapshot`Codex 已读取 Hindsight recall、`process-handoff.md/json``result.json`,并核对 worktree diff 为空。
- 审计结论:`refreshLocalFolderSidebarSnapshot` 仍是 shell 编排,包含 workspace/root/document 状态、两条 projection fetch、render 后副作用和 watcher 标记,不应整体迁入 `filetree-runtime.js`
- 下一刀建议:优先迁投影数据读取器层(如 `readProjection``readDatasetProjection``projectionItems`、row/id/title/file accessor、`groupRowsByParent`),或更保守地先迁未使用的 `replaceSidebarTreeFromDocument`Codex 本轮不直接实现 3-20 代码,只记录候选。
- 2026-05-25Batch M 按用户纠正尝试并行派发 3 个 Reasonix 实现 worker,均使用独立 worktreerun `reasonix-2026-05-24T19-40-19-862Z-b3029108` / `reasonix-2026-05-24T19-40-19-873Z-4347ed16` 仅有 `prompt.md``memory-recall.json``reasonix-transcript.jsonl`,无 `final.md``result.json``process-handoff.md/json`,不采纳。
- 失败原因:Reasonix ACP 并发串线,Worker C transcript 将 B/C/D 三个任务混为一个选择题并要求人工选择;不满足本文件 handoff 合同。
- 主控处置:不从 Batch M 合入任何 diff;改用 Batch N 的独立 worktree 子 agent worker 继续推进,仍由 Codex 复核 diff 和验证。
- 2026-05-25Batch N 已启动 2 个 3-20 独立 worktree workerWorker C `/mnt/Data1T/mnote-worktrees/0525-n-worker-c-3-20-projection-helpers` 负责 filetree projection data helper 外置;Worker D `/mnt/Data1T/mnote-worktrees/0525-n-worker-d-3-20-task435-smoke-assertion` 负责 `task435` local-folder watch projection 断言。Codex 已复核两个 worktree diff 并选择性移植。
- Worker C`filetree-runtime.js` 新增并导出 `readProjection``readSidebarDataset``readDatasetProjection``projectionItems``hasProjectionItems``nodeIdOf``rowIdOf``parentIdOf``titleOf``fileWorkspaceRelativePath``groupRowsByParent``layout.rs` 对应 inline fallback 优先委托 `fileTreeRuntimeFunction(...)`,未迁 `refreshLocalFolderSidebarSnapshot` / `startLocalFolderSidebarWatch` / fetch / post-render shell 副作用 / HTML renderer。
- Worker D`task435-local-folder-watch-no-reload-smoke.js` 新增 `waitForLocalFolderWatchProjectionApplied`,每个外部文件变化步骤前清理旧标记,步骤内显式等待 `data-mnote-local-folder-watch-applied="projection"`,未放宽 no-reload 断言;Codex 同步把初始 `Stable Page` 等待修正为当前 file tree 标题 `stable`
- 已验证:`node --check rust/crates/mnote-web/browser/filetree-runtime.js``node --check scripts/task435-local-folder-watch-no-reload-smoke.js``cargo fmt --manifest-path rust/Cargo.toml --all --check``cargo check --manifest-path rust/Cargo.toml -p mnote-web``cargo test --manifest-path rust/Cargo.toml -p mnote-web filetree_runtime_contains_row_accessor_helpers -- --test-threads=1``cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_filetree_runtime_helpers_are_externalized_with_inline_fallback -- --test-threads=1``cargo test --manifest-path rust/Cargo.toml -p mnote-web sidebar_tree_runtime_polls_local_folder_without_browser_reload -- --test-threads=1``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task435-local-folder-watch-no-reload-smoke.js``MNOTE_WEB_SMOKE_BASE_URL=http://127.0.0.1:3001 NODE_PATH=/mnt/Data1T/mnote/node_modules node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js`
@@ -137,6 +137,83 @@ function defaultObjectIdentityAttr(identity) {
}
}
function readProjection(value) {
if (!value || typeof value !== 'object') return null;
if (value.result && typeof value.result === 'object') return value.result;
if (value.snapshot && value.snapshot.tree) return value.snapshot.tree;
if (value.data && value.data.tree) return value.data.tree;
if (value.tree && typeof value.tree === 'object') return value.tree;
return value;
}
function readSidebarDataset(value) {
if (!value || typeof value !== 'object') return null;
if (value.snapshot && value.snapshot.dataset && typeof value.snapshot.dataset === 'object') return value.snapshot.dataset;
if (value.data && value.data.dataset && typeof value.data.dataset === 'object') return value.data.dataset;
if (value.dataset && typeof value.dataset === 'object') return value.dataset;
if (value.sidebar && typeof value.sidebar === 'object') return value.sidebar;
return null;
}
function readDatasetProjection(value, snakeCaseKey, camelCaseKey) {
var dataset = readSidebarDataset(value);
if (!dataset || typeof dataset !== 'object') return null;
if (dataset[snakeCaseKey] && typeof dataset[snakeCaseKey] === 'object') return dataset[snakeCaseKey];
if (dataset[camelCaseKey] && typeof dataset[camelCaseKey] === 'object') return dataset[camelCaseKey];
return null;
}
function projectionItems(projection) {
var resolved = readProjection(projection);
return resolved && Array.isArray(resolved.items) ? resolved.items : [];
}
function hasProjectionItems(projection) {
var resolved = readProjection(projection);
return Boolean(resolved && Array.isArray(resolved.items));
}
function nodeIdOf(item) {
return String(item && (item.nodeId || item.id || item.documentId) || '').trim();
}
function rowIdOf(item) {
return String(item && (item.rowId || item.nodeId || item.id) || '').trim();
}
function parentIdOf(item) {
return String(item && (item.parentNodeId || item.parentId || '') || '').trim();
}
function titleOf(item) {
return String(item && item.title || '无标题').trim() || '无标题';
}
function fileWorkspaceRelativePath(item) {
var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {};
var workspacePath = meta.workspacePath && typeof meta.workspacePath === 'object' ? meta.workspacePath : {};
var fromWorkspacePath = String(workspacePath.relativePath || '').trim();
if (fromWorkspacePath) return fromWorkspacePath;
var extra = meta.extra && typeof meta.extra === 'object' ? meta.extra : {};
var source = extra.source && typeof extra.source === 'object' ? extra.source : {};
var fromSource = String(source.relativePath || '').trim();
if (fromSource) return fromSource;
return String(item && (item.relativePath || item.rootRelativePath) || '').trim();
}
function groupRowsByParent(rows) {
rows = Array.isArray(rows) ? rows : [];
var ids = new Set(rows.map(nodeIdOf).filter(Boolean));
var grouped = new Map();
rows.forEach(function(item) {
var parentId = parentIdOf(item);
if (!ids.has(parentId)) parentId = '';
if (!grouped.has(parentId)) grouped.set(parentId, []);
grouped.get(parentId).push(item);
});
return grouped;
}
function revealFileTreeRow(row) {
if (!(row instanceof HTMLElement)) return;
var node = row.closest('.tree-node');
@@ -263,6 +340,17 @@ function fileTreeUploadTargetFallback(detail, deps) {
}
window.__mnoteFileTreeRuntime = {
readProjection: readProjection,
readSidebarDataset: readSidebarDataset,
readDatasetProjection: readDatasetProjection,
projectionItems: projectionItems,
hasProjectionItems: hasProjectionItems,
nodeIdOf: nodeIdOf,
rowIdOf: rowIdOf,
parentIdOf: parentIdOf,
titleOf: titleOf,
fileWorkspaceRelativePath: fileWorkspaceRelativePath,
groupRowsByParent: groupRowsByParent,
fileTreeRowKind: fileTreeRowKind,
fileTreeRowDocumentId: fileTreeRowDocumentId,
fileTreeRowAssetId: fileTreeRowAssetId,
@@ -2142,6 +2142,8 @@ const SIDEBAR_TREE_JS: &str = r##"
}
function readProjection(value) {
var runtimeFn = fileTreeRuntimeFunction('readProjection');
if (runtimeFn) return runtimeFn(value);
if (!value || typeof value !== 'object') return null;
if (value.result && typeof value.result === 'object') return value.result;
if (value.snapshot && value.snapshot.tree) return value.snapshot.tree;
@@ -2151,6 +2153,8 @@ const SIDEBAR_TREE_JS: &str = r##"
}
function readSidebarDataset(value) {
var runtimeFn = fileTreeRuntimeFunction('readSidebarDataset');
if (runtimeFn) return runtimeFn(value);
if (!value || typeof value !== 'object') return null;
if (value.snapshot && value.snapshot.dataset && typeof value.snapshot.dataset === 'object') return value.snapshot.dataset;
if (value.data && value.data.dataset && typeof value.data.dataset === 'object') return value.data.dataset;
@@ -2160,6 +2164,8 @@ const SIDEBAR_TREE_JS: &str = r##"
}
function readDatasetProjection(value, snakeCaseKey, camelCaseKey) {
var runtimeFn = fileTreeRuntimeFunction('readDatasetProjection');
if (runtimeFn) return runtimeFn(value, snakeCaseKey, camelCaseKey);
var dataset = readSidebarDataset(value);
if (!dataset || typeof dataset !== 'object') return null;
if (dataset[snakeCaseKey] && typeof dataset[snakeCaseKey] === 'object') return dataset[snakeCaseKey];
@@ -2172,32 +2178,46 @@ const SIDEBAR_TREE_JS: &str = r##"
}
function projectionItems(projection) {
var runtimeFn = fileTreeRuntimeFunction('projectionItems');
if (runtimeFn) return runtimeFn(projection);
var resolved = readProjection(projection);
return resolved && Array.isArray(resolved.items) ? resolved.items : [];
}
function hasProjectionItems(projection) {
var runtimeFn = fileTreeRuntimeFunction('hasProjectionItems');
if (runtimeFn) return runtimeFn(projection);
var resolved = readProjection(projection);
return Boolean(resolved && Array.isArray(resolved.items));
}
function nodeIdOf(item) {
var runtimeFn = fileTreeRuntimeFunction('nodeIdOf');
if (runtimeFn) return runtimeFn(item);
return String(item && (item.nodeId || item.id || item.documentId) || '').trim();
}
function rowIdOf(item) {
var runtimeFn = fileTreeRuntimeFunction('rowIdOf');
if (runtimeFn) return runtimeFn(item);
return String(item && (item.rowId || item.nodeId || item.id) || '').trim();
}
function parentIdOf(item) {
var runtimeFn = fileTreeRuntimeFunction('parentIdOf');
if (runtimeFn) return runtimeFn(item);
return String(item && (item.parentNodeId || item.parentId || '') || '').trim();
}
function titleOf(item) {
var runtimeFn = fileTreeRuntimeFunction('titleOf');
if (runtimeFn) return runtimeFn(item);
return String(item && item.title || '').trim() || '';
}
function fileWorkspaceRelativePath(item) {
var runtimeFn = fileTreeRuntimeFunction('fileWorkspaceRelativePath');
if (runtimeFn) return runtimeFn(item);
var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {};
var workspacePath = meta.workspacePath && typeof meta.workspacePath === 'object' ? meta.workspacePath : {};
var fromWorkspacePath = String(workspacePath.relativePath || '').trim();
@@ -2210,6 +2230,8 @@ const SIDEBAR_TREE_JS: &str = r##"
}
function groupRowsByParent(rows) {
var runtimeFn = fileTreeRuntimeFunction('groupRowsByParent');
if (runtimeFn) return runtimeFn(rows);
var ids = new Set(rows.map(nodeIdOf).filter(Boolean));
var grouped = new Map();
rows.forEach(function(item) {
@@ -11054,6 +11076,17 @@ mod tests {
assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('revealFileTreeAssetRow')"));
assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('appendUploadedAssetRow')"));
assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileTreeUploadTargetFallback')"));
assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('readProjection')"));
assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('readSidebarDataset')"));
assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('readDatasetProjection')"));
assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('projectionItems')"));
assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('hasProjectionItems')"));
assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('nodeIdOf')"));
assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('rowIdOf')"));
assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('parentIdOf')"));
assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('titleOf')"));
assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('fileWorkspaceRelativePath')"));
assert!(SIDEBAR_TREE_JS.contains("fileTreeRuntimeFunction('groupRowsByParent')"));
let document_id_body = js_function_body(SIDEBAR_TREE_JS, "fileTreeRowDocumentId");
assert!(
document_id_body.contains("data-document-id")
@@ -11077,7 +11110,19 @@ mod tests {
assert!(FILETREE_RUNTIME_JS.contains("function revealFileTreeAssetRow"));
assert!(FILETREE_RUNTIME_JS.contains("function appendUploadedAssetRow"));
assert!(FILETREE_RUNTIME_JS.contains("function fileTreeUploadTargetFallback"));
assert!(FILETREE_RUNTIME_JS.contains("function readProjection"));
assert!(FILETREE_RUNTIME_JS.contains("function readSidebarDataset"));
assert!(FILETREE_RUNTIME_JS.contains("function readDatasetProjection"));
assert!(FILETREE_RUNTIME_JS.contains("function projectionItems"));
assert!(FILETREE_RUNTIME_JS.contains("function hasProjectionItems"));
assert!(FILETREE_RUNTIME_JS.contains("function nodeIdOf"));
assert!(FILETREE_RUNTIME_JS.contains("function rowIdOf"));
assert!(FILETREE_RUNTIME_JS.contains("function parentIdOf"));
assert!(FILETREE_RUNTIME_JS.contains("function titleOf"));
assert!(FILETREE_RUNTIME_JS.contains("function fileWorkspaceRelativePath"));
assert!(FILETREE_RUNTIME_JS.contains("function groupRowsByParent"));
assert!(FILETREE_RUNTIME_JS.contains("appendUploadedAssetRow: appendUploadedAssetRow"));
assert!(FILETREE_RUNTIME_JS.contains("groupRowsByParent: groupRowsByParent"));
assert!(FILETREE_RUNTIME_JS
.contains("uploadIntent: String(detail.uploadIntent || 'filetree.folder.drop')"));
assert!(FILETREE_RUNTIME_JS
@@ -106,8 +106,25 @@ async function waitForPageTreeNodeGone(page, documentId) {
);
}
async function runStep(label, navigationEvents, action) {
async function waitForLocalFolderWatchProjectionApplied(page) {
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-local-folder-watch-applied") === "projection",
{ timeout: UI_TIMEOUT_MS },
);
const appliedValue = await page.evaluate(
() => document.documentElement.getAttribute("data-mnote-local-folder-watch-applied"),
);
assert(
appliedValue === "projection",
`local_folder watch 应通过 projection 应用,实际 data-mnote-local-folder-watch-applied=${appliedValue}`,
);
}
async function runStep(page, label, navigationEvents, action) {
const before = navigationEvents.length;
await page.evaluate(() => {
document.documentElement.removeAttribute("data-mnote-local-folder-watch-applied");
});
await action();
const after = navigationEvents.length;
assert(after === before, `${label} 不应触发浏览器导航或 reloadbefore=${before} after=${after}`);
@@ -147,30 +164,33 @@ async function run() {
await quickLogin(page);
await page.goto(treeUrl(root, "page"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await waitForVisibleText(page, "Local Root");
await waitForVisibleText(page, "Stable Page");
await waitForVisibleText(page, "stable");
await page.waitForResponse((response) => response.url().includes("/api/tree/local-folder-watch") && response.ok(), {
timeout: UI_TIMEOUT_MS,
}).catch(() => {});
await page.waitForTimeout(100);
navigationEvents.length = 0;
steps.push(await runStep("Markdown 外部创建后 page tree 原地更新", navigationEvents, async () => {
steps.push(await runStep(page, "Markdown 外部创建后 page tree 原地更新", navigationEvents, async () => {
fs.writeFileSync(path.join(root, "docs", "watcher-create.md"), "# Watcher Create\n", "utf8");
await waitForPageTreeNode(page, localMdDocumentId("docs/watcher-create.md"));
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep("Markdown 外部重命名后 page tree 原地更新", navigationEvents, async () => {
steps.push(await runStep(page, "Markdown 外部重命名后 page tree 原地更新", navigationEvents, async () => {
fs.renameSync(
path.join(root, "docs", "watcher-create.md"),
path.join(root, "docs", "watcher-renamed.md"),
);
await waitForPageTreeNode(page, localMdDocumentId("docs/watcher-renamed.md"));
await waitForPageTreeNodeGone(page, localMdDocumentId("docs/watcher-create.md"));
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep("Markdown 外部删除后 page tree 原地更新", navigationEvents, async () => {
steps.push(await runStep(page, "Markdown 外部删除后 page tree 原地更新", navigationEvents, async () => {
fs.rmSync(path.join(root, "docs", "watcher-renamed.md"));
await waitForPageTreeNodeGone(page, localMdDocumentId("docs/watcher-renamed.md"));
await waitForLocalFolderWatchProjectionApplied(page);
}));
await page.goto(treeUrl(root, "filetree"), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
@@ -182,61 +202,70 @@ async function run() {
await page.waitForTimeout(100);
navigationEvents.length = 0;
steps.push(await runStep("Markdown 外部创建后 filetree 原地更新", navigationEvents, async () => {
steps.push(await runStep(page, "Markdown 外部创建后 filetree 原地更新", navigationEvents, async () => {
fs.writeFileSync(path.join(root, "docs", "watcher-filetree-md.md"), "# Watcher Filetree Markdown\n", "utf8");
await waitForFileTreeRow(page, "local:markdown:docs/watcher-filetree-md.md");
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep("Markdown 外部重命名后 filetree 原地更新", navigationEvents, async () => {
steps.push(await runStep(page, "Markdown 外部重命名后 filetree 原地更新", navigationEvents, async () => {
fs.renameSync(
path.join(root, "docs", "watcher-filetree-md.md"),
path.join(root, "docs", "watcher-filetree-md-renamed.md"),
);
await waitForFileTreeRow(page, "local:markdown:docs/watcher-filetree-md-renamed.md");
await waitForFileTreeRowGone(page, "local:markdown:docs/watcher-filetree-md.md");
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep("Markdown 外部删除后 filetree 原地更新", navigationEvents, async () => {
steps.push(await runStep(page, "Markdown 外部删除后 filetree 原地更新", navigationEvents, async () => {
fs.rmSync(path.join(root, "docs", "watcher-filetree-md-renamed.md"));
await waitForFileTreeRowGone(page, "local:markdown:docs/watcher-filetree-md-renamed.md");
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep("非 md 资源外部创建后 filetree 原地更新", navigationEvents, async () => {
steps.push(await runStep(page, "非 md 资源外部创建后 filetree 原地更新", navigationEvents, async () => {
fs.writeFileSync(path.join(root, "docs", "watcher-asset.txt"), "watcher asset", "utf8");
await waitForFileTreeRow(page, "local:asset:docs/watcher-asset.txt");
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep("非 md 资源外部重命名后 filetree 原地更新", navigationEvents, async () => {
steps.push(await runStep(page, "非 md 资源外部重命名后 filetree 原地更新", navigationEvents, async () => {
fs.renameSync(
path.join(root, "docs", "watcher-asset.txt"),
path.join(root, "docs", "watcher-asset-renamed.txt"),
);
await waitForFileTreeRow(page, "local:asset:docs/watcher-asset-renamed.txt");
await waitForFileTreeRowGone(page, "local:asset:docs/watcher-asset.txt");
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep("非 md 资源外部删除后 filetree 原地更新", navigationEvents, async () => {
steps.push(await runStep(page, "非 md 资源外部删除后 filetree 原地更新", navigationEvents, async () => {
fs.rmSync(path.join(root, "docs", "watcher-asset-renamed.txt"));
await waitForFileTreeRowGone(page, "local:asset:docs/watcher-asset-renamed.txt");
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep("第二类非 md 资源外部创建后 filetree 原地更新", navigationEvents, async () => {
steps.push(await runStep(page, "第二类非 md 资源外部创建后 filetree 原地更新", navigationEvents, async () => {
fs.writeFileSync(path.join(root, "docs", "watcher-image.png"), "png", "utf8");
await waitForFileTreeRow(page, "local:asset:docs/watcher-image.png");
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep("第二类非 md 资源外部重命名后 filetree 原地更新", navigationEvents, async () => {
steps.push(await runStep(page, "第二类非 md 资源外部重命名后 filetree 原地更新", navigationEvents, async () => {
fs.renameSync(
path.join(root, "docs", "watcher-image.png"),
path.join(root, "docs", "watcher-image-renamed.png"),
);
await waitForFileTreeRow(page, "local:asset:docs/watcher-image-renamed.png");
await waitForFileTreeRowGone(page, "local:asset:docs/watcher-image.png");
await waitForLocalFolderWatchProjectionApplied(page);
}));
steps.push(await runStep("第二类非 md 资源外部删除后 filetree 原地更新", navigationEvents, async () => {
steps.push(await runStep(page, "第二类非 md 资源外部删除后 filetree 原地更新", navigationEvents, async () => {
fs.rmSync(path.join(root, "docs", "watcher-image-renamed.png"));
await waitForFileTreeRowGone(page, "local:asset:docs/watcher-image-renamed.png");
await waitForLocalFolderWatchProjectionApplied(page);
}));
const result = {