// MNote debug /tree shell fallback renderer 浏览器运行时模块。 // 该模块只承接 DOM fallback renderer,状态和命令语义仍由 tree-shell-runtime.js 注入。 import { TREE_SHELL_ICONS as ICONS, createTreeShellActionButton, createTreeShellKindBadge as createKindBadge, } from "./tree-shell-icons-runtime.js"; import { getFileTreeRowAssetId, getFileTreeRowDocumentId, getFileTreeRowIconKind, getFileTreeRowMetaLabel, getFileTreeRowOwnerDocumentId, } from "./tree-shell-filetree-runtime.js"; export function createTreeShellRenderer(context) { const { FILETREE_DRAG_MIME, PAGE_DRAG_MIME, allowRootPick, appElement, applyPageKeyboardAction, applyPickerFocusByItemKey, applyPickerStateAction, attachInlineRenameInput, beginInlineRename, canAcceptPageDrop, canExpandFileTreeRow, clearFileTreeDropFeedback, clearFileTreeSelection, clearPageDropFeedback, executeLocalFileTreeExternalDrop, executeLocalFileTreeInternalDrop, expanded, getActiveFileTreeDropPosition, getActiveFileTreeDropRowId, getActiveFileTreeRootDrop, getActivePageDropNodeId, getCurrentActiveDocumentId, getElementCenter, getFileTreeDropTargetFromEvent, getFileTreeRowById, focusRowElement, getFocusedNodeId, getInlineRenameState, getItemById, getRoots, getSelectedFileTreeRowIds, getSiblings, handleCreate, handleFileTreeKeyDown, handleMove, handleNavigate, handlePageDropMove, handleRowKeyDown, isBusy, mode, normalizeText, normalizeFileTreeSelectionForVisibleRows, openContextMenu, openFileTreeContextMenu, patchFileTreeCutDecoration, postPickerPickResultToHost, postToHost, pushVisibleFileTreeRowId, readFileTreeInternalDropPayload, readPageDragNodeId, resolveFileTreeDraggedRowIds, resolvePageDropTargetNodeId, resolvePickerRootFocused, runFileTreePreflight, selectFileTreeContextRow, selectFileTreeRow, setFileTreeDropFeedback, setDraggingFileTreeRowIds, setDraggingPageNodeId, setLastAction, setPageDropFeedback, setVisibleFileTreeRowIds, sourceKind, toggleExpand, validateFileTreeInternalDrop, workspaceId, } = context; const createActionButton = (icon, testId, title, onClick, disabled) => createTreeShellActionButton(icon, testId, title, onClick, disabled, isBusy()); const renderNode = (item) => { const hasChildren = item.childCount > 0; const row = document.createElement("div"); row.className = "tree-row"; row.dataset.active = String(item.nodeId === getCurrentActiveDocumentId()); row.dataset.focused = String(item.nodeId === getFocusedNodeId()); row.dataset.nodeId = item.nodeId; row.dataset.shellMode = mode; row.dataset.dropFeedback = String(getActivePageDropNodeId() === item.nodeId); row.tabIndex = item.nodeId === getFocusedNodeId() ? 0 : -1; row.setAttribute("role", "treeitem"); row.setAttribute("aria-level", String(item.depth + 1)); row.setAttribute("aria-expanded", hasChildren ? String(expanded.has(item.nodeId)) : "false"); row.draggable = mode === "page"; row.dataset.draggable = String(mode === "page"); row.addEventListener("focus", () => { if (getFocusedNodeId() !== item.nodeId) { applyPageKeyboardAction({ kind: "focus", nodeId: item.nodeId }, item, row); } }); row.addEventListener("keydown", (event) => handleRowKeyDown(event, item)); row.addEventListener("contextmenu", (event) => { if (mode !== "page") return; event.preventDefault(); openContextMenu(item.nodeId, event.clientX, event.clientY); }); row.addEventListener("dragstart", (event) => { if (mode !== "page") return; setDraggingPageNodeId(item.nodeId); if (event.dataTransfer) { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData(PAGE_DRAG_MIME, item.nodeId); event.dataTransfer.setData("text/plain", item.nodeId); } setLastAction(`开始拖拽页面 ${item.title}`); }); row.addEventListener("dragover", (event) => { if (mode !== "page") return; const sourceNodeId = readPageDragNodeId(event); const targetNodeId = resolvePageDropTargetNodeId(event.target); if (!canAcceptPageDrop(sourceNodeId, targetNodeId)) { clearPageDropFeedback(); return; } event.preventDefault(); if (event.dataTransfer) { event.dataTransfer.dropEffect = "move"; } setPageDropFeedback(targetNodeId); }); row.addEventListener("dragleave", (event) => { if (mode !== "page") return; const relatedTarget = event.relatedTarget instanceof Node ? event.relatedTarget : null; if (relatedTarget && row.contains(relatedTarget)) { return; } if (getActivePageDropNodeId() === item.nodeId) { clearPageDropFeedback(); } }); row.addEventListener("drop", (event) => { if (mode !== "page") return; const sourceNodeId = readPageDragNodeId(event); const targetNodeId = resolvePageDropTargetNodeId(event.target); clearPageDropFeedback(); setDraggingPageNodeId(""); if (!canAcceptPageDrop(sourceNodeId, targetNodeId)) { return; } event.preventDefault(); void handlePageDropMove(sourceNodeId, targetNodeId); }); row.addEventListener("dragend", () => { if (mode !== "page") return; setDraggingPageNodeId(""); clearPageDropFeedback(); }); if (hasChildren) { const toggleButton = document.createElement("button"); toggleButton.type = "button"; toggleButton.className = "tree-toggle"; toggleButton.setAttribute("data-testid", "tree-node-toggle"); toggleButton.setAttribute( "aria-label", `${expanded.has(item.nodeId) ? "折叠" : "展开"} ${item.title}`, ); toggleButton.textContent = expanded.has(item.nodeId) ? "▾" : "▸"; toggleButton.addEventListener("click", (event) => { event.stopPropagation(); applyPageKeyboardAction({ kind: "toggle", nodeId: item.nodeId }, item, event.currentTarget); }); row.appendChild(toggleButton); } else { const spacer = document.createElement("div"); spacer.className = "tree-spacer"; row.appendChild(spacer); } row.appendChild(createKindBadge("page")); const linkButton = document.createElement("button"); linkButton.type = "button"; linkButton.className = "tree-link"; linkButton.setAttribute("data-testid", "tree-node-open"); linkButton.setAttribute("aria-label", `打开 ${item.title}`); linkButton.addEventListener("click", () => { if (mode === "picker") { applyPickerFocusByItemKey(item.nodeId, { focusDom: true }); postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" })); return; } handleNavigate(item.nodeId); }); if (getInlineRenameState().mode === "page" && getInlineRenameState().id === item.nodeId) { const renameInput = document.createElement("input"); renameInput.type = "text"; renameInput.className = "tree-rename-input"; renameInput.value = item.title; renameInput.setAttribute("aria-label", `重命名 ${item.title}`); renameInput.dataset.renameId = item.nodeId; attachInlineRenameInput(renameInput, item.title); linkButton.appendChild(renameInput); } else { const titleElement = document.createElement("span"); titleElement.className = "tree-link-title"; titleElement.textContent = item.title; linkButton.appendChild(titleElement); } const metaElement = document.createElement("span"); metaElement.className = "tree-link-meta"; metaElement.textContent = `${item.nodeId} · ${item.childCount} 个子页面`; linkButton.appendChild(metaElement); row.appendChild(linkButton); const actions = document.createElement("div"); actions.className = "tree-actions"; const siblingRows = getSiblings(item.parentNodeId); const siblingIndex = siblingRows.findIndex((entry) => entry.nodeId === item.nodeId); actions.appendChild( createActionButton( ICONS.add, "tree-action-create", `在 ${item.title} 下新建子页面`, () => handleCreate(item.nodeId), false, ), ); if (mode === "page") { actions.appendChild( createActionButton( ICONS.edit, "tree-action-rename", `重命名 ${item.title}`, () => beginInlineRename("page", item.nodeId), false, ), ); actions.appendChild( createActionButton( ICONS.up, "tree-action-move-up", `上移 ${item.title}`, () => void handleMove(item.nodeId, -1), siblingIndex <= 0, ), ); actions.appendChild( createActionButton( ICONS.more, "tree-action-menu", `打开 ${item.title} 的更多操作`, (button) => { const center = getElementCenter(button); openContextMenu( item.nodeId, center.x, center.y, ); }, false, ), ); } row.appendChild(actions); const nodeElement = document.createElement("li"); nodeElement.className = "tree-node"; nodeElement.dataset.nodeId = item.nodeId; nodeElement.appendChild(row); if (hasChildren && expanded.has(item.nodeId)) { const children = getSiblings(item.nodeId); if (children.length > 0) { const childrenList = document.createElement("ul"); childrenList.className = "tree-children"; children.forEach((child) => { childrenList.appendChild(renderNode(child)); }); nodeElement.appendChild(childrenList); } } return nodeElement; }; const syncFileTreeSelectionDom = () => { if (mode !== "filetree") return; appElement.querySelectorAll('[data-rust-rendered-row="filetree"]').forEach((row) => { if (!(row instanceof HTMLElement)) return; const rowId = normalizeText(row.dataset.rowId); row.dataset.selected = String(Boolean(rowId && getSelectedFileTreeRowIds().has(rowId))); }); }; const openHydratedFileTreeItem = (item) => { const documentId = getFileTreeRowDocumentId(item); const ownerDocumentId = getFileTreeRowOwnerDocumentId(item); const assetId = getFileTreeRowAssetId(item); if (item.rowKind === "document" || item.rowKind === "index") { handleNavigate(documentId || item.nodeId); return; } setLastAction(`准备打开资源 ${assetId || item.rowId}`); postToHost("tree.asset.open", { documentId: ownerDocumentId || null, assetId: assetId || null, objectIdentity: item.resourceMeta?.objectIdentity || null, target: { documentId: ownerDocumentId || null }, payload: { documentId: ownerDocumentId || null, assetId: assetId || null, rowId: item.rowId, rowKind: item.rowKind, objectIdentity: item.resourceMeta?.objectIdentity || null, }, }); }; const postHydratedFileTreeDropToHost = (type, target, extra = {}) => { postToHost(type, { workspaceId, rowId: target.rowId, rowKind: target.rowKind, targetRowId: target.rowId, targetRowKind: target.rowKind, documentId: target.documentId, assetId: target.assetId, ...extra, payload: { workspaceId, rowId: target.rowId, rowKind: target.rowKind, targetRowId: target.rowId, targetRowKind: target.rowKind, documentId: target.documentId, assetId: target.assetId, ...extra, }, }); }; const attachHydratedFileTreeDragSource = (row, item) => { row.draggable = true; row.addEventListener("dragstart", (event) => { const rowIds = resolveFileTreeDraggedRowIds(item.rowId); setDraggingFileTreeRowIds(rowIds); if (event.dataTransfer) { const payload = JSON.stringify({ type: "mnote-file-tree-dnd", version: 1, rowIds, }); event.dataTransfer.effectAllowed = "copyMove"; event.dataTransfer.setData(FILETREE_DRAG_MIME, payload); event.dataTransfer.setData("application/x-mnote-file-tree", payload); event.dataTransfer.setData("text/plain", payload); } setLastAction(`开始拖拽 ${rowIds.length} 个文件树节点`); }); row.addEventListener("dragend", () => { setDraggingFileTreeRowIds([]); clearFileTreeDropFeedback(); }); }; const bindFileTreeRootEvents = (root) => { root.dataset.dropTarget = String(getActiveFileTreeRootDrop()); root.addEventListener("mousedown", (event) => { if (event.target !== event.currentTarget) return; clearFileTreeSelection(); }); root.addEventListener("contextmenu", (event) => { if (event.target !== event.currentTarget) return; event.preventDefault(); clearFileTreeSelection(); openFileTreeContextMenu({ documentId: null, assetId: null, rowId: null, rowKind: "root", clientX: event.clientX, clientY: event.clientY, }); }); root.addEventListener("dragover", (event) => { const internalRowIds = readFileTreeInternalDropPayload(event); const files = Array.from(event.dataTransfer?.files || []); if (!internalRowIds && files.length === 0) { return; } event.preventDefault(); const target = getFileTreeDropTargetFromEvent(event); if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) { clearFileTreeDropFeedback(); return; } if (event.dataTransfer) { event.dataTransfer.dropEffect = files.length > 0 || event.altKey ? "copy" : "move"; } setFileTreeDropFeedback(target); }); root.addEventListener("dragleave", (event) => { const relatedTarget = event.relatedTarget instanceof Node ? event.relatedTarget : null; if (relatedTarget && root.contains(relatedTarget)) { return; } clearFileTreeDropFeedback(); }); root.addEventListener("drop", (event) => { const internalRowIds = readFileTreeInternalDropPayload(event); const files = Array.from(event.dataTransfer?.files || []); if (!internalRowIds && files.length === 0) { return; } event.preventDefault(); const target = getFileTreeDropTargetFromEvent(event); if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) { clearFileTreeDropFeedback(); return; } if (files.length > 0) { if (sourceKind === "local_folder") { void executeLocalFileTreeExternalDrop(target, files); } else { void (async () => { const accepted = await runFileTreePreflight("dropFiles", { target, files }); if (!accepted) return; setLastAction(`已发送 ${files.length} 个外部文件到宿主`); postHydratedFileTreeDropToHost("tree.filetree.external-drop", target, { files, }); })(); } } else { if (sourceKind === "local_folder") { void executeLocalFileTreeInternalDrop(target, internalRowIds, event.altKey === true); } else { void (async () => { const accepted = await runFileTreePreflight("move", { target, rowIds: internalRowIds, copy: event.altKey === true, }); if (!accepted) return; setLastAction( event.altKey ? `已发送复制拖放到 ${target.rowId || "根目录"}` : `已发送移动拖放到 ${target.rowId || "根目录"}`, ); postHydratedFileTreeDropToHost("tree.filetree.internal-drop", target, { rowIds: internalRowIds, copy: event.altKey === true, }); })(); } } setDraggingFileTreeRowIds([]); clearFileTreeDropFeedback(); }); }; const bindFileTreeRowEvents = (row, item) => { if (!(row instanceof HTMLElement) || !item) return; const documentId = getFileTreeRowDocumentId(item) || null; const ownerDocumentId = getFileTreeRowOwnerDocumentId(item) || null; const assetId = getFileTreeRowAssetId(item) || null; row.dataset.active = String( item.rowKind === "document" && documentId === getCurrentActiveDocumentId() ); row.dataset.nodeId = item.nodeId; row.dataset.rowId = item.rowId; row.dataset.rowKind = item.rowKind; row.dataset.documentId = documentId || ""; row.dataset.ownerDocumentId = ownerDocumentId || ""; row.dataset.assetId = assetId || ""; row.dataset.objectIdentity = item.resourceMeta?.objectIdentity ? JSON.stringify(item.resourceMeta.objectIdentity) : ""; row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || ""; row.dataset.shellMode = "filetree"; row.dataset.selected = String(getSelectedFileTreeRowIds().has(item.rowId)); row.dataset.dropTarget = String(getActiveFileTreeDropRowId() === item.rowId); if (getActiveFileTreeDropRowId() === item.rowId && getActiveFileTreeDropPosition()) { row.dataset.dropPosition = getActiveFileTreeDropPosition(); } row.tabIndex = 0; row.setAttribute("role", "treeitem"); row.setAttribute("aria-level", String(item.depth + 1)); row.setAttribute( "aria-expanded", canExpandFileTreeRow(item) ? String(expanded.has(item.nodeId)) : "false", ); row.addEventListener("click", (event) => { selectFileTreeRow(item.rowId, event); row.focus({ preventScroll: true }); syncFileTreeSelectionDom(); }); row.addEventListener("dblclick", () => { openHydratedFileTreeItem(item); }); row.addEventListener("keydown", (event) => handleFileTreeKeyDown(event, item)); row.addEventListener("contextmenu", (event) => { event.preventDefault(); selectFileTreeContextRow(item.rowId); syncFileTreeSelectionDom(); openFileTreeContextMenu({ documentId, assetId, rowId: item.rowId, rowKind: item.rowKind, clientX: event.clientX, clientY: event.clientY, }); }); attachHydratedFileTreeDragSource(row, item); patchFileTreeCutDecoration(); row.querySelectorAll("[data-rust-action]").forEach((element) => { if (!(element instanceof HTMLElement)) return; element.addEventListener("click", (event) => { const action = normalizeText(element.dataset.rustAction); if (action === "open") { openHydratedFileTreeItem(item); return; } if (action === "menu") { event.preventDefault(); event.stopPropagation(); selectFileTreeContextRow(item.rowId); syncFileTreeSelectionDom(); const center = getElementCenter(element); openFileTreeContextMenu({ documentId, assetId, rowId: item.rowId, rowKind: item.rowKind, clientX: center.x, clientY: center.y, }); } }); }); }; const hydrateInitialFileTree = () => { if (mode !== "filetree") return false; const root = appElement.querySelector('[data-rust-filetree-renderer="initial_v1"]'); if (!(root instanceof HTMLElement)) { return false; } setVisibleFileTreeRowIds([]); bindFileTreeRootEvents(root); root.querySelectorAll('[data-rust-rendered-row="filetree"]').forEach((row) => { if (!(row instanceof HTMLElement)) return; const rowId = normalizeText(row.dataset.rowId); const nodeId = normalizeText(row.dataset.nodeId); const item = getFileTreeRowById().get(rowId) || getItemById().get(nodeId); if (!item) return; pushVisibleFileTreeRowId(item.rowId); bindFileTreeRowEvents(row, item); }); normalizeFileTreeSelectionForVisibleRows(); syncFileTreeSelectionDom(); return true; }; const renderFileTree = () => { appElement.innerHTML = ""; setVisibleFileTreeRowIds([]); clearFileTreeDropFeedback(); const fileRoot = document.createElement("div"); fileRoot.className = "tree-root"; fileRoot.setAttribute("role", "tree"); fileRoot.dataset.dropTarget = String(getActiveFileTreeRootDrop()); fileRoot.addEventListener("mousedown", (event) => { if (event.target !== event.currentTarget) return; clearFileTreeSelection(); }); const openFileTreeItem = (item) => { const documentId = getFileTreeRowDocumentId(item); const ownerDocumentId = getFileTreeRowOwnerDocumentId(item); const assetId = getFileTreeRowAssetId(item); if (item.rowKind === "document" || item.rowKind === "index" || item.rowKind === "markdown") { handleNavigate(documentId || item.nodeId); return; } setLastAction(`准备打开资源 ${assetId || item.rowId}`); postToHost("tree.asset.open", { documentId: ownerDocumentId || null, assetId: assetId || null, objectIdentity: item.resourceMeta?.objectIdentity || null, target: { documentId: ownerDocumentId || null }, payload: { documentId: ownerDocumentId || null, assetId: assetId || null, rowId: item.rowId, rowKind: item.rowKind, objectIdentity: item.resourceMeta?.objectIdentity || null, }, }); }; const postFileTreeDropToHost = (type, target, extra = {}) => { postToHost(type, { workspaceId, rowId: target.rowId, rowKind: target.rowKind, targetRowId: target.rowId, targetRowKind: target.rowKind, documentId: target.documentId, assetId: target.assetId, ...extra, payload: { workspaceId, rowId: target.rowId, rowKind: target.rowKind, targetRowId: target.rowId, targetRowKind: target.rowKind, documentId: target.documentId, assetId: target.assetId, ...extra, }, }); }; const attachFileTreeDragSource = (row, item) => { row.draggable = true; row.addEventListener("dragstart", (event) => { const rowIds = resolveFileTreeDraggedRowIds(item.rowId); setDraggingFileTreeRowIds(rowIds); if (event.dataTransfer) { const payload = JSON.stringify({ type: "mnote-file-tree-dnd", version: 1, rowIds, }); event.dataTransfer.effectAllowed = "copyMove"; event.dataTransfer.setData(FILETREE_DRAG_MIME, payload); event.dataTransfer.setData("application/x-mnote-file-tree", payload); event.dataTransfer.setData("text/plain", payload); } setLastAction(`开始拖拽 ${rowIds.length} 个文件树节点`); }); row.addEventListener("dragend", () => { setDraggingFileTreeRowIds([]); clearFileTreeDropFeedback(); }); }; const appendFileTreeRow = (container, item) => { const children = getSiblings(item.nodeId); const hasBranches = canExpandFileTreeRow(item) && children.length > 0; const documentId = getFileTreeRowDocumentId(item) || null; const ownerDocumentId = getFileTreeRowOwnerDocumentId(item) || null; const assetId = getFileTreeRowAssetId(item) || null; const row = document.createElement("div"); row.className = "tree-row"; row.style.marginLeft = `${item.depth * 22}px`; row.dataset.active = String( item.rowKind === "document" && documentId === getCurrentActiveDocumentId() ); row.dataset.nodeId = item.nodeId; row.dataset.rowId = item.rowId; row.dataset.rowKind = item.rowKind; row.dataset.documentId = documentId || ""; row.dataset.ownerDocumentId = ownerDocumentId || ""; row.dataset.assetId = assetId || ""; row.dataset.objectIdentity = item.resourceMeta?.objectIdentity ? JSON.stringify(item.resourceMeta.objectIdentity) : ""; row.dataset.objectKind = item.resourceMeta?.objectIdentity?.objectKind || ""; row.dataset.shellMode = "filetree"; row.dataset.selected = String(getSelectedFileTreeRowIds().has(item.rowId)); row.dataset.dropTarget = String(getActiveFileTreeDropRowId() === item.rowId); if (getActiveFileTreeDropRowId() === item.rowId && getActiveFileTreeDropPosition()) { row.dataset.dropPosition = getActiveFileTreeDropPosition(); } row.setAttribute( "data-testid", item.rowKind === "document" ? "filetree-doc-row" : item.rowKind === "index" ? "filetree-index-row" : "filetree-asset-row", ); row.tabIndex = 0; row.setAttribute("role", "treeitem"); row.setAttribute("aria-level", String(item.depth + 1)); row.setAttribute("aria-expanded", hasBranches ? String(expanded.has(item.nodeId)) : "false"); pushVisibleFileTreeRowId(item.rowId); row.addEventListener("click", (event) => { selectFileTreeRow(item.rowId, event); renderTree(); const nextRow = appElement.querySelector( `.tree-row[data-shell-mode="filetree"][data-row-id="${CSS.escape(item.rowId)}"]`, ); if (nextRow instanceof HTMLElement) { nextRow.focus({ preventScroll: true }); } }); row.addEventListener("dblclick", () => { openFileTreeItem(item); }); row.addEventListener("keydown", (event) => handleFileTreeKeyDown(event, item)); row.addEventListener("contextmenu", (event) => { event.preventDefault(); selectFileTreeContextRow(item.rowId); renderTree(); openFileTreeContextMenu({ documentId, assetId, rowId: item.rowId, rowKind: item.rowKind, clientX: event.clientX, clientY: event.clientY, }); }); attachFileTreeDragSource(row, item); if (hasBranches) { const toggleButton = document.createElement("button"); toggleButton.type = "button"; toggleButton.className = "tree-toggle"; toggleButton.setAttribute( "aria-label", `${expanded.has(item.nodeId) ? "折叠" : "展开"} ${item.title}`, ); toggleButton.textContent = expanded.has(item.nodeId) ? "▾" : "▸"; toggleButton.addEventListener("click", (event) => { event.stopPropagation(); toggleExpand(item.nodeId); }); row.appendChild(toggleButton); } else { const spacer = document.createElement("div"); spacer.className = "tree-spacer"; row.appendChild(spacer); } row.appendChild(createKindBadge(getFileTreeRowIconKind(item))); const linkButton = document.createElement("button"); linkButton.type = "button"; linkButton.className = "tree-link"; if (item.rowKind === "document") { linkButton.setAttribute("data-testid", "filetree-doc-open"); } else if (item.rowKind === "asset" || item.rowKind === "asset_folder") { linkButton.setAttribute("data-testid", "filetree-asset-open"); } linkButton.addEventListener("click", () => openFileTreeItem(item)); if (getInlineRenameState().mode === "filetree" && getInlineRenameState().id === item.rowId) { const renameInput = document.createElement("input"); renameInput.type = "text"; renameInput.className = "tree-rename-input"; renameInput.value = item.title; renameInput.setAttribute("aria-label", `重命名 ${item.title}`); renameInput.dataset.renameId = item.rowId; attachInlineRenameInput(renameInput, item.title); linkButton.appendChild(renameInput); } else { const title = document.createElement("span"); title.className = "tree-link-title"; title.textContent = item.title; linkButton.appendChild(title); } const meta = document.createElement("span"); meta.className = "tree-link-meta"; meta.textContent = getFileTreeRowMetaLabel(item); linkButton.appendChild(meta); row.appendChild(linkButton); const actions = document.createElement("div"); actions.className = "tree-actions"; if (getFileTreeRowDocumentId(item)) { actions.appendChild( createActionButton( ICONS.edit, "filetree-action-rename", `重命名 ${item.title}`, () => beginInlineRename("filetree", item.rowId), false, ), ); } actions.appendChild( createActionButton( ICONS.more, "filetree-action-menu", `打开 ${item.title} 的更多操作`, (button) => { const center = getElementCenter(button); openFileTreeContextMenu({ documentId, assetId, rowId: item.rowId, rowKind: item.rowKind, clientX: center.x, clientY: center.y, }); }, false, ), ); row.appendChild(actions); container.appendChild(row); if (!hasBranches || !expanded.has(item.nodeId)) return; children.forEach((child) => appendFileTreeRow(container, child)); }; fileRoot.addEventListener("dragover", (event) => { const internalRowIds = readFileTreeInternalDropPayload(event); const files = Array.from(event.dataTransfer?.files || []); if (!internalRowIds && files.length === 0) { return; } event.preventDefault(); const target = getFileTreeDropTargetFromEvent(event); if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) { clearFileTreeDropFeedback(); return; } if (event.dataTransfer) { event.dataTransfer.dropEffect = files.length > 0 || event.altKey ? "copy" : "move"; } setFileTreeDropFeedback(target); }); fileRoot.addEventListener("dragleave", (event) => { const relatedTarget = event.relatedTarget instanceof Node ? event.relatedTarget : null; if (relatedTarget && fileRoot.contains(relatedTarget)) { return; } clearFileTreeDropFeedback(); }); fileRoot.addEventListener("contextmenu", (event) => { if (event.target !== event.currentTarget) return; event.preventDefault(); clearFileTreeSelection(); openFileTreeContextMenu({ documentId: null, assetId: null, rowId: null, rowKind: "root", clientX: event.clientX, clientY: event.clientY, }); }); fileRoot.addEventListener("drop", (event) => { const internalRowIds = readFileTreeInternalDropPayload(event); const files = Array.from(event.dataTransfer?.files || []); if (!internalRowIds && files.length === 0) { return; } event.preventDefault(); const target = getFileTreeDropTargetFromEvent(event); if (internalRowIds && !validateFileTreeInternalDrop(target, internalRowIds, event.altKey === true).ok) { clearFileTreeDropFeedback(); return; } if (files.length > 0) { if (sourceKind === "local_folder") { void executeLocalFileTreeExternalDrop(target, files); } else { void (async () => { const accepted = await runFileTreePreflight("dropFiles", { target, files }); if (!accepted) return; setLastAction(`已发送 ${files.length} 个外部文件到宿主`); postFileTreeDropToHost("tree.filetree.external-drop", target, { files, }); })(); } } else { if (sourceKind === "local_folder") { void executeLocalFileTreeInternalDrop(target, internalRowIds, event.altKey === true); } else { void (async () => { const accepted = await runFileTreePreflight("move", { target, rowIds: internalRowIds, copy: event.altKey === true, }); if (!accepted) return; setLastAction( event.altKey ? `已发送复制拖放到 ${target.rowId || "根目录"}` : `已发送移动拖放到 ${target.rowId || "根目录"}`, ); postFileTreeDropToHost("tree.filetree.internal-drop", target, { rowIds: internalRowIds, copy: event.altKey === true, }); })(); } } setDraggingFileTreeRowIds([]); clearFileTreeDropFeedback(); }); if (getRoots().length === 0) { const empty = document.createElement("div"); empty.className = "tree-empty"; empty.textContent = "当前 file tree 没有可渲染的页面。"; fileRoot.appendChild(empty); } else { getRoots().forEach((item) => appendFileTreeRow(fileRoot, item)); } normalizeFileTreeSelectionForVisibleRows(); appElement.appendChild(fileRoot); patchFileTreeCutDecoration(); }; const renderTree = () => { if (mode === "filetree") { renderFileTree(); return; } appElement.innerHTML = ""; if (mode === "picker" && allowRootPick) { const rootButton = document.createElement("button"); rootButton.type = "button"; rootButton.className = "tree-row"; rootButton.setAttribute("data-testid", "tree-picker-root"); rootButton.dataset.focused = String(resolvePickerRootFocused()); rootButton.tabIndex = resolvePickerRootFocused() ? 0 : -1; rootButton.addEventListener("click", () => { applyPickerFocusByItemKey("__root__", { focusDom: true }); postPickerPickResultToHost(applyPickerStateAction({ kind: "pick" })); }); const spacer = document.createElement("div"); spacer.className = "tree-spacer"; rootButton.appendChild(spacer); const label = document.createElement("div"); label.className = "tree-link"; const title = document.createElement("span"); title.className = "tree-link-title"; title.textContent = "根目录"; const meta = document.createElement("span"); meta.className = "tree-link-meta"; meta.textContent = "选择工作空间根目录"; label.appendChild(title); label.appendChild(meta); rootButton.appendChild(label); appElement.appendChild(rootButton); } if (getRoots().length === 0) { const empty = document.createElement("div"); empty.className = "tree-empty"; empty.textContent = mode === "picker" ? "当前 projection 没有可选择的页面。" : "当前 projection 没有可渲染的页面,点击上方按钮先创建一个根页面。"; appElement.appendChild(empty); return; } const list = document.createElement("ul"); list.className = "tree-root"; list.setAttribute("role", "tree"); getRoots().forEach((item) => { list.appendChild(renderNode(item)); }); appElement.appendChild(list); if (mode === "page" && getFocusedNodeId()) { focusRowElement(getFocusedNodeId()); } }; return { renderFileTree, renderNode, renderTree, syncFileTreeSelectionDom, openHydratedFileTreeItem, hydrateInitialFileTree, }; }