refactor: split tree shell filetree menu
This commit is contained in:
@@ -0,0 +1,354 @@
|
|||||||
|
// MNote debug /tree shell filetree context menu runtime.
|
||||||
|
|
||||||
|
import {
|
||||||
|
getFileTreeRowAssetId,
|
||||||
|
getFileTreeRowDocumentId,
|
||||||
|
} from "./tree-shell-filetree-runtime.js";
|
||||||
|
import { normalizeTreeShellText as normalizeText } from "./tree-shell-state-runtime.js";
|
||||||
|
|
||||||
|
const createFileTreeMenuItem = (kind, label, options = {}) => ({
|
||||||
|
kind,
|
||||||
|
label,
|
||||||
|
disabled: options.disabled === true,
|
||||||
|
reason: normalizeText(options.reason),
|
||||||
|
separatorBefore: options.separatorBefore === true,
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildFileTreeMenuTarget(context, rowId, rowKind, documentId, assetId) {
|
||||||
|
const fileTreeRowById = context.getFileTreeRowById();
|
||||||
|
const item = rowId ? fileTreeRowById.get(rowId) : null;
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
rowId: rowId || null,
|
||||||
|
rowKind: rowKind || item?.rowKind || "root",
|
||||||
|
documentId: documentId || getFileTreeRowDocumentId(item) || null,
|
||||||
|
assetId: assetId || getFileTreeRowAssetId(item) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTreeShellFileTreeContextMenuProfile(context, target) {
|
||||||
|
const selectedFileTreeRowIds = context.getSelectedFileTreeRowIds();
|
||||||
|
const fileTreeClipboard = context.getFileTreeClipboard();
|
||||||
|
const selectedCount = selectedFileTreeRowIds.size;
|
||||||
|
const isMulti = selectedCount > 1 && target.rowId && selectedFileTreeRowIds.has(target.rowId);
|
||||||
|
const item = target.item;
|
||||||
|
const rowKind = target.rowKind || "root";
|
||||||
|
const canPaste = Boolean(fileTreeClipboard.action && fileTreeClipboard.rowIds.length > 0);
|
||||||
|
const hasDocument = Boolean(getFileTreeRowDocumentId(item));
|
||||||
|
const hasAsset = Boolean(getFileTreeRowAssetId(item));
|
||||||
|
const localSource = context.sourceKind === "local_folder";
|
||||||
|
const convexSource = context.sourceKind === "convex_workspace";
|
||||||
|
const canCreateFolder = localSource;
|
||||||
|
const canCreatePage = rowKind === "root" || rowKind === "folder" || rowKind === "document";
|
||||||
|
const canRename =
|
||||||
|
rowKind === "markdown" ||
|
||||||
|
rowKind === "document" ||
|
||||||
|
rowKind === "folder" ||
|
||||||
|
rowKind === "index";
|
||||||
|
const canCopyCut =
|
||||||
|
rowKind === "markdown" ||
|
||||||
|
rowKind === "document" ||
|
||||||
|
rowKind === "index";
|
||||||
|
const canDelete =
|
||||||
|
rowKind === "markdown" ||
|
||||||
|
rowKind === "document" ||
|
||||||
|
rowKind === "index" ||
|
||||||
|
(localSource && rowKind === "asset");
|
||||||
|
|
||||||
|
if (rowKind === "root") {
|
||||||
|
return [
|
||||||
|
createFileTreeMenuItem("newPage", "新建页面", {
|
||||||
|
disabled: !canCreatePage,
|
||||||
|
reason: "当前 root 不支持新建页面",
|
||||||
|
}),
|
||||||
|
createFileTreeMenuItem("newFolder", "新建文件夹", {
|
||||||
|
disabled: !canCreateFolder,
|
||||||
|
reason: convexSource ? "Convex workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹",
|
||||||
|
}),
|
||||||
|
createFileTreeMenuItem("upload", "上传/导入", {
|
||||||
|
disabled: true,
|
||||||
|
reason: localSource ? "外部文件请拖入 Explorer" : "Convex 上传 executor 尚未接入",
|
||||||
|
}),
|
||||||
|
createFileTreeMenuItem("refresh", "刷新", { separatorBefore: true }),
|
||||||
|
createFileTreeMenuItem("collapseAll", "全部折叠"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMulti) {
|
||||||
|
return [
|
||||||
|
createFileTreeMenuItem("copy", "复制"),
|
||||||
|
createFileTreeMenuItem("cut", "剪切"),
|
||||||
|
createFileTreeMenuItem("delete", "删除", { disabled: false }),
|
||||||
|
createFileTreeMenuItem("moveTo", "移动到", {
|
||||||
|
separatorBefore: true,
|
||||||
|
disabled: true,
|
||||||
|
reason: "目标选择器尚未接入 filetree 菜单",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
if (rowKind === "folder") {
|
||||||
|
items.push(createFileTreeMenuItem("newPage", "新建页面", {
|
||||||
|
disabled: !canCreatePage,
|
||||||
|
reason: "当前文件夹不支持新建页面",
|
||||||
|
}));
|
||||||
|
items.push(createFileTreeMenuItem("newFolder", "新建文件夹", {
|
||||||
|
disabled: !canCreateFolder,
|
||||||
|
reason: convexSource ? "Convex workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹",
|
||||||
|
}));
|
||||||
|
items.push(createFileTreeMenuItem("paste", "粘贴", {
|
||||||
|
disabled: !canPaste,
|
||||||
|
reason: "剪贴板为空",
|
||||||
|
}));
|
||||||
|
items.push(createFileTreeMenuItem("rename", "重命名", {
|
||||||
|
separatorBefore: true,
|
||||||
|
disabled: !canRename,
|
||||||
|
reason: "当前文件夹不支持重命名",
|
||||||
|
}));
|
||||||
|
items.push(createFileTreeMenuItem("delete", "删除", {
|
||||||
|
disabled: true,
|
||||||
|
reason: "目录删除尚未收口到统一 executor",
|
||||||
|
}));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(createFileTreeMenuItem("open", "打开", {
|
||||||
|
disabled: !hasDocument && !hasAsset,
|
||||||
|
reason: "当前行没有可打开资源",
|
||||||
|
}));
|
||||||
|
items.push(createFileTreeMenuItem("rename", "重命名", {
|
||||||
|
disabled: !canRename,
|
||||||
|
reason: "当前资源暂不支持重命名",
|
||||||
|
}));
|
||||||
|
items.push(createFileTreeMenuItem("copy", "复制", {
|
||||||
|
disabled: !canCopyCut,
|
||||||
|
reason: "当前资源暂不支持复制",
|
||||||
|
}));
|
||||||
|
items.push(createFileTreeMenuItem("cut", "剪切", {
|
||||||
|
disabled: !canCopyCut,
|
||||||
|
reason: "当前资源暂不支持剪切",
|
||||||
|
}));
|
||||||
|
items.push(createFileTreeMenuItem("paste", "粘贴", {
|
||||||
|
disabled: !canPaste,
|
||||||
|
reason: "剪贴板为空",
|
||||||
|
}));
|
||||||
|
items.push(createFileTreeMenuItem("moveTo", "移动到", {
|
||||||
|
disabled: true,
|
||||||
|
reason: "目标选择器尚未接入 filetree 菜单",
|
||||||
|
}));
|
||||||
|
items.push(createFileTreeMenuItem("delete", "删除", {
|
||||||
|
disabled: !canDelete,
|
||||||
|
reason: "当前资源暂不支持删除",
|
||||||
|
}));
|
||||||
|
items.push(createFileTreeMenuItem("reveal", "Reveal", { separatorBefore: true }));
|
||||||
|
if (hasAsset) {
|
||||||
|
items.push(createFileTreeMenuItem("download", "下载", {
|
||||||
|
disabled: true,
|
||||||
|
reason: "下载 executor 尚未接入",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (item?.capabilities?.includes("share")) {
|
||||||
|
items.push(createFileTreeMenuItem("share", "分享", { separatorBefore: true }));
|
||||||
|
}
|
||||||
|
if (item?.capabilities?.includes("publish")) {
|
||||||
|
items.push(createFileTreeMenuItem("publish", "发布"));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTreeShellFileTreeMenuRuntime(context) {
|
||||||
|
let activeFileTreeMenuElement = null;
|
||||||
|
|
||||||
|
const closeFileTreeContextMenu = () => {
|
||||||
|
if (!activeFileTreeMenuElement) return;
|
||||||
|
activeFileTreeMenuElement.remove();
|
||||||
|
activeFileTreeMenuElement = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeFileTreeContextMenuAction = async (kind, target) => {
|
||||||
|
closeFileTreeContextMenu();
|
||||||
|
const item = target.item;
|
||||||
|
if (kind === "open") {
|
||||||
|
if (item) context.openHydratedFileTreeItem(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === "rename") {
|
||||||
|
if (target.rowId && getFileTreeRowDocumentId(item)) {
|
||||||
|
context.beginInlineRename("filetree", target.rowId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === "copy" || kind === "cut") {
|
||||||
|
const selectedFileTreeRowIds = context.getSelectedFileTreeRowIds();
|
||||||
|
if (target.rowId && !selectedFileTreeRowIds.has(target.rowId)) {
|
||||||
|
context.commitFileTreeSelection({
|
||||||
|
selectedRowIds: [target.rowId],
|
||||||
|
anchorRowId: target.rowId,
|
||||||
|
focusedRowId: target.rowId,
|
||||||
|
});
|
||||||
|
context.syncFileTreeSelectionDom();
|
||||||
|
}
|
||||||
|
context.setFileTreeClipboard(kind);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === "paste") {
|
||||||
|
await context.pasteFileTreeClipboardInto(target.rowId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === "delete") {
|
||||||
|
await context.runFileTreeDelete(target.rowId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === "newPage") {
|
||||||
|
const parentId = item && item.rowKind === "folder"
|
||||||
|
? item.nodeId
|
||||||
|
: item && item.rowKind === "document"
|
||||||
|
? item.nodeId
|
||||||
|
: null;
|
||||||
|
await context.handleCreate(parentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === "newFolder") {
|
||||||
|
const parentId = item && item.rowKind === "folder" ? item.nodeId : null;
|
||||||
|
const result = await context.sendCommand({
|
||||||
|
action: "createFolder",
|
||||||
|
workspaceId: context.workspaceId,
|
||||||
|
documentId: "",
|
||||||
|
parentId,
|
||||||
|
title: "新建文件夹",
|
||||||
|
});
|
||||||
|
context.setStatus("创建文件夹成功");
|
||||||
|
context.setLastAction("已创建新建文件夹");
|
||||||
|
context.scheduleRefresh({
|
||||||
|
renameRowId: context.localCreatedRowIdFromCommandResult(result, "folder"),
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (kind === "refresh") {
|
||||||
|
await context.refreshLocalFolderSnapshot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === "collapseAll") {
|
||||||
|
context.expanded.clear();
|
||||||
|
context.renderTree();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kind === "reveal") {
|
||||||
|
context.setLastAction(`Reveal ${target.documentId || target.assetId || target.rowId || "root"}`);
|
||||||
|
context.postToHost("tree.filetree.reveal", {
|
||||||
|
documentId: target.documentId,
|
||||||
|
assetId: target.assetId,
|
||||||
|
rowId: target.rowId,
|
||||||
|
payload: target,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.postToHost(`tree.filetree.${kind}`, {
|
||||||
|
documentId: target.documentId,
|
||||||
|
assetId: target.assetId,
|
||||||
|
rowId: target.rowId,
|
||||||
|
payload: target,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFileTreeContextMenu = ({
|
||||||
|
documentId,
|
||||||
|
assetId,
|
||||||
|
rowId,
|
||||||
|
rowKind,
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
}) => {
|
||||||
|
closeFileTreeContextMenu();
|
||||||
|
const target = buildFileTreeMenuTarget(context, rowId, rowKind, documentId, assetId);
|
||||||
|
const profile = buildTreeShellFileTreeContextMenuProfile(context, target);
|
||||||
|
const menu = document.createElement("div");
|
||||||
|
menu.className = "tree-context-menu";
|
||||||
|
menu.dataset.testid = "filetree-context-menu";
|
||||||
|
menu.dataset.sourceKind = context.sourceKind;
|
||||||
|
menu.dataset.rowKind = target.rowKind;
|
||||||
|
menu.setAttribute("role", "menu");
|
||||||
|
|
||||||
|
profile.forEach((entry) => {
|
||||||
|
if (entry.separatorBefore) {
|
||||||
|
const separator = document.createElement("div");
|
||||||
|
separator.className = "tree-menu-separator";
|
||||||
|
separator.setAttribute("role", "separator");
|
||||||
|
menu.appendChild(separator);
|
||||||
|
}
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "tree-menu-item";
|
||||||
|
button.dataset.menuAction = entry.kind;
|
||||||
|
button.setAttribute("role", "menuitem");
|
||||||
|
button.textContent = entry.label;
|
||||||
|
if (entry.disabled) {
|
||||||
|
button.disabled = true;
|
||||||
|
if (entry.reason) {
|
||||||
|
button.title = entry.reason;
|
||||||
|
button.dataset.disabledReason = entry.reason;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
void executeFileTreeContextMenuAction(entry.kind, target);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
menu.appendChild(button);
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeOnOutside = (event) => {
|
||||||
|
if (activeFileTreeMenuElement && !activeFileTreeMenuElement.contains(event.target)) {
|
||||||
|
closeFileTreeContextMenu();
|
||||||
|
document.removeEventListener("mousedown", closeOnOutside, true);
|
||||||
|
document.removeEventListener("keydown", closeOnKeyDown, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const closeOnKeyDown = (event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeFileTreeContextMenu();
|
||||||
|
document.removeEventListener("mousedown", closeOnOutside, true);
|
||||||
|
document.removeEventListener("keydown", closeOnKeyDown, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
const x = Number.isFinite(clientX) ? clientX : 16;
|
||||||
|
const y = Number.isFinite(clientY) ? clientY : 16;
|
||||||
|
const rect = menu.getBoundingClientRect();
|
||||||
|
menu.style.left = `${Math.max(4, Math.min(x, window.innerWidth - rect.width - 4))}px`;
|
||||||
|
menu.style.top = `${Math.max(4, Math.min(y, window.innerHeight - rect.height - 4))}px`;
|
||||||
|
activeFileTreeMenuElement = menu;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
document.addEventListener("mousedown", closeOnOutside, true);
|
||||||
|
document.addEventListener("keydown", closeOnKeyDown, true);
|
||||||
|
}, 0);
|
||||||
|
context.setLastAction(
|
||||||
|
assetId
|
||||||
|
? `已打开资源 ${assetId} 的更多操作`
|
||||||
|
: `已打开文件树节点 ${documentId || rowId || "unknown"} 的更多操作`,
|
||||||
|
);
|
||||||
|
context.postToHost("tree.filetree.context-menu", {
|
||||||
|
documentId,
|
||||||
|
assetId,
|
||||||
|
rowId,
|
||||||
|
rowKind,
|
||||||
|
payload: {
|
||||||
|
documentId,
|
||||||
|
assetId,
|
||||||
|
rowId,
|
||||||
|
rowKind,
|
||||||
|
x: clientX,
|
||||||
|
y: clientY,
|
||||||
|
},
|
||||||
|
x: clientX,
|
||||||
|
y: clientY,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
closeFileTreeContextMenu,
|
||||||
|
executeFileTreeContextMenuAction,
|
||||||
|
openFileTreeContextMenu,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ import {
|
|||||||
getFileTreeRowOwnerDocumentId,
|
getFileTreeRowOwnerDocumentId,
|
||||||
normalizeTreeShellTreeItems,
|
normalizeTreeShellTreeItems,
|
||||||
} from "./tree-shell-filetree-runtime.js";
|
} from "./tree-shell-filetree-runtime.js";
|
||||||
|
import {
|
||||||
|
createTreeShellFileTreeMenuRuntime,
|
||||||
|
} from "./tree-shell-filetree-menu-runtime.js";
|
||||||
import {
|
import {
|
||||||
focusTreeShellPickerRowElement,
|
focusTreeShellPickerRowElement,
|
||||||
hydrateTreeShellInitialPageTree,
|
hydrateTreeShellInitialPageTree,
|
||||||
@@ -295,7 +298,6 @@ function startTreeShellRuntime() {
|
|||||||
let fileTreeHoverExpandTimer = 0;
|
let fileTreeHoverExpandTimer = 0;
|
||||||
let fileTreeClipboard = { action: null, rowIds: [] };
|
let fileTreeClipboard = { action: null, rowIds: [] };
|
||||||
let inlineRenameState = { mode: null, id: null, committing: false };
|
let inlineRenameState = { mode: null, id: null, committing: false };
|
||||||
let activeFileTreeMenuElement = null;
|
|
||||||
let activeFileTreePreflightElement = null;
|
let activeFileTreePreflightElement = null;
|
||||||
|
|
||||||
let activeCursor = itemById.get(currentActiveDocumentId) || null;
|
let activeCursor = itemById.get(currentActiveDocumentId) || null;
|
||||||
@@ -2195,160 +2197,6 @@ function startTreeShellRuntime() {
|
|||||||
applyPickerStateAction({ kind: normalizedCommand });
|
applyPickerStateAction({ kind: normalizedCommand });
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeFileTreeContextMenu = () => {
|
|
||||||
if (!activeFileTreeMenuElement) return;
|
|
||||||
activeFileTreeMenuElement.remove();
|
|
||||||
activeFileTreeMenuElement = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildFileTreeMenuTarget = (rowId, rowKind, documentId, assetId) => {
|
|
||||||
const item = rowId ? fileTreeRowById.get(rowId) : null;
|
|
||||||
return {
|
|
||||||
item,
|
|
||||||
rowId: rowId || null,
|
|
||||||
rowKind: rowKind || item?.rowKind || "root",
|
|
||||||
documentId: documentId || getFileTreeRowDocumentId(item) || null,
|
|
||||||
assetId: assetId || getFileTreeRowAssetId(item) || null,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const createFileTreeMenuItem = (kind, label, options = {}) => ({
|
|
||||||
kind,
|
|
||||||
label,
|
|
||||||
disabled: options.disabled === true,
|
|
||||||
reason: normalizeText(options.reason),
|
|
||||||
separatorBefore: options.separatorBefore === true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const buildFileTreeContextMenuProfile = (target) => {
|
|
||||||
const selectedCount = selectedFileTreeRowIds.size;
|
|
||||||
const isMulti = selectedCount > 1 && target.rowId && selectedFileTreeRowIds.has(target.rowId);
|
|
||||||
const item = target.item;
|
|
||||||
const rowKind = target.rowKind || "root";
|
|
||||||
const canPaste = Boolean(fileTreeClipboard.action && fileTreeClipboard.rowIds.length > 0);
|
|
||||||
const hasDocument = Boolean(getFileTreeRowDocumentId(item));
|
|
||||||
const hasAsset = Boolean(getFileTreeRowAssetId(item));
|
|
||||||
const localSource = sourceKind === "local_folder";
|
|
||||||
const convexSource = sourceKind === "convex_workspace";
|
|
||||||
const canCreateFolder = localSource;
|
|
||||||
const canCreatePage = rowKind === "root" || rowKind === "folder" || rowKind === "document";
|
|
||||||
const canRename =
|
|
||||||
rowKind === "markdown" ||
|
|
||||||
rowKind === "document" ||
|
|
||||||
rowKind === "folder" ||
|
|
||||||
rowKind === "index";
|
|
||||||
const canCopyCut =
|
|
||||||
rowKind === "markdown" ||
|
|
||||||
rowKind === "document" ||
|
|
||||||
rowKind === "index";
|
|
||||||
const canDelete =
|
|
||||||
rowKind === "markdown" ||
|
|
||||||
rowKind === "document" ||
|
|
||||||
rowKind === "index" ||
|
|
||||||
(localSource && rowKind === "asset");
|
|
||||||
|
|
||||||
if (rowKind === "root") {
|
|
||||||
return [
|
|
||||||
createFileTreeMenuItem("newPage", "新建页面", {
|
|
||||||
disabled: !canCreatePage,
|
|
||||||
reason: "当前 root 不支持新建页面",
|
|
||||||
}),
|
|
||||||
createFileTreeMenuItem("newFolder", "新建文件夹", {
|
|
||||||
disabled: !canCreateFolder,
|
|
||||||
reason: convexSource ? "Convex workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹",
|
|
||||||
}),
|
|
||||||
createFileTreeMenuItem("upload", "上传/导入", {
|
|
||||||
disabled: true,
|
|
||||||
reason: localSource ? "外部文件请拖入 Explorer" : "Convex 上传 executor 尚未接入",
|
|
||||||
}),
|
|
||||||
createFileTreeMenuItem("refresh", "刷新", { separatorBefore: true }),
|
|
||||||
createFileTreeMenuItem("collapseAll", "全部折叠"),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMulti) {
|
|
||||||
return [
|
|
||||||
createFileTreeMenuItem("copy", "复制"),
|
|
||||||
createFileTreeMenuItem("cut", "剪切"),
|
|
||||||
createFileTreeMenuItem("delete", "删除", { disabled: false }),
|
|
||||||
createFileTreeMenuItem("moveTo", "移动到", {
|
|
||||||
separatorBefore: true,
|
|
||||||
disabled: true,
|
|
||||||
reason: "目标选择器尚未接入 filetree 菜单",
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = [];
|
|
||||||
if (rowKind === "folder") {
|
|
||||||
items.push(createFileTreeMenuItem("newPage", "新建页面", {
|
|
||||||
disabled: !canCreatePage,
|
|
||||||
reason: "当前文件夹不支持新建页面",
|
|
||||||
}));
|
|
||||||
items.push(createFileTreeMenuItem("newFolder", "新建文件夹", {
|
|
||||||
disabled: !canCreateFolder,
|
|
||||||
reason: convexSource ? "Convex workspace 当前未暴露 folder capability" : "当前 source 不支持新建文件夹",
|
|
||||||
}));
|
|
||||||
items.push(createFileTreeMenuItem("paste", "粘贴", {
|
|
||||||
disabled: !canPaste,
|
|
||||||
reason: "剪贴板为空",
|
|
||||||
}));
|
|
||||||
items.push(createFileTreeMenuItem("rename", "重命名", {
|
|
||||||
separatorBefore: true,
|
|
||||||
disabled: !canRename,
|
|
||||||
reason: "当前文件夹不支持重命名",
|
|
||||||
}));
|
|
||||||
items.push(createFileTreeMenuItem("delete", "删除", {
|
|
||||||
disabled: true,
|
|
||||||
reason: "目录删除尚未收口到统一 executor",
|
|
||||||
}));
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push(createFileTreeMenuItem("open", "打开", {
|
|
||||||
disabled: !hasDocument && !hasAsset,
|
|
||||||
reason: "当前行没有可打开资源",
|
|
||||||
}));
|
|
||||||
items.push(createFileTreeMenuItem("rename", "重命名", {
|
|
||||||
disabled: !canRename,
|
|
||||||
reason: "当前资源暂不支持重命名",
|
|
||||||
}));
|
|
||||||
items.push(createFileTreeMenuItem("copy", "复制", {
|
|
||||||
disabled: !canCopyCut,
|
|
||||||
reason: "当前资源暂不支持复制",
|
|
||||||
}));
|
|
||||||
items.push(createFileTreeMenuItem("cut", "剪切", {
|
|
||||||
disabled: !canCopyCut,
|
|
||||||
reason: "当前资源暂不支持剪切",
|
|
||||||
}));
|
|
||||||
items.push(createFileTreeMenuItem("paste", "粘贴", {
|
|
||||||
disabled: !canPaste,
|
|
||||||
reason: "剪贴板为空",
|
|
||||||
}));
|
|
||||||
items.push(createFileTreeMenuItem("moveTo", "移动到", {
|
|
||||||
disabled: true,
|
|
||||||
reason: "目标选择器尚未接入 filetree 菜单",
|
|
||||||
}));
|
|
||||||
items.push(createFileTreeMenuItem("delete", "删除", {
|
|
||||||
disabled: !canDelete,
|
|
||||||
reason: "当前资源暂不支持删除",
|
|
||||||
}));
|
|
||||||
items.push(createFileTreeMenuItem("reveal", "Reveal", { separatorBefore: true }));
|
|
||||||
if (hasAsset) {
|
|
||||||
items.push(createFileTreeMenuItem("download", "下载", {
|
|
||||||
disabled: true,
|
|
||||||
reason: "下载 executor 尚未接入",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (item?.capabilities?.includes("share")) {
|
|
||||||
items.push(createFileTreeMenuItem("share", "分享", { separatorBefore: true }));
|
|
||||||
}
|
|
||||||
if (item?.capabilities?.includes("publish")) {
|
|
||||||
items.push(createFileTreeMenuItem("publish", "发布"));
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
};
|
|
||||||
|
|
||||||
const localCreatedRowIdFromCommandResult = (result, rowKind) => {
|
const localCreatedRowIdFromCommandResult = (result, rowKind) => {
|
||||||
const relativePath =
|
const relativePath =
|
||||||
typeof result?.execution?.relativePath === "string" && result.execution.relativePath.trim()
|
typeof result?.execution?.relativePath === "string" && result.execution.relativePath.trim()
|
||||||
@@ -2360,184 +2208,31 @@ function startTreeShellRuntime() {
|
|||||||
return `local:${rowKind}:${relativePath}`;
|
return `local:${rowKind}:${relativePath}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const executeFileTreeContextMenuAction = async (kind, target) => {
|
const fileTreeMenuRuntime = createTreeShellFileTreeMenuRuntime({
|
||||||
closeFileTreeContextMenu();
|
sourceKind,
|
||||||
const item = target.item;
|
workspaceId,
|
||||||
if (kind === "open") {
|
getFileTreeRowById: () => fileTreeRowById,
|
||||||
if (item) openHydratedFileTreeItem(item);
|
getSelectedFileTreeRowIds: () => selectedFileTreeRowIds,
|
||||||
return;
|
getFileTreeClipboard: () => fileTreeClipboard,
|
||||||
}
|
setFileTreeClipboard,
|
||||||
if (kind === "rename") {
|
commitFileTreeSelection,
|
||||||
if (target.rowId && getFileTreeRowDocumentId(item)) {
|
syncFileTreeSelectionDom,
|
||||||
beginInlineRename("filetree", target.rowId);
|
pasteFileTreeClipboardInto,
|
||||||
}
|
runFileTreeDelete,
|
||||||
return;
|
handleCreate: (...args) => handleCreate(...args),
|
||||||
}
|
sendCommand,
|
||||||
if (kind === "copy" || kind === "cut") {
|
setStatus,
|
||||||
if (target.rowId && !selectedFileTreeRowIds.has(target.rowId)) {
|
setLastAction,
|
||||||
commitFileTreeSelection({
|
scheduleRefresh,
|
||||||
selectedRowIds: [target.rowId],
|
refreshLocalFolderSnapshot,
|
||||||
anchorRowId: target.rowId,
|
expanded,
|
||||||
focusedRowId: target.rowId,
|
renderTree: () => renderTree(),
|
||||||
});
|
postToHost,
|
||||||
syncFileTreeSelectionDom();
|
openHydratedFileTreeItem: (...args) => openHydratedFileTreeItem(...args),
|
||||||
}
|
beginInlineRename: (...args) => beginInlineRename(...args),
|
||||||
setFileTreeClipboard(kind);
|
localCreatedRowIdFromCommandResult,
|
||||||
return;
|
});
|
||||||
}
|
const openFileTreeContextMenu = fileTreeMenuRuntime.openFileTreeContextMenu;
|
||||||
if (kind === "paste") {
|
|
||||||
await pasteFileTreeClipboardInto(target.rowId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (kind === "delete") {
|
|
||||||
await runFileTreeDelete(target.rowId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (kind === "newPage") {
|
|
||||||
const parentId = item && item.rowKind === "folder"
|
|
||||||
? item.nodeId
|
|
||||||
: item && item.rowKind === "document"
|
|
||||||
? item.nodeId
|
|
||||||
: null;
|
|
||||||
await handleCreate(parentId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (kind === "newFolder") {
|
|
||||||
const parentId = item && item.rowKind === "folder" ? item.nodeId : null;
|
|
||||||
const result = await sendCommand({
|
|
||||||
action: "createFolder",
|
|
||||||
workspaceId,
|
|
||||||
documentId: "",
|
|
||||||
parentId,
|
|
||||||
title: "新建文件夹",
|
|
||||||
});
|
|
||||||
setStatus("创建文件夹成功");
|
|
||||||
setLastAction("已创建新建文件夹");
|
|
||||||
scheduleRefresh({
|
|
||||||
renameRowId: localCreatedRowIdFromCommandResult(result, "folder"),
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
if (kind === "refresh") {
|
|
||||||
await refreshLocalFolderSnapshot();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (kind === "collapseAll") {
|
|
||||||
expanded.clear();
|
|
||||||
renderTree();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (kind === "reveal") {
|
|
||||||
setLastAction(`Reveal ${target.documentId || target.assetId || target.rowId || "root"}`);
|
|
||||||
postToHost("tree.filetree.reveal", {
|
|
||||||
documentId: target.documentId,
|
|
||||||
assetId: target.assetId,
|
|
||||||
rowId: target.rowId,
|
|
||||||
payload: target,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
postToHost(`tree.filetree.${kind}`, {
|
|
||||||
documentId: target.documentId,
|
|
||||||
assetId: target.assetId,
|
|
||||||
rowId: target.rowId,
|
|
||||||
payload: target,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const openFileTreeContextMenu = ({
|
|
||||||
documentId,
|
|
||||||
assetId,
|
|
||||||
rowId,
|
|
||||||
rowKind,
|
|
||||||
clientX,
|
|
||||||
clientY,
|
|
||||||
}) => {
|
|
||||||
closeFileTreeContextMenu();
|
|
||||||
const target = buildFileTreeMenuTarget(rowId, rowKind, documentId, assetId);
|
|
||||||
const profile = buildFileTreeContextMenuProfile(target);
|
|
||||||
const menu = document.createElement("div");
|
|
||||||
menu.className = "tree-context-menu";
|
|
||||||
menu.dataset.testid = "filetree-context-menu";
|
|
||||||
menu.dataset.sourceKind = sourceKind;
|
|
||||||
menu.dataset.rowKind = target.rowKind;
|
|
||||||
menu.setAttribute("role", "menu");
|
|
||||||
|
|
||||||
profile.forEach((entry) => {
|
|
||||||
if (entry.separatorBefore) {
|
|
||||||
const separator = document.createElement("div");
|
|
||||||
separator.className = "tree-menu-separator";
|
|
||||||
separator.setAttribute("role", "separator");
|
|
||||||
menu.appendChild(separator);
|
|
||||||
}
|
|
||||||
const button = document.createElement("button");
|
|
||||||
button.type = "button";
|
|
||||||
button.className = "tree-menu-item";
|
|
||||||
button.dataset.menuAction = entry.kind;
|
|
||||||
button.setAttribute("role", "menuitem");
|
|
||||||
button.textContent = entry.label;
|
|
||||||
if (entry.disabled) {
|
|
||||||
button.disabled = true;
|
|
||||||
if (entry.reason) {
|
|
||||||
button.title = entry.reason;
|
|
||||||
button.dataset.disabledReason = entry.reason;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
void executeFileTreeContextMenuAction(entry.kind, target);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
menu.appendChild(button);
|
|
||||||
});
|
|
||||||
|
|
||||||
const closeOnOutside = (event) => {
|
|
||||||
if (activeFileTreeMenuElement && !activeFileTreeMenuElement.contains(event.target)) {
|
|
||||||
closeFileTreeContextMenu();
|
|
||||||
document.removeEventListener("mousedown", closeOnOutside, true);
|
|
||||||
document.removeEventListener("keydown", closeOnKeyDown, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const closeOnKeyDown = (event) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
closeFileTreeContextMenu();
|
|
||||||
document.removeEventListener("mousedown", closeOnOutside, true);
|
|
||||||
document.removeEventListener("keydown", closeOnKeyDown, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.body.appendChild(menu);
|
|
||||||
const x = Number.isFinite(clientX) ? clientX : 16;
|
|
||||||
const y = Number.isFinite(clientY) ? clientY : 16;
|
|
||||||
const rect = menu.getBoundingClientRect();
|
|
||||||
menu.style.left = `${Math.max(4, Math.min(x, window.innerWidth - rect.width - 4))}px`;
|
|
||||||
menu.style.top = `${Math.max(4, Math.min(y, window.innerHeight - rect.height - 4))}px`;
|
|
||||||
activeFileTreeMenuElement = menu;
|
|
||||||
window.setTimeout(() => {
|
|
||||||
document.addEventListener("mousedown", closeOnOutside, true);
|
|
||||||
document.addEventListener("keydown", closeOnKeyDown, true);
|
|
||||||
}, 0);
|
|
||||||
setLastAction(
|
|
||||||
assetId
|
|
||||||
? `已打开资源 ${assetId} 的更多操作`
|
|
||||||
: `已打开文件树节点 ${documentId || rowId || "unknown"} 的更多操作`,
|
|
||||||
);
|
|
||||||
postToHost("tree.filetree.context-menu", {
|
|
||||||
documentId,
|
|
||||||
assetId,
|
|
||||||
rowId,
|
|
||||||
rowKind,
|
|
||||||
payload: {
|
|
||||||
documentId,
|
|
||||||
assetId,
|
|
||||||
rowId,
|
|
||||||
rowKind,
|
|
||||||
x: clientX,
|
|
||||||
y: clientY,
|
|
||||||
},
|
|
||||||
x: clientX,
|
|
||||||
y: clientY,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const openContextMenu = (nodeId, clientX, clientY) => {
|
const openContextMenu = (nodeId, clientX, clientY) => {
|
||||||
if (!nodeId || mode !== "page") return;
|
if (!nodeId || mode !== "page") return;
|
||||||
|
|||||||
@@ -182,6 +182,10 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
"/api/mnote-browser-runtime/tree-shell-filetree-runtime.js",
|
"/api/mnote-browser-runtime/tree-shell-filetree-runtime.js",
|
||||||
get(web_shell::tree_shell_filetree_runtime_asset),
|
get(web_shell::tree_shell_filetree_runtime_asset),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/mnote-browser-runtime/tree-shell-filetree-menu-runtime.js",
|
||||||
|
get(web_shell::tree_shell_filetree_menu_runtime_asset),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/mnote-browser-runtime/tree-shell-picker-runtime.js",
|
"/api/mnote-browser-runtime/tree-shell-picker-runtime.js",
|
||||||
get(web_shell::tree_shell_picker_runtime_asset),
|
get(web_shell::tree_shell_picker_runtime_asset),
|
||||||
@@ -645,6 +649,7 @@ mod tests {
|
|||||||
"/api/mnote-browser-runtime/tree-shell-state-runtime.js",
|
"/api/mnote-browser-runtime/tree-shell-state-runtime.js",
|
||||||
"/api/mnote-browser-runtime/tree-shell-icons-runtime.js",
|
"/api/mnote-browser-runtime/tree-shell-icons-runtime.js",
|
||||||
"/api/mnote-browser-runtime/tree-shell-filetree-runtime.js",
|
"/api/mnote-browser-runtime/tree-shell-filetree-runtime.js",
|
||||||
|
"/api/mnote-browser-runtime/tree-shell-filetree-menu-runtime.js",
|
||||||
"/api/mnote-browser-runtime/tree-shell-picker-runtime.js",
|
"/api/mnote-browser-runtime/tree-shell-picker-runtime.js",
|
||||||
"/api/mnote-browser-runtime/tree-shell-dom-runtime.js",
|
"/api/mnote-browser-runtime/tree-shell-dom-runtime.js",
|
||||||
"/api/mnote-browser-runtime/document-conflict-panel-runtime.js",
|
"/api/mnote-browser-runtime/document-conflict-panel-runtime.js",
|
||||||
|
|||||||
@@ -2395,6 +2395,8 @@ mod tests {
|
|||||||
const TREE_SHELL_RUNTIME_JS: &str = include_str!("../../browser/tree-shell-runtime.js");
|
const TREE_SHELL_RUNTIME_JS: &str = include_str!("../../browser/tree-shell-runtime.js");
|
||||||
const TREE_SHELL_FILETREE_RUNTIME_JS: &str =
|
const TREE_SHELL_FILETREE_RUNTIME_JS: &str =
|
||||||
include_str!("../../browser/tree-shell-filetree-runtime.js");
|
include_str!("../../browser/tree-shell-filetree-runtime.js");
|
||||||
|
const TREE_SHELL_FILETREE_MENU_RUNTIME_JS: &str =
|
||||||
|
include_str!("../../browser/tree-shell-filetree-menu-runtime.js");
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext,
|
collect_filetree_render_rows, create_command_wire, TreeCommandEnvelopeContext,
|
||||||
@@ -2676,9 +2678,12 @@ mod tests {
|
|||||||
assert!(html.contains("\"rowKind\":\"asset_folder\""));
|
assert!(html.contains("\"rowKind\":\"asset_folder\""));
|
||||||
assert!(html.contains("\"resourceMeta\""));
|
assert!(html.contains("\"resourceMeta\""));
|
||||||
assert!(TREE_SHELL_RUNTIME_JS.contains("tree-shell-filetree-runtime.js"));
|
assert!(TREE_SHELL_RUNTIME_JS.contains("tree-shell-filetree-runtime.js"));
|
||||||
|
assert!(TREE_SHELL_RUNTIME_JS.contains("tree-shell-filetree-menu-runtime.js"));
|
||||||
assert!(
|
assert!(
|
||||||
TREE_SHELL_FILETREE_RUNTIME_JS.contains("function getFileTreeRowOwnerDocumentId(item)")
|
TREE_SHELL_FILETREE_RUNTIME_JS.contains("function getFileTreeRowOwnerDocumentId(item)")
|
||||||
);
|
);
|
||||||
|
assert!(TREE_SHELL_FILETREE_MENU_RUNTIME_JS
|
||||||
|
.contains("function buildFileTreeMenuTarget(context"));
|
||||||
assert!(TREE_SHELL_RUNTIME_JS
|
assert!(TREE_SHELL_RUNTIME_JS
|
||||||
.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";"));
|
.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";"));
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -994,6 +994,20 @@ pub async fn tree_shell_filetree_runtime_asset() -> Response {
|
|||||||
.unwrap_or_else(|_| Response::new(Body::empty()))
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn tree_shell_filetree_menu_runtime_asset() -> Response {
|
||||||
|
const JS: &str = include_str!("../../browser/tree-shell-filetree-menu-runtime.js");
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
"application/javascript; charset=utf-8",
|
||||||
|
)
|
||||||
|
.header(header::CACHE_CONTROL, "no-store")
|
||||||
|
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
|
||||||
|
.body(Body::from(JS))
|
||||||
|
.unwrap_or_else(|_| Response::new(Body::empty()))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn tree_shell_picker_runtime_asset() -> Response {
|
pub async fn tree_shell_picker_runtime_asset() -> Response {
|
||||||
const JS: &str = include_str!("../../browser/tree-shell-picker-runtime.js");
|
const JS: &str = include_str!("../../browser/tree-shell-picker-runtime.js");
|
||||||
Response::builder()
|
Response::builder()
|
||||||
|
|||||||
Reference in New Issue
Block a user