feat(kernel): complete tree-first graph tasks 074-080

This commit is contained in:
lix-2026
2026-04-16 22:01:51 +08:00
parent 2ff10fa86c
commit b1d5d97142
65 changed files with 11579 additions and 4606 deletions
@@ -16,18 +16,18 @@ import { CSS } from "@dnd-kit/utilities";
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual";
import { ChevronRight, GripVertical, MoreHorizontal, Plus } from "lucide-react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import type { DocumentNode } from "@/lib/documents";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import { cn } from "@/lib/utils";
import { flattenDocumentTree } from "@/lib/sidebar-tree";
interface PrivateTreeProps {
nodes: DocumentNode[];
nodes: SidebarTreeNode[];
expanded: Set<string>;
activeId: string;
onToggleExpand: (id: string) => void;
onMove: (nodeId: string, parentId: string | null, index: number) => void;
onCreateChild: (parentId: string | null) => void;
onContextMenu: (event: React.MouseEvent, node: DocumentNode) => void;
onContextMenu: (event: React.MouseEvent, node: SidebarTreeNode) => void;
}
const ROW_HEIGHT = 36;
@@ -172,7 +172,7 @@ function VirtualRow({
}
interface SortableTreeRowProps {
node: DocumentNode;
node: SidebarTreeNode;
depth: number;
expanded: boolean;
hasChildren: boolean;
@@ -33,11 +33,10 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
import type { DocumentNode } from "@/lib/documents";
import { buildDocumentTree } from "@/lib/documents";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import { useSidebarStore } from "@/store/sidebar";
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data";
import { useSidebarData } from "@/hooks/use-sidebar-data";
import { PrivateTree } from "@/components/sidebar/private-tree";
import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree";
import { useSearchPaletteStore } from "@/store/search-palette";
@@ -127,7 +126,7 @@ interface SidebarProps {
}
interface ContextMenuState {
node: DocumentNode;
node: SidebarTreeNode;
x: number;
y: number;
}
@@ -138,8 +137,8 @@ export function Sidebar({ initialData }: SidebarProps) {
// Convex 模式专用组件 - 只调用 Convex hooks
function SidebarConvex({ initialData }: SidebarProps) {
const convexData = useConvexSidebarData(initialData.activeWorkspaceId);
return <SidebarContent initialData={initialData} sidebarQuery={convexData} />;
const sidebarData = useSidebarData(initialData);
return <SidebarContent initialData={initialData} sidebarQuery={sidebarData} />;
}
// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
@@ -173,7 +172,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const activeId = segments?.[1] ?? "";
const editorBridge = useEditorBridgeStore((state) => state.bridge);
const [tree, setTree] = useState<DocumentNode[]>(() => buildDocumentTree(sidebarData.documents));
const [tree, setTree] = useState<SidebarTreeNode[]>(() => sidebarData.kernelSidebarTree ?? []);
const [filter, setFilter] = useState("");
const [expanded, setExpanded] = useState<Set<string>>(() => collectNodeIds(tree));
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
@@ -227,7 +226,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const [tableAssets, setTableAssets] = useState<MediaAsset[]>(sidebarData.tableAssets ?? []);
const [moveEmbedOpen, setMoveEmbedOpen] = useState(false);
const [moveEmbedMode, setMoveEmbedMode] = useState<MoveEmbedMode>("move");
const [moveEmbedSource, setMoveEmbedSource] = useState<DocumentNode | null>(null);
const [moveEmbedSource, setMoveEmbedSource] = useState<SidebarTreeNode | null>(null);
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
null,
);
@@ -244,11 +243,11 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
useEffect(() => {
setTree(() => {
const nextTree = buildDocumentTree(sidebarData.documents);
const nextTree = sidebarData.kernelSidebarTree ?? [];
setExpanded((expandedPrev) => collectNodeIds(nextTree, new Set(expandedPrev)));
return nextTree;
});
}, [sidebarData.documents]);
}, [sidebarData.kernelSidebarTree]);
useEffect(() => {
setMediaAssets(sidebarData.mediaAssets ?? []);
@@ -436,8 +435,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
const privateTree = useMemo(() => sections.find((section) => section.id === "private")?.nodes ?? [], [sections]);
const nodeById = useMemo(() => {
const map = new Map<string, DocumentNode>();
const walk = (nodes: DocumentNode[]) => {
const map = new Map<string, SidebarTreeNode>();
const walk = (nodes: SidebarTreeNode[]) => {
nodes.forEach((node) => {
map.set(node.id, node);
if (node.children.length > 0) {
@@ -450,10 +449,10 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
}, [tree]);
const publicGroupNodesByGroupId = useMemo(() => {
const map = new Map<string, DocumentNode[]>();
const map = new Map<string, SidebarTreeNode[]>();
for (const g of groupPublicSummary) {
const nodes: DocumentNode[] = [];
const uniq = new Map<string, DocumentNode>();
const nodes: SidebarTreeNode[] = [];
const uniq = new Map<string, SidebarTreeNode>();
for (const d of g.documents ?? []) {
const node = nodeById.get(d.documentId);
if (node) {
@@ -631,23 +630,23 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
[router, setOpen],
);
const handleCopyLink = useCallback(async (node: DocumentNode, includeTitle = false) => {
const handleCopyLink = useCallback(async (node: SidebarTreeNode, includeTitle = false) => {
const url = buildDocumentUrl(node.id);
const payload = includeTitle ? `${node.title ?? "无标题"}\n${url}` : url;
await copyText(payload, includeTitle ? "标题 + 链接已复制" : "页面链接已复制");
}, []);
const handleCopyReference = useCallback(async (node: DocumentNode, mode: "inline" | "embed") => {
const handleCopyReference = useCallback(async (node: SidebarTreeNode, mode: "inline" | "embed") => {
const template = mode === "inline" ? `((${node.id}))` : `{{${node.id}}}`;
await copyText(template, mode === "inline" ? "行内引用已复制" : "嵌入引用已复制");
}, []);
const handleCopyId = useCallback(async (node: DocumentNode) => {
const handleCopyId = useCallback(async (node: SidebarTreeNode) => {
await copyText(node.id, "页面 ID 已复制");
}, []);
const handleDuplicateDocument = useCallback(
async (node: DocumentNode) => {
async (node: SidebarTreeNode) => {
const response = await fetch("/api/documents/duplicate", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -662,13 +661,13 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
[refreshTree],
);
const openMoveEmbedPicker = useCallback((node: DocumentNode, nextMode: MoveEmbedMode) => {
const openMoveEmbedPicker = useCallback((node: SidebarTreeNode, nextMode: MoveEmbedMode) => {
setMoveEmbedSource(node);
setMoveEmbedMode(nextMode);
setMoveEmbedOpen(true);
}, []);
const handleEmbedPrompt = useCallback(async (node: DocumentNode) => {
const handleEmbedPrompt = useCallback(async (node: SidebarTreeNode) => {
openMoveEmbedPicker(node, "embed");
}, [openMoveEmbedPicker]);
@@ -1380,19 +1379,26 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
return;
}
const payload = (await response.json()) as DocumentNode;
const nextNode: DocumentNode = {
const payload = (await response.json()) as SidebarTreeNode;
const nextNode: SidebarTreeNode = {
...payload,
access_scope: payload.access_scope ?? "private",
is_template: payload.is_template ?? false,
updated_at: payload.updated_at ?? payload.created_at,
title: payload.title ?? "无标题",
children: [],
kernel: {
nodeType: "page",
depth: 0,
position: payload.sort_order ?? null,
childCount: 0,
expandedByDefault: true,
},
};
setTree((prev) => {
// 防止同一节点被插入多次(会导致重复 key / 列表出现“同一个页面两条记录”)
const exists = (nodes: DocumentNode[]): boolean => {
const exists = (nodes: SidebarTreeNode[]): boolean => {
for (const node of nodes) {
if (node.id === nextNode.id) return true;
if (node.children.length > 0 && exists(node.children)) return true;
@@ -1442,7 +1448,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
[refreshTree],
);
const moveLocalNode = useCallback((currentTree: DocumentNode[], nodeId: string, parentId: string | null, index: number) => {
const moveLocalNode = useCallback((currentTree: SidebarTreeNode[], nodeId: string, parentId: string | null, index: number) => {
const cloned = cloneNodes(currentTree);
const { removed, tree: withoutTarget } = removeNode(cloned, nodeId);
if (!removed) {
@@ -1730,7 +1736,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
);
const handleMovePrompt = useCallback(
async (node: DocumentNode) => {
async (node: SidebarTreeNode) => {
openMoveEmbedPicker(node, "move");
},
[openMoveEmbedPicker],
@@ -1753,7 +1759,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
);
const handleDeleteFromContextMenuNode = useCallback(
async (node: DocumentNode) => {
async (node: SidebarTreeNode) => {
if (viewMode === "filesystem") {
await handleDeleteFileTreeSelection();
return;
@@ -2140,7 +2146,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
}
}, [router, signOut, signingOut]);
const openContextMenu = useCallback((event: React.MouseEvent, node: DocumentNode) => {
const openContextMenu = useCallback((event: React.MouseEvent, node: SidebarTreeNode) => {
event.preventDefault();
event.stopPropagation();
setContextMenu({
@@ -2150,7 +2156,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
});
}, []);
const openShareDialog = useCallback((node: DocumentNode) => {
const openShareDialog = useCallback((node: SidebarTreeNode) => {
setShareTarget({
id: node.id,
title: node.title ?? null,
@@ -2264,7 +2270,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
</div>
<div className="max-h-52 overflow-auto rounded-md border border-[#eff2f6] bg-white px-2 py-2">
{(() => {
const renderList = (nodes: DocumentNode[]) => {
const renderList = (nodes: SidebarTreeNode[]) => {
const flat = flattenDocumentTree(nodes, collectNodeIds(nodes));
if (flat.length === 0) {
return <div className="px-2 py-2 text-xs text-gray-400"></div>;
@@ -2842,7 +2848,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
interface SectionListProps {
label: string;
icon: React.ReactNode;
nodes: DocumentNode[];
nodes: SidebarTreeNode[];
collapsed: boolean;
onToggle: () => void;
}
@@ -2885,14 +2891,14 @@ function SectionList({ label, icon, nodes, collapsed, onToggle }: SectionListPro
interface ContextMenuProps {
contextMenu: ContextMenuState;
onClose: () => void;
onOpenRight: (node: DocumentNode) => void;
onShare: (node: DocumentNode) => void;
onMove: (node: DocumentNode) => void;
onEmbed: (node: DocumentNode) => void;
onCopyLink: (node: DocumentNode, withTitle?: boolean) => void;
onCopyReference: (node: DocumentNode, mode: "inline" | "embed") => void;
onCopyId: (node: DocumentNode) => void;
onDuplicate: (node: DocumentNode) => void;
onOpenRight: (node: SidebarTreeNode) => void;
onShare: (node: SidebarTreeNode) => void;
onMove: (node: SidebarTreeNode) => void;
onEmbed: (node: SidebarTreeNode) => void;
onCopyLink: (node: SidebarTreeNode, withTitle?: boolean) => void;
onCopyReference: (node: SidebarTreeNode, mode: "inline" | "embed") => void;
onCopyId: (node: SidebarTreeNode) => void;
onDuplicate: (node: SidebarTreeNode) => void;
onRename: () => void;
onCreateChild: () => void;
onConvertChild: () => void;
@@ -3064,17 +3070,17 @@ function ContextMenu({
);
}
const cloneNodes = (nodes: DocumentNode[]): DocumentNode[] =>
const cloneNodes = (nodes: SidebarTreeNode[]): SidebarTreeNode[] =>
nodes.map((node) => ({
...node,
children: cloneNodes(node.children),
}));
const removeNode = (
nodes: DocumentNode[],
nodes: SidebarTreeNode[],
targetId: string,
): { removed: DocumentNode | null; tree: DocumentNode[] } => {
let removed: DocumentNode | null = null;
): { removed: SidebarTreeNode | null; tree: SidebarTreeNode[] } => {
let removed: SidebarTreeNode | null = null;
const nextTree = nodes
.map((node) => {
if (removed) return node;
@@ -3089,11 +3095,11 @@ const removeNode = (
}
return node;
})
.filter(Boolean) as DocumentNode[];
.filter(Boolean) as SidebarTreeNode[];
return { removed, tree: nextTree };
};
const insertNode = (nodes: DocumentNode[], parentId: string | null, index: number, newNode: DocumentNode): DocumentNode[] => {
const insertNode = (nodes: SidebarTreeNode[], parentId: string | null, index: number, newNode: SidebarTreeNode): SidebarTreeNode[] => {
if (!parentId) {
const next = [...nodes];
next.splice(Math.min(index, next.length), 0, newNode);
@@ -3110,11 +3116,11 @@ const insertNode = (nodes: DocumentNode[], parentId: string | null, index: numbe
});
};
const filterTree = (nodes: DocumentNode[], keyword: string): DocumentNode[] => {
const filterTree = (nodes: SidebarTreeNode[], keyword: string): SidebarTreeNode[] => {
if (!keyword) {
return nodes;
}
const filtered: DocumentNode[] = [];
const filtered: SidebarTreeNode[] = [];
nodes.forEach((node) => {
const childMatches = filterTree(node.children, keyword);
const title = (node.title ?? "").toLowerCase();
@@ -3125,7 +3131,7 @@ const filterTree = (nodes: DocumentNode[], keyword: string): DocumentNode[] => {
return filtered;
};
function collectNodeIds(nodes: DocumentNode[], bag: Set<string> = new Set()): Set<string> {
function collectNodeIds(nodes: SidebarTreeNode[], bag: Set<string> = new Set()): Set<string> {
nodes.forEach((node) => {
bag.add(node.id);
collectNodeIds(node.children, bag);
@@ -1,6 +1,7 @@
import type { DocumentRecord } from "@/lib/documents";
import type { DocumentRecord } from "@/lib/documents";
import type { WorkspaceSummary } from "@/lib/workspaces";
import type { MediaAsset } from "@/types/media";
import type { KernelSidebarProjection, SidebarTreeNode } from "@/lib/kernel-sidebar";
export type SidebarSectionId = "starred" | "public" | "shared" | "private" | "templates";
@@ -16,6 +17,8 @@ export interface SidebarInitialData {
activeWorkspaceId: string;
workspaces: WorkspaceSummary[];
documents: DocumentRecord[];
kernelSidebarProjection?: KernelSidebarProjection | null;
kernelSidebarTree?: SidebarTreeNode[];
trashedDocuments: TrashRecord[];
trashedMediaAssets?: MediaAsset[];
trashedMindmapAssets?: MediaAsset[];