feat: complete tree shell cutover and regression coverage
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
|
||||
|
||||
const COOKIE_NAME = "mnote_web_convex_token";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const token = (await convexAuthNextjsToken())?.trim() ?? "";
|
||||
|
||||
if (!token) {
|
||||
const response = NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
response.cookies.set({
|
||||
name: COOKIE_NAME,
|
||||
value: "",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
response.headers.set("cache-control", "no-store");
|
||||
return response;
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ ok: true });
|
||||
response.cookies.set({
|
||||
name: COOKIE_NAME,
|
||||
value: token,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
});
|
||||
response.headers.set("cache-control", "no-store");
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { MoveEmbedPickerDialog } from "./move-embed-picker-dialog";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const sidebarData = {
|
||||
activeWorkspaceId: "ws_test",
|
||||
workspaces: [],
|
||||
documents: [],
|
||||
kernelSidebarProjection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
projection: "sidebar_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
kernelSidebarTree: [],
|
||||
trashedDocuments: [],
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
const key = Array.isArray(queryKey) ? queryKey[0] : queryKey;
|
||||
if (key === "move-embed-picker-sidebar") {
|
||||
return {
|
||||
data: sidebarData,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-document-search", () => ({
|
||||
useDocumentSearch: () => ({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ? <div>{children}</div> : null),
|
||||
DialogContent: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/input", () => ({
|
||||
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tabs", () => ({
|
||||
Tabs: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TabsList: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
TabsTrigger: ({
|
||||
children,
|
||||
className,
|
||||
value,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
value: string;
|
||||
}) => (
|
||||
<button type="button" className={className} data-value={value}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
TabsContent: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("MoveEmbedPickerDialog", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("空查询时会使用统一 picker surface 并透传根目录选择", async () => {
|
||||
const onPick = vi.fn(async () => undefined);
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MoveEmbedPickerDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
workspaceId="ws_test"
|
||||
defaultMode="move"
|
||||
allowRoot
|
||||
excludeIds={["doc_hidden"]}
|
||||
onPick={onPick}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const shellPickButton = container.querySelector('[data-testid="tree-picker-root"]');
|
||||
expect(shellPickButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
shellPickButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onPick).toHaveBeenCalledTimes(1);
|
||||
expect(onPick).toHaveBeenCalledWith("move", null);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,12 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Search } from "lucide-react";
|
||||
import { MnoteWebTreeShell } from "@/components/sidebar/MnoteWebTreeShell";
|
||||
import { TreePickerSurface } from "@/components/sidebar/tree-shell-surface";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { buildPageTreeProjectionItems, buildPickerTreeItems } from "@/lib/tree-projection";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDocumentSearch } from "@/hooks/use-document-search";
|
||||
import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search";
|
||||
@@ -29,10 +28,6 @@ type PickerItem =
|
||||
| { kind: "root"; id: null; title: string; subtitle?: string }
|
||||
| { kind: "doc"; id: string; title: string; subtitle?: string; depth?: number; raw?: DocumentSearchResult };
|
||||
|
||||
type ViewerIdentity = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
async function fetchSidebarData(workspaceId: string): Promise<SidebarInitialData> {
|
||||
const response = await fetch(`/api/sidebar?workspaceId=${encodeURIComponent(workspaceId)}`, {
|
||||
method: "GET",
|
||||
@@ -48,18 +43,6 @@ async function fetchSidebarData(workspaceId: string): Promise<SidebarInitialData
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchViewerIdentity(): Promise<ViewerIdentity> {
|
||||
const response = await fetch("/api/auth/whoami", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || typeof payload?.userId !== "string" || !payload.userId.trim()) {
|
||||
throw new Error(payload?.error ?? "获取当前用户失败");
|
||||
}
|
||||
return { userId: payload.userId.trim() };
|
||||
}
|
||||
|
||||
interface MoveEmbedPickerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -147,10 +130,6 @@ function MoveEmbedPickerDialogBody({
|
||||
|
||||
const trimmed = query.trim();
|
||||
const isEmptyQuery = trimmed.length === 0;
|
||||
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
|
||||
const treeShellEnabled = Boolean(
|
||||
(runtimeConfig.mnoteWebBaseUrl ?? "").trim() && runtimeConfig.mnoteWebTreeShellEnabled === true,
|
||||
);
|
||||
|
||||
const sidebarQuery = useQuery({
|
||||
queryKey: ["move-embed-picker-sidebar", workspaceId],
|
||||
@@ -165,14 +144,6 @@ function MoveEmbedPickerDialogBody({
|
||||
gcTime: 60_000,
|
||||
});
|
||||
|
||||
const viewerQuery = useQuery({
|
||||
queryKey: ["move-embed-picker-viewer"],
|
||||
queryFn: fetchViewerIdentity,
|
||||
enabled: Boolean(workspaceId) && isEmptyQuery && treeShellEnabled,
|
||||
staleTime: 60_000,
|
||||
gcTime: 120_000,
|
||||
});
|
||||
|
||||
const { data, isLoading, error } = useDocumentSearch(payload, Boolean(payload) && !isEmptyQuery);
|
||||
|
||||
const items = useMemo<PickerItem[]>(() => {
|
||||
@@ -220,7 +191,13 @@ function MoveEmbedPickerDialogBody({
|
||||
}, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.kernelSidebarTree]);
|
||||
|
||||
const placeholder = mode === "move" ? "移动到..." : "嵌入到...";
|
||||
const allowRootPick = allowRoot && mode === "move";
|
||||
const handlePick = useCallback(
|
||||
async (targetId: string | null) => {
|
||||
await onPick(mode, targetId);
|
||||
onOpenChange(false);
|
||||
},
|
||||
[mode, onOpenChange, onPick],
|
||||
);
|
||||
const pickerFallback = (
|
||||
sidebarQuery.isLoading ? (
|
||||
<div className="p-4 text-sm text-gray-400">加载中...</div>
|
||||
@@ -229,39 +206,17 @@ function MoveEmbedPickerDialogBody({
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400">没有匹配结果</div>
|
||||
) : (
|
||||
<div className="py-2">
|
||||
{items.map((item, idx) => (
|
||||
<button
|
||||
key={item.kind === "root" ? "root" : item.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full px-4 py-3 text-left transition-colors hover:bg-[#eef2ff]",
|
||||
idx === highlighted && "bg-[#e3ecff]",
|
||||
)}
|
||||
onMouseEnter={() => setHighlighted(idx)}
|
||||
onClick={() => void handlePick(item.id)}
|
||||
>
|
||||
<div
|
||||
className="text-sm font-medium text-gray-900"
|
||||
style={item.kind === "doc" ? { paddingLeft: 12 * (item.depth ?? 0) } : undefined}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
{item.subtitle && <div className="mt-1 text-xs text-gray-500">{item.subtitle}</div>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<TreePickerSurface
|
||||
items={items}
|
||||
highlighted={highlighted}
|
||||
onHighlight={setHighlighted}
|
||||
onPick={(targetId) => {
|
||||
void handlePick(targetId);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
const handlePick = useCallback(
|
||||
async (targetId: string | null) => {
|
||||
await onPick(mode, targetId);
|
||||
onOpenChange(false);
|
||||
},
|
||||
[mode, onOpenChange, onPick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-[520px] flex-col">
|
||||
<div className="border-b border-[#eef2ff] p-4">
|
||||
@@ -315,29 +270,7 @@ function MoveEmbedPickerDialogBody({
|
||||
{!workspaceId ? (
|
||||
<div className="p-4 text-sm text-gray-500">缺少 workspaceId,无法加载页面列表。</div>
|
||||
) : isEmptyQuery ? (
|
||||
<div className="h-full p-3">
|
||||
{!treeShellEnabled ? (
|
||||
pickerFallback
|
||||
) : viewerQuery.isLoading ? (
|
||||
<div className="p-4 text-sm text-gray-400">正在准备选择器...</div>
|
||||
) : viewerQuery.error ? (
|
||||
<div className="p-4 text-sm text-red-600">{String(viewerQuery.error)}</div>
|
||||
) : (
|
||||
<MnoteWebTreeShell
|
||||
workspaceId={workspaceId}
|
||||
actorId={viewerQuery.data?.userId ?? null}
|
||||
mode="picker"
|
||||
allowRootPick={allowRootPick}
|
||||
excludeIds={excludeIds}
|
||||
onNavigate={() => {}}
|
||||
onPick={(targetId) => {
|
||||
void handlePick(targetId);
|
||||
}}
|
||||
onRefresh={async () => undefined}
|
||||
fallback={pickerFallback}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-full p-3">{pickerFallback}</div>
|
||||
) : isLoading ? (
|
||||
<div className="p-4 text-sm text-gray-400">加载中...</div>
|
||||
) : error ? (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { ensureMnoteWebAuthCookie } from "@/lib/mnote-web-auth";
|
||||
|
||||
const TREE_SHELL_CHANNEL = "mnote-tree-shell-v1";
|
||||
const TREE_SHELL_PATH = "/tree";
|
||||
@@ -170,6 +171,7 @@ export function MnoteWebTreeShell({
|
||||
const readyTimerRef = useRef<number | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [failedShellUrl, setFailedShellUrl] = useState<string | null>(null);
|
||||
const [authCookieReady, setAuthCookieReady] = useState(false);
|
||||
|
||||
const shellUrl = useMemo(() => {
|
||||
if (!baseUrl || !treeShellEnabled) return null;
|
||||
@@ -198,7 +200,32 @@ export function MnoteWebTreeShell({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shellUrl) return;
|
||||
if (!shellUrl) {
|
||||
setAuthCookieReady(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setAuthCookieReady(false);
|
||||
setFailedShellUrl(null);
|
||||
void (async () => {
|
||||
try {
|
||||
await ensureMnoteWebAuthCookie();
|
||||
if (!cancelled) {
|
||||
setAuthCookieReady(true);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setFailedShellUrl(shellUrl);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [shellUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shellUrl || !authCookieReady) return;
|
||||
if (readyTimerRef.current) {
|
||||
window.clearTimeout(readyTimerRef.current);
|
||||
}
|
||||
@@ -213,7 +240,7 @@ export function MnoteWebTreeShell({
|
||||
readyTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [shellUrl]);
|
||||
}, [authCookieReady, shellUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!baseUrl || !treeShellEnabled) return;
|
||||
@@ -332,7 +359,7 @@ export function MnoteWebTreeShell({
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [baseUrl, onNavigate, onOpenAsset, onOpenContextMenu, onOpenFileTreeContextMenu, onPick, onRefresh, treeShellEnabled]);
|
||||
|
||||
if (!shellUrl || failedShellUrl === shellUrl) {
|
||||
if (!shellUrl || !authCookieReady || failedShellUrl === shellUrl) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,18 @@ export function FileTree({
|
||||
return (
|
||||
<div
|
||||
key={row.rowId}
|
||||
data-testid={
|
||||
row.kind === "doc"
|
||||
? "filetree-doc-row"
|
||||
: row.kind === "index"
|
||||
? "filetree-index-row"
|
||||
: row.kind === "asset-folder"
|
||||
? "filetree-asset-folder-row"
|
||||
: "filetree-asset-row"
|
||||
}
|
||||
data-row-id={row.rowId}
|
||||
data-row-kind={row.kind}
|
||||
data-doc-id={row.docId}
|
||||
className={cn(
|
||||
baseClass,
|
||||
active && activeClass,
|
||||
@@ -227,6 +239,7 @@ export function FileTree({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="展开或折叠"
|
||||
data-testid="filetree-toggle"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleExpand(row.docId);
|
||||
@@ -248,6 +261,7 @@ export function FileTree({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="新建子页面"
|
||||
data-testid="filetree-create"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onCreateChild(row.docId);
|
||||
@@ -269,6 +283,7 @@ export function FileTree({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="展开或折叠"
|
||||
data-testid="filetree-toggle"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleAssetFolderExpand?.(row.asset.id);
|
||||
|
||||
@@ -200,6 +200,8 @@ function SortableTreeRow({
|
||||
activeId === node.id && "bg-wolai-bg-active text-[#2563eb]",
|
||||
isDragging && "opacity-60",
|
||||
)}
|
||||
data-testid="page-tree-row"
|
||||
data-node-id={node.id}
|
||||
data-active={activeId === node.id}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
@@ -220,6 +222,7 @@ function SortableTreeRow({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="展开或折叠"
|
||||
data-testid="page-tree-toggle"
|
||||
onClick={onToggleExpand}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100 shrink-0"
|
||||
>
|
||||
@@ -228,13 +231,18 @@ function SortableTreeRow({
|
||||
) : (
|
||||
<span className="w-5 h-5 shrink-0" />
|
||||
)}
|
||||
<Link href={`/documents/${node.id}`} className="flex-1 truncate text-left">
|
||||
<Link
|
||||
href={`/documents/${node.id}`}
|
||||
data-testid="page-tree-open"
|
||||
className="flex-1 truncate text-left"
|
||||
>
|
||||
{node.title || "无标题"}
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="新建子页面"
|
||||
data-testid="page-tree-create"
|
||||
onClick={onCreateChild}
|
||||
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 shrink-0"
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
||||
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { useConvex } from "convex/react";
|
||||
import { useConvexAuth, useQuery } from "convex/react";
|
||||
import { useConvexAuth } from "convex/react";
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
ArrowUpRight,
|
||||
@@ -38,7 +38,7 @@ import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import { useSidebarStore } from "@/store/sidebar";
|
||||
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
|
||||
import { useSidebarData, type SidebarDataResult } from "@/hooks/use-sidebar-data";
|
||||
import { PrivateTree } from "@/components/sidebar/private-tree";
|
||||
import { SidebarTreeSurface } from "@/components/sidebar/tree-shell-surface";
|
||||
import { buildSidebarSectionsFromTree } from "@/lib/sidebar-tree";
|
||||
import {
|
||||
buildPageTreeProjectionItems,
|
||||
@@ -47,26 +47,24 @@ import {
|
||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import { useCurrentDocumentStore } from "@/store/current-document";
|
||||
import { FileTree } from "@/components/sidebar/file-tree";
|
||||
import { MnoteWebTreeShell } from "@/components/sidebar/MnoteWebTreeShell";
|
||||
import { buildVisibleRows } from "@/lib/file-tree/rows";
|
||||
import type { FileTreeSelectionState } from "@/lib/file-tree/selection";
|
||||
import { normalizeFileTreeSelectionForVisibleRows, reduceFileTreeSelection } from "@/lib/file-tree/selection";
|
||||
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||
import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd";
|
||||
import { isRealFileAsset } from "@/lib/file-tree/asset";
|
||||
import { computeFileTreeDeleteTargets } from "@/lib/file-tree/delete";
|
||||
import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog";
|
||||
import {
|
||||
computeTreePaneDeleteTargets,
|
||||
inferPasteTargetDocId,
|
||||
isTextInputTarget,
|
||||
readFileTreeClipboardPayload,
|
||||
writeFileTreeClipboardPayload,
|
||||
} from "@/lib/file-tree/clipboard";
|
||||
normalizeTreePaneSelectionForVisibleRows,
|
||||
readTreePaneClipboardPayload,
|
||||
reduceTreePaneSelection,
|
||||
type TreePaneRow,
|
||||
type TreePaneSelectionState,
|
||||
writeTreePaneClipboardPayload,
|
||||
} from "@/components/sidebar/tree-pane-bindings";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
|
||||
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { useSidebarTreeStream } from "@/lib/tree-stream/use-sidebar-tree-stream";
|
||||
import { DocumentShareDialog } from "@/components/sharing/document-share-dialog";
|
||||
import { api } from "@/lib/convex/api";
|
||||
@@ -168,17 +166,12 @@ interface SidebarContentProps {
|
||||
|
||||
function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarContentProps) {
|
||||
const convex = useConvex();
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
const currentUser = useQuery(api.users.currentUser, isAuthenticated ? {} : "skip");
|
||||
useConvexAuth();
|
||||
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } =
|
||||
useSidebarStore();
|
||||
const viewMode = useSidebarStore((state) => state.viewMode);
|
||||
const setViewMode = useSidebarStore((state) => state.setViewMode);
|
||||
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
|
||||
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
|
||||
const treeShellAvailable = Boolean(
|
||||
(runtimeConfig.mnoteWebBaseUrl ?? "").trim() && runtimeConfig.mnoteWebTreeShellEnabled === true,
|
||||
);
|
||||
|
||||
// 处理数据
|
||||
const sidebarData = useMemo(() => {
|
||||
@@ -251,14 +244,13 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [treeShellReloadToken, setTreeShellReloadToken] = useState(0);
|
||||
const [fileTreeSelection, setFileTreeSelection] = useState<FileTreeSelectionState>(() => ({
|
||||
const [resourceSelection, setResourceSelection] = useState<TreePaneSelectionState>(() => ({
|
||||
selectedRowIds: new Set(),
|
||||
anchorRowId: null,
|
||||
focusedRowId: null,
|
||||
}));
|
||||
|
||||
const fileTreeContainerRef = useRef<HTMLDivElement>(null);
|
||||
const resourcePaneContainerRef = useRef<HTMLDivElement>(null);
|
||||
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
|
||||
// 删除在线表格后,Convex 订阅刷新存在极短延迟;这里做短暂“乐观隐藏”,避免文件树闪回。
|
||||
const hiddenTableIdsRef = useRef<Map<string, number>>(new Map());
|
||||
@@ -322,7 +314,6 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
|
||||
const refreshTree = useCallback(async () => {
|
||||
await sidebarQuery.refetch();
|
||||
setTreeShellReloadToken((prev) => prev + 1);
|
||||
}, [sidebarQuery]);
|
||||
|
||||
const refreshShareSummary = useCallback(async () => {
|
||||
@@ -487,7 +478,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
}
|
||||
return map;
|
||||
}, [groupPublicSummary, nodeById]);
|
||||
const filteredPrivateTree = useMemo(
|
||||
const filteredPageNodes = useMemo(
|
||||
() => (filter ? filterTree(privateTree, filter.toLowerCase()) : privateTree),
|
||||
[filter, privateTree],
|
||||
);
|
||||
@@ -496,8 +487,8 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
[privateTree],
|
||||
);
|
||||
const filteredPrivatePageRows = useMemo(
|
||||
() => buildPageTreeProjectionItems(filteredPrivateTree),
|
||||
[filteredPrivateTree],
|
||||
() => buildPageTreeProjectionItems(filteredPageNodes),
|
||||
[filteredPageNodes],
|
||||
);
|
||||
const flattenedPrivate = useMemo(
|
||||
() => filterVisiblePageTreeProjectionItems(privatePageRows, expanded),
|
||||
@@ -614,7 +605,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
|
||||
const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const fileTreeRows = useMemo(
|
||||
const resourceRows = useMemo(
|
||||
() =>
|
||||
buildVisibleRows({
|
||||
pageRows: visibleFilteredPrivatePageRows,
|
||||
@@ -632,12 +623,12 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
],
|
||||
);
|
||||
|
||||
const fileTreeVisibleRowIds = useMemo(() => fileTreeRows.map((row) => row.rowId), [fileTreeRows]);
|
||||
const fileTreeRowById = useMemo(() => new Map(fileTreeRows.map((row) => [row.rowId, row])), [fileTreeRows]);
|
||||
const resourceVisibleRowIds = useMemo(() => resourceRows.map((row) => row.rowId), [resourceRows]);
|
||||
const resourceRowById = useMemo(() => new Map(resourceRows.map((row) => [row.rowId, row])), [resourceRows]);
|
||||
|
||||
useEffect(() => {
|
||||
setFileTreeSelection((prev) => normalizeFileTreeSelectionForVisibleRows(prev, fileTreeVisibleRowIds));
|
||||
}, [fileTreeVisibleRowIds]);
|
||||
setResourceSelection((prev) => normalizeTreePaneSelectionForVisibleRows(prev, resourceVisibleRowIds));
|
||||
}, [resourceVisibleRowIds]);
|
||||
|
||||
const docParentById = useMemo(
|
||||
() =>
|
||||
@@ -679,14 +670,6 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
[router, setOpen],
|
||||
);
|
||||
|
||||
const handleNavigateFromTreeShell = useCallback(
|
||||
(documentId: string) => {
|
||||
if (!documentId) return;
|
||||
handleOpenDocument(documentId, "main");
|
||||
},
|
||||
[handleOpenDocument],
|
||||
);
|
||||
|
||||
const handleCopyLink = useCallback(async (node: SidebarTreeNode, includeTitle = false) => {
|
||||
const url = buildDocumentUrl(node.id);
|
||||
const payload = includeTitle ? `${node.title ?? "无标题"}\n${url}` : url;
|
||||
@@ -840,17 +823,17 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
})();
|
||||
}, [activeId, editorBridge, router, setOpen]);
|
||||
|
||||
const handleFileTreeBlankMouseDown = useCallback(() => {
|
||||
setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" }));
|
||||
const handleResourcePaneBlankMouseDown = useCallback(() => {
|
||||
setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
|
||||
}, []);
|
||||
|
||||
const handleFileTreeRowClick = useCallback(
|
||||
(row: FileTreeRow, event: React.MouseEvent) => {
|
||||
setFileTreeSelection((prev) =>
|
||||
reduceFileTreeSelection(prev, {
|
||||
const handleResourceRowClick = useCallback(
|
||||
(row: TreePaneRow, event: React.MouseEvent) => {
|
||||
setResourceSelection((prev) =>
|
||||
reduceTreePaneSelection(prev, {
|
||||
type: "click",
|
||||
rowId: row.rowId,
|
||||
visibleRowIds: fileTreeVisibleRowIds,
|
||||
visibleRowIds: resourceVisibleRowIds,
|
||||
modifiers: {
|
||||
shiftKey: event.shiftKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
@@ -871,23 +854,23 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
// doc/index/asset 都统一跳转到所属页面(index.md)
|
||||
handleOpenDocument(targetDocId, "main");
|
||||
},
|
||||
[activeId, fileTreeVisibleRowIds, handleOpenDocument],
|
||||
[activeId, resourceVisibleRowIds, handleOpenDocument],
|
||||
);
|
||||
|
||||
const handleFileTreeRowDragStart = useCallback((row: FileTreeRow) => {
|
||||
setFileTreeSelection((prev) => {
|
||||
const handleResourceRowDragStart = useCallback((row: TreePaneRow) => {
|
||||
setResourceSelection((prev) => {
|
||||
if (prev.selectedRowIds.has(row.rowId)) return prev;
|
||||
return reduceFileTreeSelection(prev, {
|
||||
return reduceTreePaneSelection(prev, {
|
||||
type: "click",
|
||||
rowId: row.rowId,
|
||||
visibleRowIds: fileTreeVisibleRowIds,
|
||||
visibleRowIds: resourceVisibleRowIds,
|
||||
modifiers: { shiftKey: false, ctrlKey: false, metaKey: false },
|
||||
});
|
||||
});
|
||||
}, [fileTreeVisibleRowIds]);
|
||||
}, [resourceVisibleRowIds]);
|
||||
|
||||
const handleFileTreeRowDoubleClick = useCallback(
|
||||
(row: FileTreeRow, _event?: React.MouseEvent) => {
|
||||
const handleResourceRowDoubleClick = useCallback(
|
||||
(row: TreePaneRow, _event?: React.MouseEvent) => {
|
||||
if (row.kind === "asset" || row.kind === "asset-folder") {
|
||||
handleOpenAsset(row.asset);
|
||||
return;
|
||||
@@ -897,12 +880,12 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
[handleOpenAsset, handleOpenDocument],
|
||||
);
|
||||
|
||||
const handleFileTreeRowContextMenu = useCallback(
|
||||
(row: FileTreeRow, event: React.MouseEvent) => {
|
||||
const handleResourceRowContextMenu = useCallback(
|
||||
(row: TreePaneRow, event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setFileTreeSelection((prev) =>
|
||||
reduceFileTreeSelection(prev, { type: "contextmenu", rowId: row.rowId }),
|
||||
setResourceSelection((prev) =>
|
||||
reduceTreePaneSelection(prev, { type: "contextmenu", rowId: row.rowId }),
|
||||
);
|
||||
|
||||
if (row.kind === "asset" || row.kind === "asset-folder") {
|
||||
@@ -938,21 +921,21 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
return;
|
||||
}
|
||||
|
||||
const container = fileTreeContainerRef.current;
|
||||
const container = resourcePaneContainerRef.current;
|
||||
const activeElement = document.activeElement;
|
||||
if (!container || !activeElement || !container.contains(activeElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCopy) {
|
||||
if (fileTreeSelection.selectedRowIds.size === 0) {
|
||||
if (resourceSelection.selectedRowIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const orderedRowIds = fileTreeRows
|
||||
.filter((row) => fileTreeSelection.selectedRowIds.has(row.rowId))
|
||||
const orderedRowIds = resourceRows
|
||||
.filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
|
||||
.map((row) => row.rowId);
|
||||
await writeFileTreeClipboardPayload({
|
||||
await writeTreePaneClipboardPayload({
|
||||
type: "mnote-file-tree",
|
||||
version: 1,
|
||||
action: "copy",
|
||||
@@ -963,14 +946,14 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
|
||||
if (isPaste) {
|
||||
event.preventDefault();
|
||||
const payload = await readFileTreeClipboardPayload();
|
||||
const payload = await readTreePaneClipboardPayload();
|
||||
if (!payload || payload.rowIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetDocId = inferPasteTargetDocId({
|
||||
focusedRowId: fileTreeSelection.focusedRowId,
|
||||
rowById: fileTreeRowById,
|
||||
focusedRowId: resourceSelection.focusedRowId,
|
||||
rowById: resourceRowById,
|
||||
activeDocId: activeId || null,
|
||||
});
|
||||
if (!targetDocId) {
|
||||
@@ -979,8 +962,8 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
}
|
||||
|
||||
const rows = payload.rowIds
|
||||
.map((rowId) => fileTreeRowById.get(rowId as any))
|
||||
.filter(Boolean) as FileTreeRow[];
|
||||
.map((rowId) => resourceRowById.get(rowId as any))
|
||||
.filter(Boolean) as TreePaneRow[];
|
||||
|
||||
const docItemsMap = new Map<string, boolean>();
|
||||
rows.forEach((row) => {
|
||||
@@ -1011,7 +994,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
emitDocumentsChanged(targetDocId);
|
||||
}
|
||||
|
||||
const assetRows = rows.filter((row) => row.kind === "asset") as Extract<FileTreeRow, { kind: "asset" }>[];
|
||||
const assetRows = rows.filter((row) => row.kind === "asset") as Extract<TreePaneRow, { kind: "asset" }>[];
|
||||
const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id);
|
||||
|
||||
if (copyableAssetIds.length > 0) {
|
||||
@@ -1041,10 +1024,10 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [
|
||||
activeId,
|
||||
fileTreeRowById,
|
||||
fileTreeRows,
|
||||
fileTreeSelection.focusedRowId,
|
||||
fileTreeSelection.selectedRowIds,
|
||||
resourceRowById,
|
||||
resourceRows,
|
||||
resourceSelection.focusedRowId,
|
||||
resourceSelection.selectedRowIds,
|
||||
sidebarQuery,
|
||||
]);
|
||||
|
||||
@@ -1301,10 +1284,10 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
[mediaAssets, mindmapAssets, refreshTree, tableAssets],
|
||||
);
|
||||
|
||||
const handleDeleteFileTreeSelection = useCallback(async () => {
|
||||
const { docIds, assetIds } = computeFileTreeDeleteTargets({
|
||||
visibleRows: fileTreeRows,
|
||||
selectedRowIds: fileTreeSelection.selectedRowIds,
|
||||
const handleDeleteResourceSelection = useCallback(async () => {
|
||||
const { docIds, assetIds } = computeTreePaneDeleteTargets({
|
||||
visibleRows: resourceRows,
|
||||
selectedRowIds: resourceSelection.selectedRowIds,
|
||||
parentById: docParentById,
|
||||
});
|
||||
|
||||
@@ -1317,14 +1300,14 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
// 说明:文件树里可能展示“思维导图子文件”等动态资源(不一定在 mindmapAssets 列表里)。
|
||||
// 为避免出现“看起来选中了,但删除不生效”,这里优先从可见行里拿到选中资源的完整元数据。
|
||||
const isAssetRow = (
|
||||
row: FileTreeRow,
|
||||
): row is Extract<FileTreeRow, { kind: "asset" | "asset-folder" }> =>
|
||||
row: TreePaneRow,
|
||||
): row is Extract<TreePaneRow, { kind: "asset" | "asset-folder" }> =>
|
||||
row.kind === "asset" || row.kind === "asset-folder";
|
||||
|
||||
const selectedAssetHints = Array.from(
|
||||
new Map(
|
||||
fileTreeRows
|
||||
.filter((row) => fileTreeSelection.selectedRowIds.has(row.rowId))
|
||||
resourceRows
|
||||
.filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
|
||||
.filter(isAssetRow)
|
||||
.map((row) => [row.asset.id, row.asset] as const),
|
||||
).values(),
|
||||
@@ -1379,15 +1362,15 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
|
||||
await refreshTree();
|
||||
setContextMenu(null);
|
||||
setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" }));
|
||||
setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "删除失败");
|
||||
}
|
||||
}, [
|
||||
activeId,
|
||||
docParentById,
|
||||
fileTreeRows,
|
||||
fileTreeSelection.selectedRowIds,
|
||||
resourceRows,
|
||||
resourceSelection.selectedRowIds,
|
||||
handleDeleteAssets,
|
||||
mediaAssets,
|
||||
mindmapAssets,
|
||||
@@ -1528,8 +1511,8 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
[moveLocalNode, refreshTree, setExpanded],
|
||||
);
|
||||
|
||||
const handleFileTreeDropFiles = useCallback(
|
||||
(docId: string, files: FileList, targetRow?: FileTreeRow) => {
|
||||
const handleResourcePaneDropFiles = useCallback(
|
||||
(docId: string, files: FileList, targetRow?: TreePaneRow) => {
|
||||
void (async () => {
|
||||
const droppedFiles = Array.from(files ?? []);
|
||||
if (droppedFiles.length === 0) return;
|
||||
@@ -1552,8 +1535,8 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
const inferredTargetDocId =
|
||||
docId ||
|
||||
inferPasteTargetDocId({
|
||||
focusedRowId: fileTreeSelection.focusedRowId,
|
||||
rowById: fileTreeRowById,
|
||||
focusedRowId: resourceSelection.focusedRowId,
|
||||
rowById: resourceRowById,
|
||||
activeDocId: activeId || null,
|
||||
}) ||
|
||||
"";
|
||||
@@ -1618,16 +1601,16 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
[
|
||||
activeId,
|
||||
editorBridge,
|
||||
fileTreeRowById,
|
||||
fileTreeSelection.focusedRowId,
|
||||
resourceRowById,
|
||||
resourceSelection.focusedRowId,
|
||||
sidebarData.activeWorkspaceId,
|
||||
sidebarData.documents,
|
||||
sidebarQuery,
|
||||
],
|
||||
);
|
||||
|
||||
const handleFileTreeInternalDrop = useCallback(
|
||||
(args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => {
|
||||
const handleResourcePaneInternalDrop = useCallback(
|
||||
(args: { targetRow: TreePaneRow; rowIds: string[]; copy: boolean }) => {
|
||||
void (async () => {
|
||||
const targetDocId = inferDropTargetDocId(args.targetRow);
|
||||
if (!targetDocId) {
|
||||
@@ -1660,11 +1643,11 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
});
|
||||
|
||||
const rows = uniqueRowIds
|
||||
.map((rowId) => fileTreeRowById.get(rowId as any))
|
||||
.filter(Boolean) as FileTreeRow[];
|
||||
.map((rowId) => resourceRowById.get(rowId as any))
|
||||
.filter(Boolean) as TreePaneRow[];
|
||||
|
||||
const docIds = rows.filter((row) => row.kind === "doc").map((row) => row.docId);
|
||||
const assetRows = rows.filter((row) => row.kind === "asset") as Extract<FileTreeRow, { kind: "asset" }>[];
|
||||
const assetRows = rows.filter((row) => row.kind === "asset") as Extract<TreePaneRow, { kind: "asset" }>[];
|
||||
const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id);
|
||||
|
||||
if (docIds.length === 0 && copyableAssetIds.length === 0) {
|
||||
@@ -1771,7 +1754,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
[
|
||||
childrenCountByParentId,
|
||||
docParentById,
|
||||
fileTreeRowById,
|
||||
resourceRowById,
|
||||
moveLocalNode,
|
||||
refreshTree,
|
||||
sidebarQuery,
|
||||
@@ -1803,7 +1786,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
const handleDeleteFromContextMenuNode = useCallback(
|
||||
async (node: SidebarTreeNode) => {
|
||||
if (viewMode === "filesystem") {
|
||||
await handleDeleteFileTreeSelection();
|
||||
await handleDeleteResourceSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1816,7 +1799,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
window.alert(error instanceof Error ? error.message : "删除失败");
|
||||
}
|
||||
},
|
||||
[handleDelete, handleDeleteFileTreeSelection, viewMode],
|
||||
[handleDelete, handleDeleteResourceSelection, viewMode],
|
||||
);
|
||||
|
||||
const handleDeleteFromAssetContextMenu = useCallback(
|
||||
@@ -1847,7 +1830,7 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
|
||||
try {
|
||||
await handleDeleteAssets(uniqueAssetIds, assetHint);
|
||||
setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" }));
|
||||
setResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "删除失败");
|
||||
}
|
||||
@@ -2193,68 +2176,6 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openContextMenuFromTreeShell = useCallback(
|
||||
({ documentId, x, y }: { documentId: string; x: number; y: number }) => {
|
||||
const node = nodeById.get(documentId);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
setContextMenu({
|
||||
node,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
},
|
||||
[nodeById],
|
||||
);
|
||||
|
||||
const openFileTreeContextMenuFromTreeShell = useCallback(
|
||||
({
|
||||
documentId,
|
||||
assetId,
|
||||
rowId,
|
||||
x,
|
||||
y,
|
||||
}: {
|
||||
documentId?: string;
|
||||
assetId?: string;
|
||||
rowId?: string;
|
||||
rowKind?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}) => {
|
||||
if (assetId) {
|
||||
const asset = assetById.get(assetId);
|
||||
if (asset) {
|
||||
if (rowId) {
|
||||
setFileTreeSelection((prev) =>
|
||||
reduceFileTreeSelection(prev, { type: "contextmenu", rowId }),
|
||||
);
|
||||
}
|
||||
setAssetMenu({ asset, x, y });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (documentId) {
|
||||
const node = nodeById.get(documentId);
|
||||
if (node) {
|
||||
if (rowId) {
|
||||
setFileTreeSelection((prev) =>
|
||||
reduceFileTreeSelection(prev, { type: "contextmenu", rowId }),
|
||||
);
|
||||
}
|
||||
setContextMenu({
|
||||
node,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[assetById, nodeById],
|
||||
);
|
||||
|
||||
const openShareDialog = useCallback((node: SidebarTreeNode) => {
|
||||
setShareTarget({
|
||||
id: node.id,
|
||||
@@ -2596,42 +2517,17 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
</button>
|
||||
{!collapsedSections.private ? (
|
||||
<div className="flex-1 px-1 pb-2">
|
||||
{treeShellAvailable ? (
|
||||
<MnoteWebTreeShell
|
||||
workspaceId={sidebarData.activeWorkspaceId ?? null}
|
||||
activeDocumentId={activeId}
|
||||
actorId={typeof currentUser?._id === "string" ? currentUser._id : null}
|
||||
reloadToken={treeShellReloadToken}
|
||||
onNavigate={handleNavigateFromTreeShell}
|
||||
onOpenContextMenu={openContextMenuFromTreeShell}
|
||||
onRefresh={refreshTree}
|
||||
fallback={
|
||||
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
|
||||
<PrivateTree
|
||||
rows={visibleFilteredPrivatePageRows}
|
||||
expanded={expanded}
|
||||
activeId={activeId}
|
||||
onToggleExpand={toggleExpand}
|
||||
onMove={handleMove}
|
||||
onCreateChild={handleCreate}
|
||||
onContextMenu={openContextMenu}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
|
||||
<PrivateTree
|
||||
rows={visibleFilteredPrivatePageRows}
|
||||
expanded={expanded}
|
||||
activeId={activeId}
|
||||
onToggleExpand={toggleExpand}
|
||||
onMove={handleMove}
|
||||
onCreateChild={handleCreate}
|
||||
onContextMenu={openContextMenu}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<SidebarTreeSurface
|
||||
mode="page"
|
||||
className="h-full"
|
||||
rows={visibleFilteredPrivatePageRows}
|
||||
expanded={expanded}
|
||||
activeId={activeId}
|
||||
onToggleExpand={toggleExpand}
|
||||
onMove={handleMove}
|
||||
onCreateChild={handleCreate}
|
||||
onContextMenu={openContextMenu}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 pb-2 text-xs text-gray-400">已折叠</div>
|
||||
@@ -2642,66 +2538,30 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
|
||||
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
|
||||
<div className="flex-1 px-1 pb-2">
|
||||
<div
|
||||
ref={fileTreeContainerRef}
|
||||
ref={resourcePaneContainerRef}
|
||||
data-testid="file-tree-container"
|
||||
className="h-full w-full min-w-0 overflow-x-hidden"
|
||||
>
|
||||
{treeShellAvailable ? (
|
||||
<MnoteWebTreeShell
|
||||
workspaceId={sidebarData.activeWorkspaceId ?? null}
|
||||
activeDocumentId={activeId}
|
||||
actorId={typeof currentUser?._id === "string" ? currentUser._id : null}
|
||||
mode="filetree"
|
||||
reloadToken={treeShellReloadToken}
|
||||
onNavigate={handleNavigateFromTreeShell}
|
||||
onOpenAsset={(assetId) => {
|
||||
const asset = assetById.get(assetId);
|
||||
if (asset) {
|
||||
handleOpenAsset(asset);
|
||||
}
|
||||
}}
|
||||
onOpenContextMenu={openContextMenuFromTreeShell}
|
||||
onOpenFileTreeContextMenu={openFileTreeContextMenuFromTreeShell}
|
||||
onRefresh={refreshTree}
|
||||
fallback={
|
||||
<div className="h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white">
|
||||
<FileTree
|
||||
rows={fileTreeRows}
|
||||
activeId={activeId}
|
||||
selectedRowIds={fileTreeSelection.selectedRowIds}
|
||||
onRowClick={handleFileTreeRowClick}
|
||||
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)}
|
||||
onRowContextMenu={handleFileTreeRowContextMenu}
|
||||
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
|
||||
onToggleExpand={toggleExpand}
|
||||
onToggleAssetFolderExpand={toggleAssetFolderExpand}
|
||||
onCreateChild={handleCreate}
|
||||
onBlankMouseDown={handleFileTreeBlankMouseDown}
|
||||
onDropFiles={handleFileTreeDropFiles}
|
||||
onInternalDrop={handleFileTreeInternalDrop}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white">
|
||||
<FileTree
|
||||
rows={fileTreeRows}
|
||||
activeId={activeId}
|
||||
selectedRowIds={fileTreeSelection.selectedRowIds}
|
||||
onRowClick={handleFileTreeRowClick}
|
||||
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)}
|
||||
onRowContextMenu={handleFileTreeRowContextMenu}
|
||||
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
|
||||
onToggleExpand={toggleExpand}
|
||||
onToggleAssetFolderExpand={toggleAssetFolderExpand}
|
||||
onCreateChild={handleCreate}
|
||||
onBlankMouseDown={handleFileTreeBlankMouseDown}
|
||||
onDropFiles={handleFileTreeDropFiles}
|
||||
onInternalDrop={handleFileTreeInternalDrop}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<SidebarTreeSurface
|
||||
mode="filetree"
|
||||
className="h-full"
|
||||
rows={resourceRows}
|
||||
activeId={activeId}
|
||||
selectedRowIds={resourceSelection.selectedRowIds}
|
||||
onRowClick={handleResourceRowClick}
|
||||
onRowDoubleClick={(row, event) => handleResourceRowDoubleClick(row, event)}
|
||||
onRowContextMenu={handleResourceRowContextMenu}
|
||||
onRowDragStart={(row, event) => {
|
||||
void event;
|
||||
handleResourceRowDragStart(row);
|
||||
}}
|
||||
onToggleExpand={toggleExpand}
|
||||
onToggleAssetFolderExpand={toggleAssetFolderExpand}
|
||||
onCreateChild={handleCreate}
|
||||
onBlankMouseDown={handleResourcePaneBlankMouseDown}
|
||||
onDropFiles={handleResourcePaneDropFiles}
|
||||
onInternalDrop={handleResourcePaneInternalDrop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
export type { FileTreeSelectionState as TreePaneSelectionState } from "@/lib/file-tree/selection";
|
||||
export {
|
||||
normalizeFileTreeSelectionForVisibleRows as normalizeTreePaneSelectionForVisibleRows,
|
||||
reduceFileTreeSelection as reduceTreePaneSelection,
|
||||
} from "@/lib/file-tree/selection";
|
||||
export type { FileTreeRow as TreePaneRow } from "@/lib/file-tree/types";
|
||||
export { computeFileTreeDeleteTargets as computeTreePaneDeleteTargets } from "@/lib/file-tree/delete";
|
||||
export {
|
||||
inferPasteTargetDocId,
|
||||
isTextInputTarget,
|
||||
readFileTreeClipboardPayload as readTreePaneClipboardPayload,
|
||||
writeFileTreeClipboardPayload as writeTreePaneClipboardPayload,
|
||||
} from "@/lib/file-tree/clipboard";
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import type { DragEvent, MouseEvent } from "react";
|
||||
import { FileTree } from "@/components/sidebar/file-tree";
|
||||
import { PrivateTree } from "@/components/sidebar/private-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";
|
||||
|
||||
type SidebarPageTreeSurfaceProps = {
|
||||
mode: "page";
|
||||
rows: PageTreeProjectionItem[];
|
||||
expanded: Set<string>;
|
||||
activeId: string;
|
||||
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;
|
||||
};
|
||||
|
||||
type SidebarFileTreeSurfaceProps = {
|
||||
mode: "filetree";
|
||||
rows: FileTreeRow[];
|
||||
activeId: string;
|
||||
selectedRowIds: Set<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?: (docId: string, files: FileList, targetRow?: FileTreeRow) => void;
|
||||
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => 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 = {
|
||||
items: TreePickerSurfaceItem[];
|
||||
highlighted: number;
|
||||
className?: string;
|
||||
emptyText?: string;
|
||||
onHighlight: (index: number) => void;
|
||||
onPick: (targetId: string | null) => void;
|
||||
};
|
||||
|
||||
export function SidebarTreeSurface(props: SidebarTreeSurfaceProps) {
|
||||
const surfaceTestId =
|
||||
props.mode === "page"
|
||||
? "sidebar-page-tree-shell"
|
||||
: "sidebar-file-tree-shell";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={surfaceTestId}
|
||||
data-shell-mode={props.mode}
|
||||
className={cn(
|
||||
"h-full w-full min-w-0 overflow-x-hidden rounded-md border border-[#eff2f6] bg-white",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.mode === "page" ? (
|
||||
<PrivateTree
|
||||
rows={props.rows}
|
||||
expanded={props.expanded}
|
||||
activeId={props.activeId}
|
||||
onToggleExpand={props.onToggleExpand}
|
||||
onMove={props.onMove}
|
||||
onCreateChild={props.onCreateChild}
|
||||
onContextMenu={props.onContextMenu}
|
||||
/>
|
||||
) : (
|
||||
<FileTree
|
||||
rows={props.rows}
|
||||
activeId={props.activeId}
|
||||
selectedRowIds={props.selectedRowIds}
|
||||
onRowClick={props.onRowClick}
|
||||
onRowDoubleClick={props.onRowDoubleClick}
|
||||
onRowContextMenu={props.onRowContextMenu}
|
||||
onRowDragStart={props.onRowDragStart}
|
||||
onToggleExpand={props.onToggleExpand}
|
||||
onToggleAssetFolderExpand={props.onToggleAssetFolderExpand}
|
||||
onCreateChild={props.onCreateChild}
|
||||
onBlankMouseDown={props.onBlankMouseDown}
|
||||
onDropFiles={props.onDropFiles}
|
||||
onInternalDrop={props.onInternalDrop}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TreePickerSurface({
|
||||
items,
|
||||
highlighted,
|
||||
className,
|
||||
emptyText = "没有匹配结果",
|
||||
onHighlight,
|
||||
onPick,
|
||||
}: TreePickerSurfaceProps) {
|
||||
if (items.length === 0) {
|
||||
return <div className="p-4 text-sm text-gray-400">{emptyText}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="tree-picker-surface"
|
||||
className={cn("py-2", className)}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
copyTreeCommand,
|
||||
createDocumentCommand,
|
||||
deleteDocumentCommand,
|
||||
embedDocumentCommand,
|
||||
moveDocumentCommand,
|
||||
purgeDocumentCommand,
|
||||
renameDocumentCommand,
|
||||
restoreDocumentCommand,
|
||||
} from "@/lib/documents/tree-command-client";
|
||||
|
||||
describe("tree-command-client", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("通过统一 client 发送 tree/document command 并返回结果", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, success: true, items: [] }),
|
||||
} as Response);
|
||||
|
||||
await createDocumentCommand(null);
|
||||
await renameDocumentCommand({ documentId: "doc_1", title: "新标题" });
|
||||
await moveDocumentCommand({ documentId: "doc_1", parentId: null, position: 0 });
|
||||
await deleteDocumentCommand({ documentId: "doc_1" });
|
||||
await restoreDocumentCommand({ documentId: "doc_1" });
|
||||
await purgeDocumentCommand({ documentId: "doc_1" });
|
||||
await embedDocumentCommand({ sourceId: "doc_1", targetId: "doc_2" });
|
||||
await copyTreeCommand({ targetParentId: null, items: [{ documentId: "doc_1", recursive: true }] });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(8);
|
||||
expect(fetchMock.mock.calls.map((call) => String(call[0]))).toEqual([
|
||||
"/api/documents/create",
|
||||
"/api/documents/title",
|
||||
"/api/documents/move",
|
||||
"/api/documents/delete",
|
||||
"/api/documents/restore",
|
||||
"/api/documents/purge",
|
||||
"/api/documents/embed",
|
||||
"/api/documents/copy-tree",
|
||||
]);
|
||||
});
|
||||
|
||||
it("在后端返回错误时抛出统一异常", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ error: "删除失败" }),
|
||||
} as Response);
|
||||
|
||||
await expect(deleteDocumentCommand({ documentId: "doc_1" })).rejects.toThrow("删除失败");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,41 @@ type DocumentCommandErrorPayload = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const TREE_COMMAND_PROTOCOL = {
|
||||
create: {
|
||||
preferredCommandName: "tree.node.create",
|
||||
compatCommandName: "documents.create",
|
||||
},
|
||||
rename: {
|
||||
preferredCommandName: "tree.node.rename",
|
||||
compatCommandName: "documents.title.update",
|
||||
},
|
||||
move: {
|
||||
preferredCommandName: "tree.subtree.move",
|
||||
compatCommandName: "documents.move",
|
||||
},
|
||||
archive: {
|
||||
preferredCommandName: "tree.node.archive",
|
||||
compatCommandName: "documents.delete",
|
||||
},
|
||||
restore: {
|
||||
preferredCommandName: "tree.node.restore",
|
||||
compatCommandName: "documents.restore",
|
||||
},
|
||||
purge: {
|
||||
preferredCommandName: "tree.node.purge",
|
||||
compatCommandName: "documents.purge",
|
||||
},
|
||||
embed: {
|
||||
preferredCommandName: "tree.node.embed",
|
||||
compatCommandName: "documents.embed",
|
||||
},
|
||||
copy: {
|
||||
preferredCommandName: "tree.subtree.copy",
|
||||
compatCommandName: "documents.copy_tree",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type DocumentCreateCommandResult = {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import type {
|
||||
TreeProjectionCapability,
|
||||
TreeProjectionResourceMeta,
|
||||
} from "@/lib/tree-protocol";
|
||||
|
||||
export type KernelSidebarProjectionEdge = {
|
||||
id: string;
|
||||
@@ -12,11 +16,16 @@ export type KernelSidebarProjectionItem = {
|
||||
nodeId: string;
|
||||
parentNodeId: string | null;
|
||||
nodeType: "page";
|
||||
projectionKind: "sidebar_tree";
|
||||
title: string | null;
|
||||
depth: number;
|
||||
position: number | null;
|
||||
childCount: number;
|
||||
expandable: boolean;
|
||||
expandedByDefault: boolean;
|
||||
capabilities: TreeProjectionCapability[];
|
||||
resourceMeta: TreeProjectionResourceMeta;
|
||||
iconHint: string | null;
|
||||
};
|
||||
|
||||
export type KernelSidebarProjection = {
|
||||
@@ -64,6 +73,25 @@ function dedupeRecords(records: DocumentRecord[]) {
|
||||
return unique;
|
||||
}
|
||||
|
||||
function buildSidebarCapabilities(childCount: number): TreeProjectionCapability[] {
|
||||
const capabilities: TreeProjectionCapability[] = [
|
||||
"open",
|
||||
"drag",
|
||||
"drop",
|
||||
"select",
|
||||
"create-child",
|
||||
"rename",
|
||||
"archive",
|
||||
"restore",
|
||||
"context-menu",
|
||||
"reorder",
|
||||
];
|
||||
if (childCount > 0) {
|
||||
capabilities.unshift("expand");
|
||||
}
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
function buildChildrenByParent(records: DocumentRecord[]) {
|
||||
const childrenByParentId = new Map<string | null, DocumentRecord[]>();
|
||||
const recordIds = new Set(records.map((record) => record.id));
|
||||
@@ -102,11 +130,21 @@ export function buildKernelSidebarProjection(
|
||||
nodeId: child.id,
|
||||
parentNodeId: parentId,
|
||||
nodeType: "page",
|
||||
projectionKind: "sidebar_tree",
|
||||
title: child.title ?? "无标题",
|
||||
depth,
|
||||
position: child.sort_order ?? null,
|
||||
childCount: childNodes.length,
|
||||
expandable: childNodes.length > 0,
|
||||
expandedByDefault: true,
|
||||
capabilities: buildSidebarCapabilities(childNodes.length),
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: child.id,
|
||||
workspaceId: child.workspace_id ?? null,
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
});
|
||||
if (parentId && recordById.has(parentId)) {
|
||||
edges.push({
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
const MNOTE_WEB_AUTH_COOKIE_PATH = "/api/auth/mnote-web-token";
|
||||
|
||||
export async function ensureMnoteWebAuthCookie(): Promise<void> {
|
||||
const response = await fetch(MNOTE_WEB_AUTH_COOKIE_PATH, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("mnote-web 鉴权 cookie 准备失败");
|
||||
}
|
||||
}
|
||||
@@ -221,11 +221,32 @@ describe("buildSidebarInitialData", () => {
|
||||
nodeId: "doc_1",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
projectionKind: "sidebar_tree",
|
||||
title: "页面 1",
|
||||
depth: 0,
|
||||
position: 1,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: true,
|
||||
capabilities: [
|
||||
"open",
|
||||
"drag",
|
||||
"drop",
|
||||
"select",
|
||||
"create-child",
|
||||
"rename",
|
||||
"archive",
|
||||
"restore",
|
||||
"context-menu",
|
||||
"reorder",
|
||||
],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
@@ -276,11 +297,32 @@ describe("buildSidebarInitialData", () => {
|
||||
nodeId: "doc_1",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
projectionKind: "sidebar_tree",
|
||||
title: "页面 1",
|
||||
depth: 0,
|
||||
position: 1,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: true,
|
||||
capabilities: [
|
||||
"open",
|
||||
"drag",
|
||||
"drop",
|
||||
"select",
|
||||
"create-child",
|
||||
"rename",
|
||||
"archive",
|
||||
"restore",
|
||||
"context-menu",
|
||||
"reorder",
|
||||
],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
@@ -319,7 +361,7 @@ describe("buildSidebarInitialData", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("缺少 kernel projection 时不再本地补真相,而是返回空契约 projection", () => {
|
||||
it("缺少 kernel projection 时会按 documents 重建 projection 并保留层级", () => {
|
||||
expect(
|
||||
mapSidebarDatasetListQueryResultToInitialData({
|
||||
active_workspace_id: "ws_1",
|
||||
@@ -337,6 +379,18 @@ describe("buildSidebarInitialData", () => {
|
||||
created_at: "2026-04-14T00:00:00Z",
|
||||
updated_at: null,
|
||||
},
|
||||
{
|
||||
id: "doc_2",
|
||||
workspace_id: "ws_1",
|
||||
title: "页面 2",
|
||||
parent_id: "doc_1",
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-14T00:01:00Z",
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
trashed_documents: [],
|
||||
media_assets: [],
|
||||
@@ -351,13 +405,16 @@ describe("buildSidebarInitialData", () => {
|
||||
).toMatchObject({
|
||||
activeWorkspaceId: "ws_1",
|
||||
kernelSidebarProjection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:missing",
|
||||
items: [],
|
||||
edges: [],
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
},
|
||||
kernelSidebarTree: [
|
||||
{
|
||||
id: "doc_1",
|
||||
children: [
|
||||
{
|
||||
id: "doc_2",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -96,6 +96,12 @@ function readKernelSidebarProjection(
|
||||
return camelCaseProjection;
|
||||
}
|
||||
|
||||
if (Array.isArray(result.documents) && result.documents.length > 0) {
|
||||
// 说明:部分 query transport 只回 documents,没有同步附带 projection。
|
||||
// 这里按同一协议即时重建,避免 UI 把缺失节点全部降级到根层。
|
||||
return buildProjectionContract(result.documents);
|
||||
}
|
||||
|
||||
return EMPTY_KERNEL_SIDEBAR_PROJECTION;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildPageTreeProjectionItems } from "@/lib/tree-projection";
|
||||
|
||||
describe("tree-projection contract", () => {
|
||||
it("page_tree projection item 持续输出共享 contract 字段", () => {
|
||||
const items = buildPageTreeProjectionItems([
|
||||
{
|
||||
access_scope: "private",
|
||||
id: "root",
|
||||
workspace_id: "ws_1",
|
||||
title: "Root",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "2026-04-18T00:00:00Z",
|
||||
updated_at: null,
|
||||
children: [],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
rowId: "page:root",
|
||||
nodeId: "root",
|
||||
parentNodeId: null,
|
||||
projectionKind: "page_tree",
|
||||
expandable: false,
|
||||
expandedByDefault: true,
|
||||
iconHint: "page",
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "root",
|
||||
iconHint: "page",
|
||||
},
|
||||
});
|
||||
expect(items[0]?.capabilities).toContain("open");
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,12 @@
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
|
||||
export type TreeProjectionKind = "page_tree";
|
||||
|
||||
export type TreeProjectionNodeType = "page";
|
||||
|
||||
export type TreeProjectionCapability =
|
||||
| "expand"
|
||||
| "open"
|
||||
| "drag"
|
||||
| "drop"
|
||||
| "select"
|
||||
| "create-child";
|
||||
|
||||
export type TreeProjectionResourceMeta = {
|
||||
resourceKind: "document";
|
||||
documentId: string;
|
||||
};
|
||||
import type {
|
||||
TreeProjectionCapability,
|
||||
TreeProjectionItemBase,
|
||||
} from "@/lib/tree-protocol";
|
||||
|
||||
export type PageTreeProjectionItem = {
|
||||
rowId: `page:${string}`;
|
||||
nodeId: string;
|
||||
parentNodeId: string | null;
|
||||
nodeType: TreeProjectionNodeType;
|
||||
projectionKind: TreeProjectionKind;
|
||||
depth: number;
|
||||
position: number | null;
|
||||
title: string;
|
||||
childCount: number;
|
||||
capabilities: TreeProjectionCapability[];
|
||||
resourceMeta: TreeProjectionResourceMeta;
|
||||
} & TreeProjectionItemBase & {
|
||||
node: SidebarTreeNode;
|
||||
};
|
||||
|
||||
@@ -45,6 +23,11 @@ function buildPageCapabilities(childCount: number): TreeProjectionCapability[] {
|
||||
"drop",
|
||||
"select",
|
||||
"create-child",
|
||||
"rename",
|
||||
"archive",
|
||||
"restore",
|
||||
"context-menu",
|
||||
"reorder",
|
||||
];
|
||||
if (childCount > 0) {
|
||||
capabilities.unshift("expand");
|
||||
@@ -80,11 +63,16 @@ export function buildPageTreeProjectionItems(
|
||||
position: node.kernel?.position ?? node.sort_order ?? index,
|
||||
title: node.title ?? "无标题",
|
||||
childCount,
|
||||
expandable: childCount > 0,
|
||||
expandedByDefault: node.kernel?.expandedByDefault ?? true,
|
||||
capabilities: buildPageCapabilities(childCount),
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: node.id,
|
||||
workspaceId: node.workspace_id,
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
node: {
|
||||
...node,
|
||||
parent_id: parentNodeId,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TreeProjectionItemBase } from "@/lib/tree-protocol";
|
||||
|
||||
describe("tree-protocol", () => {
|
||||
it("冻结共享 tree projection 字段与 file_tree 扩展语义", () => {
|
||||
const item: TreeProjectionItemBase = {
|
||||
nodeId: "doc_1",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
projectionKind: "file_tree",
|
||||
title: "页面 1",
|
||||
depth: 0,
|
||||
position: 1,
|
||||
childCount: 2,
|
||||
expandable: true,
|
||||
expandedByDefault: true,
|
||||
capabilities: ["expand", "open", "drag", "drop", "select", "create-child"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_1",
|
||||
assetKind: "file",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
};
|
||||
|
||||
expect(item.resourceMeta.resourceKind).toBe("document");
|
||||
expect(item.resourceMeta.assetKind).toBe("file");
|
||||
expect(item.capabilities).toContain("expand");
|
||||
expect(item.expandable).toBe(true);
|
||||
expect(item.iconHint).toBe("page");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
export type TreeProjectionKind = "sidebar_tree" | "page_tree" | "file_tree";
|
||||
|
||||
export type TreeProjectionNodeType =
|
||||
| "workspace"
|
||||
| "folder"
|
||||
| "page"
|
||||
| "section"
|
||||
| "asset"
|
||||
| "mindmap"
|
||||
| "table"
|
||||
| "book"
|
||||
| "pdf"
|
||||
| "index";
|
||||
|
||||
export type TreeProjectionCapability =
|
||||
| "expand"
|
||||
| "open"
|
||||
| "drag"
|
||||
| "drop"
|
||||
| "select"
|
||||
| "create-child"
|
||||
| "rename"
|
||||
| "archive"
|
||||
| "restore"
|
||||
| "context-menu"
|
||||
| "reorder"
|
||||
| "open-asset"
|
||||
| "pick";
|
||||
|
||||
export type TreeProjectionResourceKind =
|
||||
| "workspace"
|
||||
| "document"
|
||||
| "index"
|
||||
| "asset"
|
||||
| "asset_folder"
|
||||
| "mindmap"
|
||||
| "table"
|
||||
| "book"
|
||||
| "pdf";
|
||||
|
||||
export type TreeProjectionAssetKind =
|
||||
| "file"
|
||||
| "mindmap"
|
||||
| "table"
|
||||
| "book"
|
||||
| "pdf"
|
||||
| "image"
|
||||
| "video"
|
||||
| "audio"
|
||||
| "unknown";
|
||||
|
||||
export type TreeProjectionResourceMeta = {
|
||||
resourceKind: TreeProjectionResourceKind;
|
||||
documentId?: string | null;
|
||||
assetId?: string | null;
|
||||
workspaceId?: string | null;
|
||||
assetKind?: TreeProjectionAssetKind | null;
|
||||
iconHint?: string | null;
|
||||
extra?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TreeProjectionItemBase = {
|
||||
nodeId: string;
|
||||
parentNodeId: string | null;
|
||||
nodeType: TreeProjectionNodeType;
|
||||
projectionKind: TreeProjectionKind;
|
||||
title: string;
|
||||
depth: number;
|
||||
position: number | null;
|
||||
childCount: number;
|
||||
expandable: boolean;
|
||||
expandedByDefault: boolean;
|
||||
capabilities: TreeProjectionCapability[];
|
||||
resourceMeta: TreeProjectionResourceMeta;
|
||||
iconHint: string | null;
|
||||
};
|
||||
@@ -40,21 +40,64 @@ const baseSidebarData: SidebarInitialData = {
|
||||
nodeId: "root",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
projectionKind: "sidebar_tree",
|
||||
title: "Root",
|
||||
depth: 0,
|
||||
position: 0,
|
||||
childCount: 1,
|
||||
expandable: true,
|
||||
expandedByDefault: true,
|
||||
capabilities: [
|
||||
"expand",
|
||||
"open",
|
||||
"drag",
|
||||
"drop",
|
||||
"select",
|
||||
"create-child",
|
||||
"rename",
|
||||
"archive",
|
||||
"restore",
|
||||
"context-menu",
|
||||
"reorder",
|
||||
],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "root",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
{
|
||||
nodeId: "child",
|
||||
parentNodeId: "root",
|
||||
nodeType: "page",
|
||||
projectionKind: "sidebar_tree",
|
||||
title: "Child",
|
||||
depth: 1,
|
||||
position: 1,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: true,
|
||||
capabilities: [
|
||||
"open",
|
||||
"drag",
|
||||
"drop",
|
||||
"select",
|
||||
"create-child",
|
||||
"rename",
|
||||
"archive",
|
||||
"restore",
|
||||
"context-menu",
|
||||
"reorder",
|
||||
],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "child",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
parseTreeStreamMessage,
|
||||
} from "@/lib/tree-stream/protocol";
|
||||
import { applyTreeStreamDelta, type TreeStreamDeltaEvent } from "@/lib/tree-stream/tree-delta";
|
||||
import { ensureMnoteWebAuthCookie } from "@/lib/mnote-web-auth";
|
||||
|
||||
export interface SidebarTreeStreamState {
|
||||
data: SidebarInitialData | null;
|
||||
@@ -60,9 +61,8 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, state.cursor);
|
||||
const eventSource = new EventSource(url, { withCredentials: true });
|
||||
eventSourceRef.current = eventSource;
|
||||
let cancelled = false;
|
||||
let eventSource: EventSource | null = null;
|
||||
|
||||
const handleMessage = (event: MessageEvent<string>) => {
|
||||
const envelope = parseTreeStreamMessage({
|
||||
@@ -121,21 +121,45 @@ export function useSidebarTreeStream(initialData: SidebarInitialData): SidebarTr
|
||||
status: previous.data ? "live" : "fallback",
|
||||
error: previous.error ?? new Error("tree stream 连接失败"),
|
||||
}));
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
eventSource?.close();
|
||||
if (eventSourceRef.current === eventSource) {
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.addEventListener("snapshot", handleMessage as EventListener);
|
||||
eventSource.addEventListener("delta", handleMessage as EventListener);
|
||||
eventSource.addEventListener("resync", handleMessage as EventListener);
|
||||
eventSource.onmessage = handleMessage;
|
||||
eventSource.onerror = handleError;
|
||||
void (async () => {
|
||||
try {
|
||||
await ensureMnoteWebAuthCookie();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = buildWorkspaceTreeStreamUrl(baseUrl, workspaceId, state.cursor);
|
||||
eventSource = new EventSource(url, { withCredentials: true });
|
||||
eventSourceRef.current = eventSource;
|
||||
eventSource.addEventListener("snapshot", handleMessage as EventListener);
|
||||
eventSource.addEventListener("delta", handleMessage as EventListener);
|
||||
eventSource.addEventListener("resync", handleMessage as EventListener);
|
||||
eventSource.onmessage = handleMessage;
|
||||
eventSource.onerror = handleError;
|
||||
} catch {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setState((previous) => ({
|
||||
...previous,
|
||||
status: previous.data ? "live" : "fallback",
|
||||
error: previous.error ?? new Error("tree stream 鉴权失败"),
|
||||
}));
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
eventSource.removeEventListener("snapshot", handleMessage as EventListener);
|
||||
eventSource.removeEventListener("delta", handleMessage as EventListener);
|
||||
eventSource.removeEventListener("resync", handleMessage as EventListener);
|
||||
eventSource.close();
|
||||
cancelled = true;
|
||||
eventSource?.removeEventListener("snapshot", handleMessage as EventListener);
|
||||
eventSource?.removeEventListener("delta", handleMessage as EventListener);
|
||||
eventSource?.removeEventListener("resync", handleMessage as EventListener);
|
||||
eventSource?.close();
|
||||
if (eventSourceRef.current === eventSource) {
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user