Files
mnote/wolai-frontend/src/components/sidebar/tree-shell-surface.tsx
T
lix-2026 96e03645f7 chore: 收口 review 执行清单与 runtime 验证
- 补齐 design/10-review 执行清单、验收标准与相关设计治理记录

- 迁移已完成的 tree、mindmap、runtime fallback、AI kernel 等设计和缺陷条目

- 推进 Rust Web runtime、tree/sidebar、page aggregate、mindmap 与 OnlyOffice 路由侧验证支撑

- 增加 task177-task180 smoke/audit 脚本及前端相关测试覆盖
2026-05-14 05:52:08 +08:00

247 lines
8.9 KiB
TypeScript

"use client";
import type { DragEvent, MouseEvent } from "react";
import {
TreeShellHost,
type FileTreeShellDeleteSelectionPayload,
type FileTreeShellExternalDropPayload,
type FileTreeShellInternalDropPayload,
type TreeShellMutationPayload,
type TreeShellPickerCommand,
type TreeRendererFamily,
} from "@/components/sidebar/tree-shell-host";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { FileTreeRow } from "@/lib/file-tree/types";
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
import { cn } from "@/lib/utils";
export type { TreeRendererFamily } from "@/components/sidebar/tree-shell-host";
type SidebarPageTreeSurfaceProps = {
mode: "page";
rendererFamily?: TreeRendererFamily;
workspaceId: string | null;
treeShellEnabled?: boolean;
rows?: PageTreeProjectionItem[];
treeShellRows?: PageTreeProjectionItem[];
expanded: Set<string>;
activeId: string;
focusedDocumentId?: string | null;
className?: string;
onToggleExpand: (id: string) => void;
onMove: (nodeId: string, parentId: string | null, index: number) => void;
onCreateChild: (parentId: string | null) => void;
onContextMenu: (event: MouseEvent, node: SidebarTreeNode) => void;
onNavigate?: (documentId: string) => void;
onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void;
onPageExpandChange?: (payload: { documentId: string | null; expanded: boolean }) => void;
onPageFocusChange?: (payload: { documentId: string | null }) => void;
onTreeMutation?: (payload: TreeShellMutationPayload) => void;
};
type SidebarFileTreeSurfaceProps = {
mode: "filetree";
rendererFamily?: TreeRendererFamily;
workspaceId: string | null;
treeShellEnabled?: boolean;
rows?: FileTreeRow[];
treeShellItems?: KernelFileTreeProjectionItem[];
activeId: string;
className?: string;
onRowClick: (row: FileTreeRow, event: MouseEvent) => void;
onRowDoubleClick: (row: FileTreeRow, event: MouseEvent) => void;
onRowContextMenu: (row: FileTreeRow, event: MouseEvent) => void;
onRowDragStart?: (row: FileTreeRow, event: DragEvent) => void;
onToggleExpand: (docId: string) => void;
onToggleAssetFolderExpand?: (assetId: string) => void;
onCreateChild: (parentId: string | null) => void;
onBlankMouseDown?: (event: MouseEvent) => void;
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
onNavigate?: (documentId: string) => void;
onFileTreeContextMenu?: (payload: {
documentId: string | null;
assetId: string | null;
rowId: string | null;
rowKind: string | null;
x: number;
y: number;
}) => void;
onFileTreeSelectionChange?: (payload: {
selectedRowIds: string[];
anchorRowId: string | null;
focusedRowId: string | null;
}) => void;
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
onTreeMutation?: (payload: TreeShellMutationPayload) => void;
};
export type SidebarTreeSurfaceProps =
| SidebarPageTreeSurfaceProps
| SidebarFileTreeSurfaceProps;
export type TreePickerSurfaceItem =
| { kind: "root"; id: null; title: string; subtitle?: string }
| { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number };
type TreePickerSurfaceProps = {
rendererFamily?: TreeRendererFamily;
workspaceId: string | null;
treeShellEnabled?: boolean;
activeDocumentId?: string | null;
activePickerItemKey?: string | null;
allowRootPick?: boolean;
excludeIds?: string[];
pickerCommand?: TreeShellPickerCommand | null;
treeShellItems?: TreePickerSurfaceItem[];
items: TreePickerSurfaceItem[];
highlighted: number;
className?: string;
emptyText?: string;
onHighlight: (index: number) => void;
onPick: (targetId: string | null) => void;
onPickerFocusChange?: (payload: { itemKey: string | null; documentId: string | null }) => void;
};
export function SidebarTreeSurface(props: SidebarTreeSurfaceProps) {
const surfaceTestId =
props.mode === "page"
? "sidebar-page-tree-shell"
: "sidebar-file-tree-shell";
const rendererFamily = props.rendererFamily ?? "react";
const pageTreeFallback = (
<div
data-testid="page-tree-renderer-removed"
className="flex h-full items-center justify-center px-4 text-center text-sm text-gray-400"
>
页面树旧 React renderer 已退出;请使用 Rust tree shell 宿主。
</div>
);
const fileTreeFallback = (
<div
data-testid="file-tree-renderer-removed"
className="flex h-full items-center justify-center px-4 text-center text-sm text-gray-400"
>
文件树旧 React renderer 已退出;请使用 Rust tree shell 宿主。
</div>
);
const fallbackContent =
props.mode === "page" ? (
pageTreeFallback
) : (
fileTreeFallback
);
return (
<TreeShellHost
mode={props.mode}
surfaceTestId={surfaceTestId}
rendererFamily={rendererFamily}
treeShellEnabled={props.treeShellEnabled}
fallbackImplementation={
props.mode === "page"
? "page_tree_renderer_removed"
: "filetree_renderer_removed"
}
workspaceId={props.workspaceId}
activeDocumentId={props.activeId}
focusedDocumentId={props.mode === "page" ? (props.focusedDocumentId ?? null) : undefined}
pageTreeItems={props.mode === "page" ? props.treeShellRows : undefined}
inlineFileTreeItems={props.mode === "filetree" ? props.treeShellItems : undefined}
onNavigate={props.onNavigate}
onPageContextMenu={props.mode === "page" ? props.onPageContextMenu : undefined}
onPageExpandChange={props.mode === "page" ? props.onPageExpandChange : undefined}
onPageFocusChange={props.mode === "page" ? props.onPageFocusChange : undefined}
onFileTreeContextMenu={props.mode === "filetree" ? props.onFileTreeContextMenu : undefined}
onFileTreeSelectionChange={props.mode === "filetree" ? props.onFileTreeSelectionChange : undefined}
onFileTreeDeleteSelection={props.mode === "filetree" ? props.onFileTreeDeleteSelection : undefined}
onInternalDrop={props.mode === "filetree" ? props.onInternalDrop : undefined}
onDropFiles={props.mode === "filetree" ? props.onDropFiles : undefined}
onAssetOpen={props.mode === "filetree" ? props.onAssetOpen : undefined}
onTreeMutation={props.onTreeMutation}
className={cn(
"h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white",
props.className,
)}
>
{fallbackContent}
</TreeShellHost>
);
}
export function TreePickerSurface({
rendererFamily = "react",
workspaceId,
treeShellEnabled = true,
activeDocumentId = null,
activePickerItemKey = null,
allowRootPick = false,
excludeIds = [],
pickerCommand = null,
treeShellItems,
items,
highlighted,
className,
emptyText = "没有匹配结果",
onHighlight,
onPick,
onPickerFocusChange,
}: TreePickerSurfaceProps) {
const hasItems = items.length > 0;
const effectiveTreeShellItems =
rendererFamily === "rust_family" ? (treeShellItems ?? items) : treeShellItems;
return (
<TreeShellHost
mode="picker"
surfaceTestId="tree-picker-surface"
rendererFamily={rendererFamily}
treeShellEnabled={treeShellEnabled}
workspaceId={workspaceId}
activeDocumentId={activeDocumentId}
activePickerItemKey={activePickerItemKey}
allowRootPick={allowRootPick}
excludeIds={excludeIds}
pickerCommand={pickerCommand}
pickerItems={effectiveTreeShellItems}
onPick={onPick}
onPickerFocusChange={onPickerFocusChange}
className={cn(hasItems ? "py-2" : null, className)}
>
{!hasItems ? (
<div className="p-4 text-sm text-gray-400">{emptyText}</div>
) : (
items.map((item, index) => {
const isRoot = item.kind === "root";
return (
<button
key={isRoot ? "root" : item.id}
data-testid={isRoot ? "tree-picker-root" : "tree-picker-row"}
data-node-id={isRoot ? "" : item.id}
type="button"
className={cn(
"w-full px-4 py-3 text-left transition-colors hover:bg-[#eef2ff]",
index === highlighted && "bg-[#e3ecff]",
)}
onMouseEnter={() => onHighlight(index)}
onClick={() => onPick(item.id)}
>
<div
className="text-sm font-medium text-gray-900"
style={!isRoot ? { paddingLeft: 12 * (item.depth ?? 0) } : undefined}
>
{item.title}
</div>
{item.subtitle ? (
<div className="mt-1 text-xs text-gray-500">{item.subtitle}</div>
) : null}
</button>
);
})
)}
</TreeShellHost>
);
}