feat: consolidate local-first mnote web runtime

This commit is contained in:
lix-2026
2026-05-28 22:01:44 +08:00
parent 7354807ee9
commit 39b9a0183a
154 changed files with 13591 additions and 12728 deletions
@@ -9,6 +9,14 @@ const RUNTIME_PATH = path.join(
REPO_ROOT,
"rust/crates/mnote-web/browser/sidebar-filetree-command-runtime.js",
);
const FILETREE_RUNTIME_PATH = path.join(
REPO_ROOT,
"rust/crates/mnote-web/browser/filetree-runtime.js",
);
const FILETREE_CONTEXT_MENU_RUNTIME_PATH = path.join(
REPO_ROOT,
"rust/crates/mnote-web/browser/filetree-context-menu-runtime.js",
);
function loadRuntimeFactory() {
const source = fs
@@ -64,6 +72,9 @@ function loadRuntimeFactory() {
};
context.globalThis = context;
vm.createContext(context);
vm.runInContext(fs.readFileSync(FILETREE_CONTEXT_MENU_RUNTIME_PATH, "utf8"), context, {
filename: FILETREE_CONTEXT_MENU_RUNTIME_PATH,
});
vm.runInContext(
`${source}\nglobalThis.__factory = createSidebarFileTreeCommandRuntime;`,
context,
@@ -72,6 +83,23 @@ function loadRuntimeFactory() {
return context;
}
function loadFileTreeRuntime() {
const context = {
console,
HTMLElement: class HTMLElement {},
document: {},
window: {},
URL,
};
context.globalThis = context;
context.window = context;
vm.createContext(context);
vm.runInContext(fs.readFileSync(FILETREE_RUNTIME_PATH, "utf8"), context, {
filename: FILETREE_RUNTIME_PATH,
});
return context;
}
async function runCopyId(runtime, context, detail) {
context.__clipboardText = "";
runtime.handleTreeContextMenuAction("copy-id", detail, null);
@@ -80,10 +108,151 @@ async function runCopyId(runtime, context, detail) {
}
async function main() {
const fileTreeContext = loadFileTreeRuntime();
class FakeRow extends fileTreeContext.HTMLElement {
constructor(attrs, textContent = "") {
super();
this.attrs = attrs;
this.textContent = textContent;
}
getAttribute(name) {
return this.attrs[name] || "";
}
querySelector() {
return null;
}
}
const readWorkspacePath = (attrs, textContent = "") =>
fileTreeContext.window.__mnoteFileTreeRuntime.readWorkspacePathFromRow(
new FakeRow(attrs, textContent),
{
currentSourceKind: () => attrs.__sourceKind || "local_folder",
currentRootUri: () => attrs.__rootUri || "file:///mnt/Data1T/mnote",
resolveWorkspaceId: () => "local-folder",
rowTitle: (row) => row.textContent,
},
);
const workspacePath = readWorkspacePath({
"data-row-id": "local:asset:assets/report.pdf",
"data-row-kind": "asset",
"data-document-id": "local-md:README.md",
"data-asset-id": "local-file:assets/report.pdf",
"data-local-relative-path": "assets/report.pdf",
"data-object-identity": JSON.stringify({
objectKind: "attachment",
documentId: "local-md:README.md",
assetId: "local-file:assets/report.pdf",
}),
}, "report.pdf");
assert.equal(workspacePath.schema, "mnote.workspace_path.v1");
assert.equal(workspacePath.workspaceId, "local-folder");
assert.equal(workspacePath.sourceKind, "local_folder");
assert.equal(workspacePath.rootUri, "file:///mnt/Data1T/mnote");
assert.equal(workspacePath.relativePath, "assets/report.pdf");
assert.equal(workspacePath.documentId, "local-md:README.md");
assert.equal(workspacePath.assetId, "local-file:assets/report.pdf");
assert.equal(workspacePath.objectKind, "attachment");
assert.equal(workspacePath.resourceKind, "attachment");
assert.equal(workspacePath.objectIdentity.assetId, "local-file:assets/report.pdf");
const markdownWorkspacePath = readWorkspacePath({
"data-row-id": "local:markdown:docs/Page.md",
"data-row-kind": "markdown",
"data-document-id": "local-md:docs~2FPage.md",
"data-local-relative-path": "docs/Page.md",
"data-object-identity": JSON.stringify({
objectKind: "page",
documentId: "local-md:docs~2FPage.md",
assetId: null,
}),
}, "Page.md");
assert.equal(markdownWorkspacePath.relativePath, "docs/Page.md");
assert.equal(markdownWorkspacePath.objectKind, "page");
assert.equal(markdownWorkspacePath.isLocalFolder, true);
const folderWorkspacePath = readWorkspacePath({
"data-row-id": "local:folder:docs",
"data-row-kind": "folder",
"data-document-id": "local-dir:docs",
"data-local-relative-path": "docs",
"data-object-identity": JSON.stringify({
objectKind: "index",
documentId: "local-dir:docs",
assetId: null,
}),
}, "docs");
assert.equal(folderWorkspacePath.relativePath, "docs");
assert.equal(folderWorkspacePath.objectKind, "index");
const mindmapWorkspacePath = readWorkspacePath({
"data-row-id": "local:asset:Page/思维导图.json",
"data-row-kind": "asset",
"data-asset-id": "local-file:Page/思维导图.json",
"data-local-relative-path": "Page/思维导图.json",
"data-object-identity": JSON.stringify({
objectKind: "mindmap",
documentId: "local-md:Page~2FPage.md",
assetId: "local-file:Page/思维导图.json",
}),
}, "思维导图.json");
assert.equal(mindmapWorkspacePath.objectKind, "mindmap");
assert.equal(mindmapWorkspacePath.objectIdentity.assetId, "local-file:Page/思维导图.json");
const officeWorkspacePath = readWorkspacePath({
"data-row-id": "local:asset:Page/report.docx",
"data-row-kind": "asset",
"data-asset-id": "local-file:Page/report.docx",
"data-local-relative-path": "Page/report.docx",
"data-object-identity": JSON.stringify({
objectKind: "only_office",
documentId: "local-md:Page~2FPage.md",
assetId: "local-file:Page/report.docx",
}),
}, "report.docx");
assert.equal(officeWorkspacePath.objectKind, "only_office");
assert.equal(officeWorkspacePath.objectIdentity.assetId, "local-file:Page/report.docx");
const nonLocalWorkspacePath = readWorkspacePath({
__sourceKind: "cloud",
__rootUri: "",
"data-row-id": "doc:cloud-page",
"data-row-kind": "document",
"data-document-id": "local-cloud-compatible-id",
"data-object-identity": JSON.stringify({
objectKind: "page",
documentId: "local-cloud-compatible-id",
assetId: null,
}),
}, "cloud");
assert.equal(nonLocalWorkspacePath.isLocalFolder, false, "sourceKind 非 local_folder 时不应仅因 local-* documentId 误判成本地文件夹");
const context = loadRuntimeFactory();
class FakeCommandBadge extends context.HTMLElement {
constructor(kind) {
super();
this.kind = kind;
}
getAttribute(name) {
return name === "data-kind" ? this.kind : "";
}
}
class FakeCommandRow extends context.HTMLElement {
constructor(attrs, textContent = "", badgeKind = "") {
super();
this.attrs = attrs;
this.textContent = textContent;
this.badge = badgeKind ? new FakeCommandBadge(badgeKind) : null;
}
getAttribute(name) {
return this.attrs[name] || "";
}
querySelector(selector) {
return selector === ".tree-kind-badge" ? this.badge : null;
}
}
const runtime = context.__factory({
currentRootUri: () => "file:///mnt/Data1T/mnote",
currentSourceKind: () => "local_folder",
currentRootUri: () => "file:///mnt/Data1T/mnote",
cssEscape: (value) => String(value).replace(/"/g, '\\"'),
fileTreeRuntimeFunction: () => null,
localFilePathFromAssetId: (assetId) =>
@@ -92,6 +261,139 @@ async function main() {
runtimeState: { activeTreeContextMenu: null },
selectedSidebarFileTreeSelection: { selectedRowIds: new Set(), focusedRowId: null },
});
assert.equal(
runtime.classifySidebarFileTreeAsset(new FakeCommandRow({}, "legacy.luckysheet", "luckysheet")),
"table",
"legacy luckysheet 行仍应归为 table 资源",
);
const dirtyCommandRow = new FakeCommandRow({
"data-row-id": "local:markdown:docs/Page.md",
"data-row-kind": "markdown",
"data-document-id": "local-md:docs~2FPage.md",
"data-local-relative-path": "docs/Page.md",
}, "Page.md");
const commandContextRuntime = context.window.__mnoteFileTreeContextMenuRuntime;
const dirtyContext = commandContextRuntime.buildFileTreeCommandContext(
dirtyCommandRow,
{
schema: "mnote.open_editors_snapshot.v1",
editors: [
{
documentId: "local-md:docs~2FPage.md",
dirtyState: "dirty",
workspacePath: { relativePath: "docs/Page.md" },
},
],
},
null,
{
currentSourceKind: () => "local_folder",
workspaceReadonly: () => false,
selection: { selectedRowIds: new Set(["local:markdown:docs/Page.md"]) },
queryRowById: () => dirtyCommandRow,
},
);
assert.equal(dirtyContext["workspace.sourceKind"], "local_folder");
assert.equal(dirtyContext["tree.selectionCount"], 1);
assert.equal(dirtyContext["tree.selectionResourceKind"], "page");
assert.equal(dirtyContext["tree.targetResourceKind"], "page");
assert.equal(dirtyContext["editor.dirty"], true);
assert.equal(dirtyContext["editor.dirtyState"], "dirty");
assert.equal(dirtyContext["ai.canWrite"], false);
assert.equal(
commandContextRuntime.evaluateSidebarFileTreeWhen(dirtyContext, "!workspace.readonly && !editor.dirty"),
false,
"dirty 目标下菜单与快捷键写操作应被统一 command context 禁用",
);
const readonlyContext = commandContextRuntime.buildFileTreeCommandContext(
dirtyCommandRow,
{ editors: [] },
null,
{
currentSourceKind: () => "local_folder",
workspaceReadonly: () => true,
selection: { selectedRowIds: new Set(["local:markdown:docs/Page.md"]) },
queryRowById: () => dirtyCommandRow,
},
);
assert.equal(readonlyContext["workspace.readonly"], true);
assert.equal(
commandContextRuntime.evaluateSidebarFileTreeWhen(readonlyContext, "!workspace.readonly"),
false,
"readonly 目标下菜单与快捷键写操作应被统一 command context 禁用",
);
const cleanTargetRow = new FakeCommandRow({
"data-row-id": "local:markdown:docs/Clean.md",
"data-row-kind": "markdown",
"data-document-id": "local-md:docs~2FClean.md",
"data-local-relative-path": "docs/Clean.md",
}, "Clean.md");
const selectedRows = new Map([
["local:markdown:docs/Clean.md", cleanTargetRow],
["local:markdown:docs/Page.md", dirtyCommandRow],
]);
const multiSelectDirtyContext = commandContextRuntime.buildFileTreeCommandContext(
cleanTargetRow,
{
editors: [
{
documentId: "local-md:docs~2FPage.md",
dirtyState: "Dirty",
workspacePath: { relativePath: "docs/Page.md" },
},
],
},
null,
{
currentSourceKind: () => "local_folder",
workspaceReadonly: () => false,
selection: { selectedRowIds: new Set(["local:markdown:docs/Clean.md", "local:markdown:docs/Page.md"]) },
queryRowById: (rowId) => selectedRows.get(rowId),
},
);
assert.equal(multiSelectDirtyContext["tree.selectionCount"], 2);
assert.equal(multiSelectDirtyContext["tree.selectionResourceKind"], "page");
assert.equal(multiSelectDirtyContext["editor.dirty"], true);
assert.equal(multiSelectDirtyContext["editor.dirtyState"], "dirty");
assert.equal(
commandContextRuntime.evaluateSidebarFileTreeWhen(multiSelectDirtyContext, "!workspace.readonly && !editor.dirty"),
false,
"多选中任一非 target 行 dirty 时,菜单与快捷键写操作也必须禁用",
);
const dirtyAssetRow = new FakeCommandRow({
"data-row-id": "local:asset:assets/report.pdf",
"data-row-kind": "asset",
"data-asset-id": "local-file:assets/report.pdf",
"data-local-relative-path": "assets/report.pdf",
}, "report.pdf");
const dirtyAssetContext = commandContextRuntime.buildFileTreeCommandContext(
dirtyAssetRow,
{
resourceEditors: [
{
assetId: "local-file:assets/report.pdf",
dirtyState: "ExternalModified",
workspacePath: { relativePath: "assets/report.pdf", assetId: "local-file:assets/report.pdf" },
},
],
},
null,
{
currentSourceKind: () => "local_folder",
workspaceReadonly: () => false,
selection: { selectedRowIds: new Set(["local:asset:assets/report.pdf"]) },
queryRowById: () => dirtyAssetRow,
},
);
assert.equal(dirtyAssetContext["tree.selectionResourceKind"], "asset");
assert.equal(dirtyAssetContext["tree.targetIsAsset"], true);
assert.equal(dirtyAssetContext["editor.dirty"], true);
assert.equal(dirtyAssetContext["editor.dirtyState"], "external_modified");
assert.equal(
commandContextRuntime.evaluateSidebarFileTreeWhen(dirtyAssetContext, "!workspace.readonly && !editor.dirty"),
false,
"dirty resource tab 对应 asset 的 rename/delete 也必须禁用",
);
assert.equal(
await runCopyId(runtime, context, {
@@ -99,6 +401,7 @@ async function main() {
rowKind: "folder",
documentId: "local-dir:docs",
rowId: "local:folder:docs",
workspacePath: folderWorkspacePath,
title: "docs",
}),
"/mnt/Data1T/mnote/docs",
@@ -111,6 +414,7 @@ async function main() {
rowKind: "markdown",
documentId: "local-md:docs~2FPage.md",
rowId: "local:markdown:docs~2FPage.md",
workspacePath: markdownWorkspacePath,
title: "Page",
}),
"/mnt/Data1T/mnote/docs/Page.md",
@@ -121,8 +425,9 @@ async function main() {
await runCopyId(runtime, context, {
contextKind: "filetree",
rowKind: "asset",
assetId: "local-file:assets~2Freport.pdf",
rowId: "local:asset:assets~2Freport.pdf",
assetId: "local-file:assets/report.pdf",
rowId: "local:asset:assets/report.pdf",
workspacePath,
title: "report.pdf",
}),
"/mnt/Data1T/mnote/assets/report.pdf",