4-26 树rust-2
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
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 React, { 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;
|
||||
@@ -21,6 +21,33 @@ const sidebarData = {
|
||||
trashedDocuments: [],
|
||||
};
|
||||
|
||||
function buildSidebarNode(input: {
|
||||
id: string;
|
||||
title: string;
|
||||
children?: Array<ReturnType<typeof buildSidebarNode>>;
|
||||
}) {
|
||||
return {
|
||||
access_scope: "private",
|
||||
id: input.id,
|
||||
workspace_id: "ws_test",
|
||||
title: input.title,
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "2026-04-24T00:00:00Z",
|
||||
updated_at: "2026-04-24T00:00:00Z",
|
||||
children: input.children ?? [],
|
||||
kernel: {
|
||||
nodeType: "page" as const,
|
||||
depth: 0,
|
||||
position: 0,
|
||||
childCount: input.children?.length ?? 0,
|
||||
expandedByDefault: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
const key = Array.isArray(queryKey) ? queryKey[0] : queryKey;
|
||||
@@ -60,7 +87,9 @@ vi.mock("@/components/ui/dialog", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/input", () => ({
|
||||
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
||||
Input: React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>((props, ref) => (
|
||||
<input ref={ref} {...props} />
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tabs", () => ({
|
||||
@@ -94,6 +123,9 @@ describe("MoveEmbedPickerDialog", () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
window.__MNOTE_RUNTIME_CONFIG__ = {
|
||||
treeRendererFamily: "react",
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -101,6 +133,7 @@ describe("MoveEmbedPickerDialog", () => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
mockUseDocumentSearch.mockReset();
|
||||
mockUseDocumentSearch.mockReturnValue({
|
||||
@@ -108,6 +141,7 @@ describe("MoveEmbedPickerDialog", () => {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
sidebarData.kernelSidebarTree = [];
|
||||
delete window.__MNOTE_RUNTIME_CONFIG__;
|
||||
});
|
||||
|
||||
@@ -141,6 +175,73 @@ describe("MoveEmbedPickerDialog", () => {
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("空查询时应保留根节点并过滤 excludeIds", async () => {
|
||||
sidebarData.kernelSidebarTree = [
|
||||
buildSidebarNode({ id: "doc_hidden", title: "隐藏页面" }),
|
||||
buildSidebarNode({ id: "doc_visible", title: "保留页面" }),
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MoveEmbedPickerDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
workspaceId="ws_test"
|
||||
defaultMode="move"
|
||||
allowRoot
|
||||
excludeIds={["doc_hidden"]}
|
||||
onPick={vi.fn(async () => undefined)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-testid="tree-picker-root"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_hidden"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="tree-picker-row"][data-node-id="doc_visible"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("空查询态应支持键盘高亮切换并按当前高亮项选中", async () => {
|
||||
sidebarData.kernelSidebarTree = [
|
||||
buildSidebarNode({ id: "doc_first", title: "第一页" }),
|
||||
buildSidebarNode({ id: "doc_second", title: "第二页" }),
|
||||
];
|
||||
|
||||
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={[]}
|
||||
onPick={onPick}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const input = container.querySelector("input");
|
||||
const pickerRows = container.querySelectorAll('[data-testid="tree-picker-row"]');
|
||||
expect(input).not.toBeNull();
|
||||
expect(pickerRows).toHaveLength(2);
|
||||
|
||||
await act(async () => {
|
||||
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||
});
|
||||
|
||||
expect(pickerRows[0]?.className ?? "").toContain("bg-[#e3ecff]");
|
||||
|
||||
await act(async () => {
|
||||
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onPick).toHaveBeenCalledWith("move", "doc_first");
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("搜索结果也应继续复用统一 picker surface", async () => {
|
||||
mockUseDocumentSearch.mockReturnValue({
|
||||
data: {
|
||||
@@ -198,6 +299,141 @@ describe("MoveEmbedPickerDialog", () => {
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("搜索结果态应支持高亮切换并按当前高亮项选中", async () => {
|
||||
mockUseDocumentSearch.mockReturnValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: "doc_first",
|
||||
title: "第一页",
|
||||
matchField: "title",
|
||||
},
|
||||
{
|
||||
id: "doc_second",
|
||||
title: "第二页",
|
||||
matchField: "title",
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
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={[]}
|
||||
onPick={onPick}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const input = container.querySelector("input");
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
Object.defineProperty(input as HTMLInputElement, "value", {
|
||||
configurable: true,
|
||||
value: "第",
|
||||
});
|
||||
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input?.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
const pickerRows = container.querySelectorAll('[data-testid="tree-picker-row"]');
|
||||
expect(pickerRows).toHaveLength(2);
|
||||
await act(async () => {
|
||||
pickerRows[1]?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(pickerRows[1]?.className ?? "").toContain("bg-[#e3ecff]");
|
||||
|
||||
await act(async () => {
|
||||
pickerRows[1]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onPick).toHaveBeenCalledWith("move", "doc_second");
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("搜索结果态不应再注入根节点,且仍应支持多次 ArrowDown 后选中后续结果", async () => {
|
||||
mockUseDocumentSearch.mockReturnValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: "doc_first",
|
||||
title: "第一页",
|
||||
matchField: "title",
|
||||
},
|
||||
{
|
||||
id: "doc_second",
|
||||
title: "第二页",
|
||||
matchField: "title",
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
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={[]}
|
||||
onPick={onPick}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const input = container.querySelector("input");
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
Object.defineProperty(input as HTMLInputElement, "value", {
|
||||
configurable: true,
|
||||
value: "第",
|
||||
});
|
||||
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input?.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
const pickerRows = container.querySelectorAll('[data-testid="tree-picker-row"]');
|
||||
expect(container.querySelector('[data-testid="tree-picker-root"]')).toBeNull();
|
||||
expect(pickerRows).toHaveLength(2);
|
||||
|
||||
await act(async () => {
|
||||
input?.focus();
|
||||
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onPick).toHaveBeenCalledWith("move", "doc_second");
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("rust_family 配置下,picker 空态与结果态都应进入统一 host", async () => {
|
||||
window.__MNOTE_RUNTIME_CONFIG__ = {
|
||||
treeRendererFamily: "rust_family",
|
||||
@@ -266,4 +502,165 @@ describe("MoveEmbedPickerDialog", () => {
|
||||
container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rust_family 配置下,输入框键盘命令应转发给 iframe,并用焦点回传更新 shell 状态", async () => {
|
||||
window.__MNOTE_RUNTIME_CONFIG__ = {
|
||||
treeRendererFamily: "rust_family",
|
||||
};
|
||||
mockUseDocumentSearch.mockReturnValue({
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: "doc_first",
|
||||
title: "第一页",
|
||||
matchField: "title",
|
||||
},
|
||||
{
|
||||
id: "doc_second",
|
||||
title: "第二页",
|
||||
matchField: "title",
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
|
||||
{ status: 200, headers: { "content-type": "text/html" } },
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MoveEmbedPickerDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
workspaceId="ws_test"
|
||||
defaultMode="move"
|
||||
allowRoot
|
||||
excludeIds={[]}
|
||||
onPick={vi.fn(async () => undefined)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const input = container.querySelector("input");
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
Object.defineProperty(input as HTMLInputElement, "value", {
|
||||
configurable: true,
|
||||
value: "第",
|
||||
});
|
||||
input?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input?.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
||||
expect(iframe).not.toBeNull();
|
||||
Object.defineProperty(iframe, "contentWindow", {
|
||||
configurable: true,
|
||||
value: window,
|
||||
});
|
||||
const postMessageMock = vi.spyOn(window, "postMessage").mockImplementation(() => undefined);
|
||||
postMessageMock.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
|
||||
});
|
||||
|
||||
expect(postMessageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "tree-picker-surface",
|
||||
type: "tree.picker.command",
|
||||
command: "next",
|
||||
}),
|
||||
"*",
|
||||
);
|
||||
|
||||
postMessageMock.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
channel: "tree-picker-surface",
|
||||
type: "tree.picker.focus.changed",
|
||||
documentId: "doc_second",
|
||||
itemKey: "doc_second",
|
||||
},
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(postMessageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "tree-picker-surface",
|
||||
type: "tree.shell.state.patch",
|
||||
activeDocumentId: "doc_second",
|
||||
activePickerItemKey: "doc_second",
|
||||
}),
|
||||
"*",
|
||||
);
|
||||
|
||||
postMessageMock.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
|
||||
expect(postMessageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "tree-picker-surface",
|
||||
type: "tree.picker.command",
|
||||
command: "pick",
|
||||
}),
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
it("rust_family 配置下,无根目录且无结果时也应保持 same-origin host 空态", async () => {
|
||||
window.__MNOTE_RUNTIME_CONFIG__ = {
|
||||
treeRendererFamily: "rust_family",
|
||||
};
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
|
||||
{ status: 200, headers: { "content-type": "text/html" } },
|
||||
),
|
||||
);
|
||||
|
||||
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={false}
|
||||
excludeIds={[]}
|
||||
onPick={onPick}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const surface = container.querySelector('[data-testid="tree-picker-surface"]');
|
||||
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
||||
|
||||
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||
expect(iframe).not.toBeNull();
|
||||
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Search } from "lucide-react";
|
||||
import { TreePickerSurface } from "@/components/sidebar/tree-shell-surface";
|
||||
import type { TreeShellPickerCommand } from "@/components/sidebar/tree-shell-host";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@@ -14,6 +15,7 @@ import { useDocumentSearch } from "@/hooks/use-document-search";
|
||||
import type { DocumentSearchFilters, DocumentSearchResult } from "@/types/search";
|
||||
|
||||
export type MoveEmbedMode = "move" | "embed";
|
||||
const PICKER_ROOT_ITEM_KEY = "__root__";
|
||||
|
||||
const DEFAULT_FILTERS: DocumentSearchFilters = {
|
||||
titleOnly: true,
|
||||
@@ -106,7 +108,11 @@ function MoveEmbedPickerDialogBody({
|
||||
const [mode, setMode] = useState<MoveEmbedMode>(defaultMode);
|
||||
const [query, setQuery] = useState("");
|
||||
const [highlighted, setHighlighted] = useState(0);
|
||||
const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "react";
|
||||
const [pickerCommand, setPickerCommand] = useState<TreeShellPickerCommand | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const keyboardHighlightPendingRef = useRef(false);
|
||||
const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "rust_family";
|
||||
const canDelegatePickerKeyboardToShell = treeRendererFamily === "rust_family" && Boolean(workspaceId);
|
||||
|
||||
const handleModeChange = useCallback((value: string) => {
|
||||
setMode(value as MoveEmbedMode);
|
||||
@@ -119,6 +125,13 @@ function MoveEmbedPickerDialogBody({
|
||||
setHighlighted(0);
|
||||
}, []);
|
||||
|
||||
const queuePickerCommand = useCallback((kind: TreeShellPickerCommand["kind"]) => {
|
||||
setPickerCommand((prev) => ({
|
||||
kind,
|
||||
seq: (prev?.seq ?? 0) + 1,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const payload = useMemo(() => {
|
||||
if (!workspaceId) return null;
|
||||
return {
|
||||
@@ -152,11 +165,11 @@ function MoveEmbedPickerDialogBody({
|
||||
|
||||
const result: PickerItem[] = [];
|
||||
|
||||
if (allowRoot && mode === "move") {
|
||||
result.push({ kind: "root", id: null, title: "根目录", subtitle: "移动到工作空间根目录" });
|
||||
}
|
||||
|
||||
if (isEmptyQuery) {
|
||||
if (allowRoot && mode === "move") {
|
||||
result.push({ kind: "root", id: null, title: "根目录", subtitle: "移动到工作空间根目录" });
|
||||
}
|
||||
|
||||
const tree = sidebarQuery.data?.kernelSidebarTree ?? [];
|
||||
const flattened = buildPickerTreeItems(
|
||||
buildPageTreeProjectionItems(tree),
|
||||
@@ -191,6 +204,14 @@ function MoveEmbedPickerDialogBody({
|
||||
return result;
|
||||
}, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.kernelSidebarTree]);
|
||||
|
||||
const pickerItemIndexByKey = useMemo(() => {
|
||||
const result = new Map<string, number>();
|
||||
items.forEach((item, index) => {
|
||||
result.set(item.kind === "root" ? PICKER_ROOT_ITEM_KEY : item.id, index);
|
||||
});
|
||||
return result;
|
||||
}, [items]);
|
||||
|
||||
const placeholder = mode === "move" ? "移动到..." : "嵌入到...";
|
||||
const handlePick = useCallback(
|
||||
async (targetId: string | null) => {
|
||||
@@ -199,26 +220,161 @@ function MoveEmbedPickerDialogBody({
|
||||
},
|
||||
[mode, onOpenChange, onPick],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (items.length === 0) {
|
||||
if (highlighted !== 0) {
|
||||
setHighlighted(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (highlighted >= items.length) {
|
||||
setHighlighted(items.length - 1);
|
||||
}
|
||||
}, [highlighted, items.length]);
|
||||
const handleShellPickerFocusChange = useCallback(
|
||||
(payload: { itemKey: string | null; documentId: string | null }) => {
|
||||
const nextKey = payload.itemKey ?? payload.documentId;
|
||||
if (!nextKey) {
|
||||
return;
|
||||
}
|
||||
const nextIndex = pickerItemIndexByKey.get(nextKey);
|
||||
if (typeof nextIndex === "number") {
|
||||
setHighlighted(nextIndex);
|
||||
}
|
||||
},
|
||||
[pickerItemIndexByKey],
|
||||
);
|
||||
const handlePickerKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
keyboardHighlightPendingRef.current = true;
|
||||
if (canDelegatePickerKeyboardToShell) {
|
||||
queuePickerCommand("next");
|
||||
return;
|
||||
}
|
||||
setHighlighted((prev) => Math.min(items.length - 1, prev + 1));
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
keyboardHighlightPendingRef.current = true;
|
||||
if (canDelegatePickerKeyboardToShell) {
|
||||
queuePickerCommand("previous");
|
||||
return;
|
||||
}
|
||||
setHighlighted((prev) => Math.max(0, prev - 1));
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
keyboardHighlightPendingRef.current = true;
|
||||
if (canDelegatePickerKeyboardToShell) {
|
||||
queuePickerCommand("home");
|
||||
return;
|
||||
}
|
||||
setHighlighted(0);
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
keyboardHighlightPendingRef.current = true;
|
||||
if (canDelegatePickerKeyboardToShell) {
|
||||
queuePickerCommand("end");
|
||||
return;
|
||||
}
|
||||
setHighlighted(items.length - 1);
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
if (canDelegatePickerKeyboardToShell) {
|
||||
queuePickerCommand("pick");
|
||||
return;
|
||||
}
|
||||
const picked = items[highlighted];
|
||||
if (!picked) return;
|
||||
void handlePick(picked.id);
|
||||
}
|
||||
},
|
||||
[canDelegatePickerKeyboardToShell, handlePick, highlighted, items, queuePickerCommand],
|
||||
);
|
||||
useEffect(() => {
|
||||
const input = searchInputRef.current;
|
||||
if (!input) {
|
||||
return undefined;
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
handlePickerKeyDown(event);
|
||||
};
|
||||
input.addEventListener("keydown", onKeyDown, true);
|
||||
return () => {
|
||||
input.removeEventListener("keydown", onKeyDown, true);
|
||||
};
|
||||
}, [handlePickerKeyDown]);
|
||||
useEffect(() => {
|
||||
if (!keyboardHighlightPendingRef.current) {
|
||||
return;
|
||||
}
|
||||
keyboardHighlightPendingRef.current = false;
|
||||
if (isEmptyQuery || treeRendererFamily !== "rust_family") {
|
||||
return;
|
||||
}
|
||||
const input = searchInputRef.current;
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
window.requestAnimationFrame(() => {
|
||||
const current = searchInputRef.current;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
// 说明:same-origin picker shell 在搜索态会随高亮更新重新同步 iframe。
|
||||
// 这里把焦点稳回搜索框,避免第二次 ArrowDown 丢到输入框外。
|
||||
current.focus({ preventScroll: true });
|
||||
const end = current.value.length;
|
||||
try {
|
||||
current.setSelectionRange(end, end);
|
||||
} catch {
|
||||
// 说明:部分输入实现不支持 selection range,这里静默忽略即可。
|
||||
}
|
||||
});
|
||||
}, [highlighted, isEmptyQuery, treeRendererFamily]);
|
||||
const highlightedItem = items[highlighted] ?? null;
|
||||
const highlightedDocumentId = highlightedItem?.kind === "doc" ? highlightedItem.id : null;
|
||||
const activePickerItemKey =
|
||||
highlightedItem?.kind === "root"
|
||||
? PICKER_ROOT_ITEM_KEY
|
||||
: highlightedItem?.kind === "doc"
|
||||
? highlightedItem.id
|
||||
: null;
|
||||
const pickerFallback = (
|
||||
sidebarQuery.isLoading ? (
|
||||
<div className="p-4 text-sm text-gray-400">加载中...</div>
|
||||
) : sidebarQuery.error ? (
|
||||
<div className="p-4 text-sm text-red-600">{String(sidebarQuery.error)}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400">没有匹配结果</div>
|
||||
) : (
|
||||
<TreePickerSurface
|
||||
rendererFamily={treeRendererFamily}
|
||||
workspaceId={workspaceId}
|
||||
treeShellEnabled={isEmptyQuery}
|
||||
activePickerItemKey={activePickerItemKey}
|
||||
allowRootPick={allowRoot && mode === "move"}
|
||||
excludeIds={excludeIds}
|
||||
pickerCommand={canDelegatePickerKeyboardToShell ? pickerCommand : null}
|
||||
treeShellItems={items}
|
||||
items={items}
|
||||
highlighted={highlighted}
|
||||
onHighlight={setHighlighted}
|
||||
onPick={(targetId) => {
|
||||
void handlePick(targetId);
|
||||
}}
|
||||
onPickerFocusChange={canDelegatePickerKeyboardToShell ? handleShellPickerFocusChange : undefined}
|
||||
/>
|
||||
)
|
||||
);
|
||||
@@ -243,6 +399,7 @@ function MoveEmbedPickerDialogBody({
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
onChange={(e) => handleQueryChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
@@ -253,26 +410,7 @@ function MoveEmbedPickerDialogBody({
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
onKeyDown={(event) => {
|
||||
if (isEmptyQuery) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setHighlighted((prev) => Math.min(items.length - 1, prev + 1));
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setHighlighted((prev) => Math.max(0, prev - 1));
|
||||
} else if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const picked = items[highlighted];
|
||||
if (!picked) return;
|
||||
void handlePick(picked.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!workspaceId ? (
|
||||
<div className="p-4 text-sm text-gray-500">缺少 workspaceId,无法加载页面列表。</div>
|
||||
) : isEmptyQuery ? (
|
||||
@@ -281,15 +419,16 @@ function MoveEmbedPickerDialogBody({
|
||||
<div className="p-4 text-sm text-gray-400">加载中...</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-sm text-red-600">{String(error)}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-4 text-sm text-gray-400">没有匹配结果</div>
|
||||
) : (
|
||||
<TreePickerSurface
|
||||
rendererFamily={treeRendererFamily}
|
||||
workspaceId={workspaceId}
|
||||
treeShellEnabled={Boolean(workspaceId)}
|
||||
activeDocumentId={highlightedDocumentId}
|
||||
activePickerItemKey={activePickerItemKey}
|
||||
allowRootPick={false}
|
||||
excludeIds={excludeIds}
|
||||
pickerCommand={canDelegatePickerKeyboardToShell ? pickerCommand : null}
|
||||
treeShellItems={items}
|
||||
items={items}
|
||||
highlighted={highlighted}
|
||||
@@ -298,6 +437,7 @@ function MoveEmbedPickerDialogBody({
|
||||
onPick={(targetId) => {
|
||||
void handlePick(targetId);
|
||||
}}
|
||||
onPickerFocusChange={canDelegatePickerKeyboardToShell ? handleShellPickerFocusChange : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, FileText, Folder, Paperclip, Plus } from "lucide-react";
|
||||
import { AudioLines, BookOpen, ChevronRight, FileImage, FileText, FileVideo, Folder, Paperclip, Plus } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||
@@ -26,6 +26,47 @@ interface FileTreeProps {
|
||||
|
||||
const INDENT = 16;
|
||||
|
||||
function resolveAssetIconKind(row: Extract<FileTreeRow, { kind: "asset" | "asset-folder" }>) {
|
||||
const assetType = String(row.asset.asset_type ?? "").trim().toLowerCase();
|
||||
if (assetType === "mindmap") return "mindmap";
|
||||
if (assetType === "luckysheet") return "table";
|
||||
|
||||
const mimeType = String(row.asset.mime_type ?? "").trim().toLowerCase();
|
||||
const fileName = String(row.asset.file_name ?? "").trim().toLowerCase();
|
||||
const ext = fileName.includes(".") ? fileName.split(".").pop() ?? "" : "";
|
||||
|
||||
if (ext === "pdf" || mimeType.includes("pdf")) return "pdf";
|
||||
if (ext === "epub" || mimeType.includes("epub")) return "book";
|
||||
if (mimeType.startsWith("image/")) return "image";
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
if (mimeType.startsWith("audio/")) return "audio";
|
||||
return "file";
|
||||
}
|
||||
|
||||
function renderAssetIcon(row: Extract<FileTreeRow, { kind: "asset" | "asset-folder" }>) {
|
||||
const iconKind = resolveAssetIconKind(row);
|
||||
const baseClass = "w-4 h-4 shrink-0";
|
||||
|
||||
switch (iconKind) {
|
||||
case "mindmap":
|
||||
return <Folder className={`${baseClass} text-[#7c3aed]`} />;
|
||||
case "table":
|
||||
return <FileText className={`${baseClass} text-[#b45309]`} />;
|
||||
case "pdf":
|
||||
return <FileText className={`${baseClass} text-[#dc2626]`} />;
|
||||
case "book":
|
||||
return <BookOpen className={`${baseClass} text-[#0f766e]`} />;
|
||||
case "image":
|
||||
return <FileImage className={`${baseClass} text-[#0891b2]`} />;
|
||||
case "video":
|
||||
return <FileVideo className={`${baseClass} text-[#ea580c]`} />;
|
||||
case "audio":
|
||||
return <AudioLines className={`${baseClass} text-[#16a34a]`} />;
|
||||
default:
|
||||
return <Paperclip className={`${baseClass} text-wolai-text-secondary`} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function FileTree({
|
||||
rows,
|
||||
activeId,
|
||||
@@ -300,13 +341,13 @@ export function FileTree({
|
||||
) : (
|
||||
<span className="w-5 h-5 shrink-0" />
|
||||
)}
|
||||
<Folder className="w-4 h-4 text-[#2563eb] shrink-0" />
|
||||
{renderAssetIcon(row)}
|
||||
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="w-4 h-4 shrink-0" />
|
||||
<Paperclip className="w-4 h-4 text-wolai-text-secondary shrink-0" />
|
||||
{renderAssetIcon(row)}
|
||||
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSidebarDocumentOpenTarget } from "./sidebar-navigation";
|
||||
|
||||
describe("sidebar-navigation", () => {
|
||||
it("页面树普通打开应留在当前窗口", () => {
|
||||
expect(buildSidebarDocumentOpenTarget("doc_1", "main", "http://127.0.0.1:3000")).toEqual({
|
||||
kind: "same-window",
|
||||
path: "/documents/doc_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("显式侧栏预览打开才应使用新窗口 URL", () => {
|
||||
expect(buildSidebarDocumentOpenTarget("doc_1", "sidebar", "http://127.0.0.1:3000")).toEqual({
|
||||
kind: "new-window",
|
||||
url: "http://127.0.0.1:3000/documents/doc_1?preview=sidebar",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
export type SidebarDocumentOpenMode = "main" | "sidebar";
|
||||
|
||||
export type SidebarDocumentOpenTarget =
|
||||
| { kind: "same-window"; path: string }
|
||||
| { kind: "new-window"; url: string };
|
||||
|
||||
export function buildSidebarDocumentOpenTarget(
|
||||
documentId: string,
|
||||
mode: SidebarDocumentOpenMode,
|
||||
origin?: string | null,
|
||||
): SidebarDocumentOpenTarget {
|
||||
const path = `/documents/${documentId}`;
|
||||
if (mode === "main") {
|
||||
return { kind: "same-window", path };
|
||||
}
|
||||
|
||||
const base = origin ? `${origin}${path}` : path;
|
||||
return { kind: "new-window", url: `${base}?preview=sidebar` };
|
||||
}
|
||||
@@ -54,9 +54,18 @@ import {
|
||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import { useCurrentDocumentStore } from "@/store/current-document";
|
||||
import { buildVisibleRows } from "@/lib/file-tree/rows";
|
||||
import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd";
|
||||
import { buildVisibleRows, filterKernelFileTreeProjectionItems } from "@/lib/file-tree/rows";
|
||||
import { buildParentById, filterTopLevelDocIds } from "@/lib/file-tree/dnd";
|
||||
import { isRealFileAsset } from "@/lib/file-tree/asset";
|
||||
import {
|
||||
computeFileTreeShellDeleteTargets,
|
||||
buildFileTreeShellRowById,
|
||||
buildFileTreeShellVisibleRowIds,
|
||||
type FileTreeShellRow,
|
||||
inferFileTreeShellTargetDocumentId,
|
||||
getOrderedFileTreeShellRows,
|
||||
resolveFileTreeShellMindmapTargetId,
|
||||
} from "@/lib/file-tree/shell";
|
||||
import { MoveEmbedPickerDialog, type MoveEmbedMode } from "@/components/documents/move-embed-picker-dialog";
|
||||
import {
|
||||
computeTreePaneDeleteTargets,
|
||||
@@ -69,6 +78,10 @@ import {
|
||||
type TreePaneSelectionState,
|
||||
writeTreePaneClipboardPayload,
|
||||
} from "@/components/sidebar/tree-pane-bindings";
|
||||
import {
|
||||
buildSidebarDocumentOpenTarget,
|
||||
type SidebarDocumentOpenMode,
|
||||
} from "@/components/sidebar/sidebar-navigation";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
|
||||
import { emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events";
|
||||
@@ -105,8 +118,6 @@ const SECTION_ICONS: Record<Exclude<SidebarSectionId, "private">, React.ReactNod
|
||||
templates: <LayoutGrid className="h-4 w-4 text-[#34d399]" />,
|
||||
};
|
||||
|
||||
const normalizeStoragePath = (storagePath: string): string => storagePath.replaceAll("\\", "/");
|
||||
|
||||
const officeFileTypeFromAsset = (fileName: string | null, mimeType: string | null) => {
|
||||
const name = (fileName ?? "").trim().toLowerCase();
|
||||
const mt = (mimeType ?? "").trim().toLowerCase();
|
||||
@@ -122,27 +133,6 @@ const officeFileTypeFromAsset = (fileName: string | null, mimeType: string | nul
|
||||
return null;
|
||||
};
|
||||
|
||||
const extractMindmapIdFromStoragePath = (
|
||||
storagePath: string | null | undefined,
|
||||
): string | null => {
|
||||
if (!storagePath) return null;
|
||||
const normalized = normalizeStoragePath(storagePath);
|
||||
|
||||
const prefix = "mindmaps/";
|
||||
if (normalized.startsWith(prefix)) {
|
||||
const rest = normalized.slice(prefix.length);
|
||||
const id = rest.split("/")[0];
|
||||
return id ? id : null;
|
||||
}
|
||||
|
||||
const marker = "/mindmaps/";
|
||||
const idx = normalized.indexOf(marker);
|
||||
if (idx === -1) return null;
|
||||
const rest = normalized.slice(idx + marker.length);
|
||||
const id = rest.split("/")[0];
|
||||
return id ? id : null;
|
||||
};
|
||||
|
||||
interface SidebarProps {
|
||||
initialData: SidebarInitialData;
|
||||
sidebarData?: SidebarInitialData;
|
||||
@@ -212,7 +202,8 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
const { signOut } = useAuthActions();
|
||||
const activeId = segments?.[1] ?? "";
|
||||
const editorBridge = useEditorBridgeStore((state) => state.bridge);
|
||||
const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "react";
|
||||
const treeRendererFamily = getMnoteRuntimeConfig().treeRendererFamily ?? "rust_family";
|
||||
const isRustFamilyTreeRenderer = treeRendererFamily === "rust_family";
|
||||
|
||||
const [tree, setTree] = useState<SidebarTreeNode[]>(() => sidebarData.kernelSidebarTree);
|
||||
const [filter, setFilter] = useState("");
|
||||
@@ -282,6 +273,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
const mediaAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mediaAssets ?? []));
|
||||
const mindmapAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mindmapAssets ?? []));
|
||||
const tableAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.tableAssets ?? []));
|
||||
const pageTreeFocusedDocumentIdRef = useRef<string | null>(activeId || null);
|
||||
|
||||
const resourcePaneContainerRef = useRef<HTMLDivElement>(null);
|
||||
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
|
||||
@@ -301,6 +293,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
});
|
||||
}, [sidebarData.kernelSidebarTree]);
|
||||
|
||||
useEffect(() => {
|
||||
pageTreeFocusedDocumentIdRef.current = activeId || null;
|
||||
}, [activeId]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextAssets = sidebarData.mediaAssets ?? [];
|
||||
const nextSyncKey = buildMediaAssetListSyncKey(nextAssets);
|
||||
@@ -588,80 +584,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
return assets.filter((item) => (item.file_name ?? "未命名附件").toLowerCase().includes(keyword));
|
||||
}, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, sidebarData.trashedTableAssets, trashSearch]);
|
||||
|
||||
const mindmapChildrenSnapshot = useMemo(() => {
|
||||
const mapping = sidebarData.mindmapAssetChildren ?? {};
|
||||
const mindmapDocById = new Map<string, string>(
|
||||
(mindmapAssets ?? [])
|
||||
.filter((asset) => asset.asset_type === "mindmap")
|
||||
.map((asset) => [asset.id, asset.document_id]),
|
||||
);
|
||||
const mindmapIds = new Set(mindmapDocById.keys());
|
||||
|
||||
const mediaById = new Map<string, MediaAsset>(
|
||||
(mediaAssets ?? []).map((asset) => [asset.id, asset]),
|
||||
);
|
||||
|
||||
const childAssetsByMindmapId: Record<string, MediaAsset[]> = {};
|
||||
const childIds = new Set<string>();
|
||||
const assigned = new Set<string>();
|
||||
|
||||
// 物理目录:storage_path 归属到 mindmaps/<mindmapId>/ 的附件,作为导图文件夹内容
|
||||
(mediaAssets ?? []).forEach((asset) => {
|
||||
const sp = asset.storage_path;
|
||||
if (!sp || typeof sp !== "string") return;
|
||||
const mindmapId = extractMindmapIdFromStoragePath(sp);
|
||||
if (!mindmapId) return;
|
||||
if (!mindmapIds.has(mindmapId)) return;
|
||||
const docId = mindmapDocById.get(mindmapId);
|
||||
if (docId && asset.document_id !== docId) return;
|
||||
if (assigned.has(asset.id)) return;
|
||||
assigned.add(asset.id);
|
||||
childIds.add(asset.id);
|
||||
if (!childAssetsByMindmapId[mindmapId]) childAssetsByMindmapId[mindmapId] = [];
|
||||
childAssetsByMindmapId[mindmapId].push(asset);
|
||||
});
|
||||
|
||||
// 引用图片:从 mindmap JSON 解析出的 assetIds,也放到导图文件夹下(去重)
|
||||
(mindmapAssets ?? []).forEach((mindmapAsset) => {
|
||||
const ids = mapping[mindmapAsset.id] ?? [];
|
||||
if (!Array.isArray(ids) || ids.length === 0) return;
|
||||
ids.forEach((id) => {
|
||||
const asset = mediaById.get(id);
|
||||
if (!asset) return;
|
||||
if (asset.document_id !== mindmapAsset.document_id) return;
|
||||
if (assigned.has(asset.id)) return;
|
||||
assigned.add(asset.id);
|
||||
childIds.add(asset.id);
|
||||
if (!childAssetsByMindmapId[mindmapAsset.id]) childAssetsByMindmapId[mindmapAsset.id] = [];
|
||||
childAssetsByMindmapId[mindmapAsset.id].push(asset);
|
||||
});
|
||||
});
|
||||
|
||||
return { childAssetsByMindmapId, childIds };
|
||||
}, [mediaAssets, mindmapAssets, sidebarData.mindmapAssetChildren]);
|
||||
|
||||
const assetsByDoc = useMemo(() => {
|
||||
const map: Record<string, MediaAsset[]> = {};
|
||||
const assets = [
|
||||
...((mediaAssets ?? []).filter(
|
||||
(asset) => !mindmapChildrenSnapshot.childIds.has(asset.id),
|
||||
)),
|
||||
...(mindmapAssets ?? []),
|
||||
...(tableAssets ?? []),
|
||||
];
|
||||
|
||||
assets.forEach((asset) => {
|
||||
if (!map[asset.document_id]) {
|
||||
map[asset.document_id] = [];
|
||||
}
|
||||
const exists = map[asset.document_id].some((a) => a.id === asset.id && a.asset_type === asset.asset_type);
|
||||
if (!exists) {
|
||||
map[asset.document_id].push(asset);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [mediaAssets, mindmapAssets, tableAssets, mindmapChildrenSnapshot.childIds]);
|
||||
|
||||
const assetById = useMemo(() => {
|
||||
const map = new Map<string, MediaAsset>();
|
||||
[...mediaAssets, ...mindmapAssets, ...tableAssets].forEach((asset) => {
|
||||
@@ -672,40 +594,81 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
|
||||
const [expandedAssetFolders, setExpandedAssetFolders] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const resourceRows = useMemo(
|
||||
const resourceTreeShellItems = useMemo(
|
||||
() =>
|
||||
buildVisibleRows({
|
||||
fileTreeItems:
|
||||
filter.trim().length === 0
|
||||
? sidebarData.kernelFileTreeProjection.items
|
||||
: undefined,
|
||||
pageRows: visibleFilteredPrivatePageRows,
|
||||
expanded,
|
||||
assetsByDoc,
|
||||
assetChildrenByAssetId: mindmapChildrenSnapshot.childAssetsByMindmapId,
|
||||
expandedAssetFolderIds: expandedAssetFolders,
|
||||
nodeById,
|
||||
assetById,
|
||||
}),
|
||||
filter.trim().length === 0
|
||||
? undefined
|
||||
: filterKernelFileTreeProjectionItems({
|
||||
fileTreeItems: sidebarData.kernelFileTreeProjection.items,
|
||||
visibleDocumentIds: new Set(visibleFilteredPrivatePageRows.map((item) => item.nodeId)),
|
||||
expandedDocumentIds: expanded,
|
||||
expandedAssetFolderIds: expandedAssetFolders,
|
||||
}),
|
||||
[
|
||||
assetById,
|
||||
assetsByDoc,
|
||||
expanded,
|
||||
expandedAssetFolders,
|
||||
mindmapChildrenSnapshot.childAssetsByMindmapId,
|
||||
nodeById,
|
||||
sidebarData.kernelFileTreeProjection.items,
|
||||
visibleFilteredPrivatePageRows,
|
||||
filter,
|
||||
],
|
||||
);
|
||||
|
||||
const effectivePageTreeShellRows = useMemo(
|
||||
() => (filter.trim().length > 0 ? filteredPrivatePageRows : privatePageRows),
|
||||
[filter, filteredPrivatePageRows, privatePageRows],
|
||||
);
|
||||
|
||||
const effectiveResourceTreeShellItems = useMemo(
|
||||
() => resourceTreeShellItems ?? sidebarData.kernelFileTreeProjection.items,
|
||||
[resourceTreeShellItems, sidebarData.kernelFileTreeProjection.items],
|
||||
);
|
||||
|
||||
const resourceShellVisibleRowIds = useMemo(
|
||||
() => buildFileTreeShellVisibleRowIds(effectiveResourceTreeShellItems),
|
||||
[effectiveResourceTreeShellItems],
|
||||
);
|
||||
|
||||
const resourceShellRowById = useMemo(
|
||||
() =>
|
||||
buildFileTreeShellRowById({
|
||||
fileTreeItems: effectiveResourceTreeShellItems,
|
||||
nodeById,
|
||||
assetById,
|
||||
}),
|
||||
[assetById, effectiveResourceTreeShellItems, nodeById],
|
||||
);
|
||||
|
||||
const resourceRows = useMemo<TreePaneRow[]>(() => {
|
||||
if (isRustFamilyTreeRenderer) {
|
||||
return [];
|
||||
}
|
||||
return buildVisibleRows({
|
||||
fileTreeItems: effectiveResourceTreeShellItems,
|
||||
expanded,
|
||||
expandedAssetFolderIds: expandedAssetFolders,
|
||||
nodeById,
|
||||
assetById,
|
||||
});
|
||||
}, [
|
||||
assetById,
|
||||
effectiveResourceTreeShellItems,
|
||||
expanded,
|
||||
expandedAssetFolders,
|
||||
isRustFamilyTreeRenderer,
|
||||
nodeById,
|
||||
]);
|
||||
|
||||
const resourceVisibleRowIds = useMemo(() => resourceRows.map((row) => row.rowId), [resourceRows]);
|
||||
const resourceRowById = useMemo(() => new Map(resourceRows.map((row) => [row.rowId, row])), [resourceRows]);
|
||||
const resourceSelectionVisibleRowIds = isRustFamilyTreeRenderer
|
||||
? resourceShellVisibleRowIds
|
||||
: resourceVisibleRowIds;
|
||||
|
||||
useEffect(() => {
|
||||
setResourceSelection((prev) => normalizeTreePaneSelectionForVisibleRows(prev, resourceVisibleRowIds));
|
||||
}, [resourceVisibleRowIds]);
|
||||
setResourceSelection((prev) =>
|
||||
normalizeTreePaneSelectionForVisibleRows(prev, resourceSelectionVisibleRowIds),
|
||||
);
|
||||
}, [resourceSelectionVisibleRowIds]);
|
||||
|
||||
const docParentById = useMemo(
|
||||
() =>
|
||||
@@ -730,18 +693,22 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
const activeWorkspace =
|
||||
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
|
||||
sidebarData.workspaces[0];
|
||||
const pageTreeFocusedDocumentId = pageTreeFocusedDocumentIdRef.current ?? (activeId || null);
|
||||
|
||||
const handleOpenDocument = useCallback(
|
||||
(documentId: string, mode: "main" | "sidebar") => {
|
||||
const targetPath = `/documents/${documentId}`;
|
||||
if (mode === "main") {
|
||||
router.push(targetPath);
|
||||
(documentId: string, mode: SidebarDocumentOpenMode) => {
|
||||
const target = buildSidebarDocumentOpenTarget(
|
||||
documentId,
|
||||
mode,
|
||||
typeof window !== "undefined" ? window.location.origin : null,
|
||||
);
|
||||
if (target.kind === "same-window") {
|
||||
router.push(target.path);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
const sidebarUrl = `${buildDocumentUrl(documentId)}?preview=sidebar`;
|
||||
window.open(sidebarUrl, "_blank", "noopener,noreferrer");
|
||||
window.open(target.url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
},
|
||||
[router, setOpen],
|
||||
@@ -981,7 +948,8 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
|
||||
const handlePageTreeShellNavigate = useCallback(
|
||||
(documentId: string) => {
|
||||
handleOpenDocument(documentId, "sidebar");
|
||||
pageTreeFocusedDocumentIdRef.current = documentId;
|
||||
handleOpenDocument(documentId, "main");
|
||||
},
|
||||
[handleOpenDocument],
|
||||
);
|
||||
@@ -1008,6 +976,31 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
[nodeById],
|
||||
);
|
||||
|
||||
const handlePageTreeShellExpandChange = useCallback(
|
||||
(payload: { documentId: string | null; expanded: boolean }) => {
|
||||
if (!payload.documentId) {
|
||||
return;
|
||||
}
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (payload.expanded) {
|
||||
next.add(payload.documentId!);
|
||||
} else {
|
||||
next.delete(payload.documentId!);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePageTreeShellFocusChange = useCallback(
|
||||
(payload: { documentId: string | null }) => {
|
||||
pageTreeFocusedDocumentIdRef.current = payload.documentId;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleFileTreeShellContextMenu = useCallback(
|
||||
(payload: {
|
||||
documentId: string | null;
|
||||
@@ -1029,9 +1022,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
}
|
||||
}
|
||||
|
||||
const row = payload.rowId ? resourceRowById.get(payload.rowId) : null;
|
||||
const row = payload.rowId ? resourceShellRowById.get(payload.rowId) : null;
|
||||
const node =
|
||||
row && (row.kind === "doc" || row.kind === "index")
|
||||
row && (row.rowKind === "doc" || row.rowKind === "index")
|
||||
? row.node
|
||||
: payload.documentId
|
||||
? nodeById.get(payload.documentId) ?? null
|
||||
@@ -1045,7 +1038,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
y: payload.y,
|
||||
});
|
||||
},
|
||||
[assetById, nodeById, resourceRowById],
|
||||
[assetById, nodeById, resourceShellRowById],
|
||||
);
|
||||
|
||||
const handleFileTreeShellSelectionChange = useCallback(
|
||||
@@ -1054,26 +1047,25 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
anchorRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
}) => {
|
||||
const visibleRowIds = resourceRows.map((row) => row.rowId);
|
||||
const normalized = normalizeTreePaneSelectionForVisibleRows(
|
||||
{
|
||||
selectedRowIds: new Set(
|
||||
payload.selectedRowIds.filter((rowId) => resourceRowById.has(rowId)),
|
||||
payload.selectedRowIds.filter((rowId) => resourceShellRowById.has(rowId)),
|
||||
),
|
||||
anchorRowId:
|
||||
payload.anchorRowId && resourceRowById.has(payload.anchorRowId)
|
||||
payload.anchorRowId && resourceShellRowById.has(payload.anchorRowId)
|
||||
? payload.anchorRowId
|
||||
: null,
|
||||
focusedRowId:
|
||||
payload.focusedRowId && resourceRowById.has(payload.focusedRowId)
|
||||
payload.focusedRowId && resourceShellRowById.has(payload.focusedRowId)
|
||||
? payload.focusedRowId
|
||||
: null,
|
||||
},
|
||||
visibleRowIds,
|
||||
resourceShellVisibleRowIds,
|
||||
);
|
||||
setResourceSelection(normalized);
|
||||
},
|
||||
[resourceRowById, resourceRows],
|
||||
[resourceShellRowById, resourceShellVisibleRowIds],
|
||||
);
|
||||
|
||||
const handleFileTreeShellAssetOpen = useCallback(
|
||||
@@ -1121,9 +1113,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const orderedRowIds = resourceRows
|
||||
.filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
|
||||
.map((row) => row.rowId);
|
||||
const orderedRowIds = resourceSelectionVisibleRowIds.filter((rowId) =>
|
||||
resourceSelection.selectedRowIds.has(rowId),
|
||||
);
|
||||
await writeTreePaneClipboardPayload({
|
||||
type: "mnote-file-tree",
|
||||
version: 1,
|
||||
@@ -1140,30 +1132,68 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
return;
|
||||
}
|
||||
|
||||
const targetDocId = inferPasteTargetDocId({
|
||||
focusedRowId: resourceSelection.focusedRowId,
|
||||
rowById: resourceRowById,
|
||||
activeDocId: activeId || null,
|
||||
});
|
||||
const targetDocId = isRustFamilyTreeRenderer
|
||||
? inferFileTreeShellTargetDocumentId({
|
||||
focusedRowId: resourceSelection.focusedRowId,
|
||||
rowById: resourceShellRowById,
|
||||
activeDocId: activeId || null,
|
||||
})
|
||||
: inferPasteTargetDocId({
|
||||
focusedRowId: resourceSelection.focusedRowId,
|
||||
rowById: resourceRowById,
|
||||
activeDocId: activeId || null,
|
||||
});
|
||||
if (!targetDocId) {
|
||||
setTimeout(() => window.alert("请选择一个目标页面后再粘贴"), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = payload.rowIds
|
||||
.map((rowId) => resourceRowById.get(rowId as any))
|
||||
.filter(Boolean) as TreePaneRow[];
|
||||
|
||||
const docItemsMap = new Map<string, boolean>();
|
||||
rows.forEach((row) => {
|
||||
if (row.kind === "doc") {
|
||||
docItemsMap.set(row.docId, true);
|
||||
} else if (row.kind === "index") {
|
||||
if (!docItemsMap.has(row.docId)) {
|
||||
docItemsMap.set(row.docId, false);
|
||||
const copyableAssetIds: string[] = [];
|
||||
|
||||
if (isRustFamilyTreeRenderer) {
|
||||
const rows = getOrderedFileTreeShellRows({
|
||||
rowIds: payload.rowIds,
|
||||
visibleRowIds: resourceShellVisibleRowIds,
|
||||
rowById: resourceShellRowById,
|
||||
});
|
||||
|
||||
rows.forEach((row) => {
|
||||
if (row.rowKind === "doc") {
|
||||
docItemsMap.set(row.documentId, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (row.rowKind === "index" && !docItemsMap.has(row.documentId)) {
|
||||
docItemsMap.set(row.documentId, false);
|
||||
return;
|
||||
}
|
||||
if (row.rowKind === "asset" && row.asset && isRealFileAsset(row.asset)) {
|
||||
copyableAssetIds.push(row.asset.id);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const rows = payload.rowIds
|
||||
.map((rowId) => resourceRowById.get(rowId as any))
|
||||
.filter(Boolean) as TreePaneRow[];
|
||||
|
||||
rows.forEach((row) => {
|
||||
if (row.kind === "doc") {
|
||||
docItemsMap.set(row.docId, true);
|
||||
} else if (row.kind === "index") {
|
||||
if (!docItemsMap.has(row.docId)) {
|
||||
docItemsMap.set(row.docId, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rows
|
||||
.filter((row): row is Extract<TreePaneRow, { kind: "asset" }> => row.kind === "asset")
|
||||
.map((row) => row.asset)
|
||||
.filter((asset) => isRealFileAsset(asset))
|
||||
.forEach((asset) => {
|
||||
copyableAssetIds.push(asset.id);
|
||||
});
|
||||
}
|
||||
|
||||
if (docItemsMap.size > 0) {
|
||||
try {
|
||||
@@ -1183,9 +1213,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
emitDocumentsChanged(targetDocId);
|
||||
}
|
||||
|
||||
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) {
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
@@ -1213,10 +1240,13 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [
|
||||
activeId,
|
||||
isRustFamilyTreeRenderer,
|
||||
resourceSelectionVisibleRowIds,
|
||||
resourceShellRowById,
|
||||
resourceRowById,
|
||||
resourceRows,
|
||||
resourceSelection.focusedRowId,
|
||||
resourceSelection.selectedRowIds,
|
||||
resourceShellVisibleRowIds,
|
||||
sidebarQuery,
|
||||
]);
|
||||
|
||||
@@ -1474,11 +1504,23 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
);
|
||||
|
||||
const handleDeleteResourceSelection = useCallback(async () => {
|
||||
const { docIds, assetIds } = computeTreePaneDeleteTargets({
|
||||
visibleRows: resourceRows,
|
||||
selectedRowIds: resourceSelection.selectedRowIds,
|
||||
parentById: docParentById,
|
||||
});
|
||||
const shellDeleteTargets = isRustFamilyTreeRenderer
|
||||
? computeFileTreeShellDeleteTargets({
|
||||
visibleRowIds: resourceShellVisibleRowIds,
|
||||
rowById: resourceShellRowById,
|
||||
selectedRowIds: resourceSelection.selectedRowIds,
|
||||
parentById: docParentById,
|
||||
})
|
||||
: null;
|
||||
const legacyDeleteTargets = !isRustFamilyTreeRenderer
|
||||
? computeTreePaneDeleteTargets({
|
||||
visibleRows: resourceRows,
|
||||
selectedRowIds: resourceSelection.selectedRowIds,
|
||||
parentById: docParentById,
|
||||
})
|
||||
: null;
|
||||
const docIds = shellDeleteTargets?.docIds ?? legacyDeleteTargets?.docIds ?? [];
|
||||
const assetIds = shellDeleteTargets?.assetIds ?? legacyDeleteTargets?.assetIds ?? [];
|
||||
|
||||
if (docIds.length === 0 && assetIds.length === 0) {
|
||||
return;
|
||||
@@ -1493,14 +1535,16 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
): row is Extract<TreePaneRow, { kind: "asset" | "asset-folder" }> =>
|
||||
row.kind === "asset" || row.kind === "asset-folder";
|
||||
|
||||
const selectedAssetHints = Array.from(
|
||||
new Map(
|
||||
resourceRows
|
||||
.filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
|
||||
.filter(isAssetRow)
|
||||
.map((row) => [row.asset.id, row.asset] as const),
|
||||
).values(),
|
||||
);
|
||||
const selectedAssetHints =
|
||||
shellDeleteTargets?.assetHints ??
|
||||
Array.from(
|
||||
new Map(
|
||||
resourceRows
|
||||
.filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
|
||||
.filter(isAssetRow)
|
||||
.map((row) => [row.asset.id, row.asset] as const),
|
||||
).values(),
|
||||
);
|
||||
|
||||
const mindmapCount = selectedAssetHints.filter((item) => item.asset_type === "mindmap").length;
|
||||
const tableCount = selectedAssetHints.filter((item) => item.asset_type === "luckysheet").length;
|
||||
@@ -1558,12 +1602,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
}, [
|
||||
activeId,
|
||||
docParentById,
|
||||
isRustFamilyTreeRenderer,
|
||||
resourceRows,
|
||||
resourceShellRowById,
|
||||
resourceShellVisibleRowIds,
|
||||
resourceSelection.selectedRowIds,
|
||||
handleDeleteAssets,
|
||||
mediaAssets,
|
||||
mindmapAssets,
|
||||
tableAssets,
|
||||
refreshTree,
|
||||
router,
|
||||
]);
|
||||
@@ -1710,31 +1754,32 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
);
|
||||
|
||||
const handleResourcePaneDropFiles = useCallback(
|
||||
(docId: string, files: FileList, targetRow?: TreePaneRow) => {
|
||||
(payload: {
|
||||
targetDocumentId: string | null;
|
||||
targetRowId: string | null;
|
||||
targetRowKind: string | null;
|
||||
targetAssetId: string | null;
|
||||
files: FileList | File[];
|
||||
}) => {
|
||||
void (async () => {
|
||||
const droppedFiles = Array.from(files ?? []);
|
||||
const droppedFiles = Array.from(payload.files ?? []);
|
||||
if (droppedFiles.length === 0) return;
|
||||
const targetRow =
|
||||
payload.targetRowId
|
||||
? (resourceShellRowById.get(payload.targetRowId) ?? null)
|
||||
: null;
|
||||
|
||||
const targetMindmapId = (() => {
|
||||
if (!targetRow) return null;
|
||||
if (targetRow.kind === "asset-folder" && targetRow.asset.asset_type === "mindmap") {
|
||||
return targetRow.asset.id;
|
||||
}
|
||||
if (targetRow.kind === "asset") {
|
||||
return extractMindmapIdFromStoragePath(targetRow.asset.storage_path);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const targetMindmapId = resolveFileTreeShellMindmapTargetId(targetRow);
|
||||
|
||||
if (targetMindmapId) {
|
||||
setExpandedAssetFolders((prev) => new Set(prev).add(targetMindmapId));
|
||||
}
|
||||
|
||||
const inferredTargetDocId =
|
||||
docId ||
|
||||
inferPasteTargetDocId({
|
||||
payload.targetDocumentId ||
|
||||
inferFileTreeShellTargetDocumentId({
|
||||
focusedRowId: resourceSelection.focusedRowId,
|
||||
rowById: resourceRowById,
|
||||
rowById: resourceShellRowById,
|
||||
activeDocId: activeId || null,
|
||||
}) ||
|
||||
"";
|
||||
@@ -1799,7 +1844,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
[
|
||||
activeId,
|
||||
editorBridge,
|
||||
resourceRowById,
|
||||
resourceShellRowById,
|
||||
resourceSelection.focusedRowId,
|
||||
sidebarData.activeWorkspaceId,
|
||||
sidebarData.documents,
|
||||
@@ -1808,23 +1853,33 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
);
|
||||
|
||||
const handleResourcePaneInternalDrop = useCallback(
|
||||
(args: { targetRow: TreePaneRow; rowIds: string[]; copy: boolean }) => {
|
||||
(payload: {
|
||||
targetDocumentId: string | null;
|
||||
targetRowId: string | null;
|
||||
targetRowKind: string | null;
|
||||
targetAssetId: string | null;
|
||||
rowIds: string[];
|
||||
copy: boolean;
|
||||
}) => {
|
||||
void (async () => {
|
||||
const targetDocId = inferDropTargetDocId(args.targetRow);
|
||||
const targetRow =
|
||||
payload.targetRowId
|
||||
? (resourceShellRowById.get(payload.targetRowId) ?? null)
|
||||
: null;
|
||||
const targetDocId =
|
||||
payload.targetDocumentId ??
|
||||
targetRow?.documentId ??
|
||||
inferFileTreeShellTargetDocumentId({
|
||||
focusedRowId: resourceSelection.focusedRowId,
|
||||
rowById: resourceShellRowById,
|
||||
activeDocId: activeId || null,
|
||||
});
|
||||
if (!targetDocId) {
|
||||
setTimeout(() => window.alert("无法识别拖拽目标页面"), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetMindmapId = (() => {
|
||||
if (args.targetRow.kind === "asset-folder" && args.targetRow.asset.asset_type === "mindmap") {
|
||||
return args.targetRow.asset.id;
|
||||
}
|
||||
if (args.targetRow.kind === "asset") {
|
||||
return extractMindmapIdFromStoragePath(args.targetRow.asset.storage_path);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const targetMindmapId = resolveFileTreeShellMindmapTargetId(targetRow);
|
||||
|
||||
const targetSubPath = targetMindmapId ? `mindmaps/${targetMindmapId}` : undefined;
|
||||
|
||||
@@ -1834,26 +1889,32 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
|
||||
const uniqueRowIds: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
args.rowIds.forEach((id) => {
|
||||
payload.rowIds.forEach((id) => {
|
||||
if (!id || seen.has(id)) return;
|
||||
seen.add(id);
|
||||
uniqueRowIds.push(id);
|
||||
});
|
||||
|
||||
const rows = uniqueRowIds
|
||||
.map((rowId) => resourceRowById.get(rowId as any))
|
||||
.filter(Boolean) as TreePaneRow[];
|
||||
.map((rowId) => resourceShellRowById.get(rowId) ?? null)
|
||||
.filter((row): row is FileTreeShellRow => Boolean(row));
|
||||
|
||||
const docIds = rows.filter((row) => row.kind === "doc").map((row) => row.docId);
|
||||
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);
|
||||
const docIds = rows.filter((row) => row.rowKind === "doc").map((row) => row.documentId);
|
||||
const assetRows = rows.filter(
|
||||
(row): row is FileTreeShellRow & { rowKind: "asset"; asset: MediaAsset } =>
|
||||
row.rowKind === "asset" && Boolean(row.asset),
|
||||
);
|
||||
const copyableAssetIds = assetRows
|
||||
.map((row) => row.asset)
|
||||
.filter((asset) => isRealFileAsset(asset))
|
||||
.map((asset) => asset.id);
|
||||
|
||||
if (docIds.length === 0 && copyableAssetIds.length === 0) {
|
||||
setTimeout(() => window.alert("没有可拖拽的对象(虚拟附件暂不支持)"), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.copy) {
|
||||
if (payload.copy) {
|
||||
if (docIds.length > 0) {
|
||||
try {
|
||||
await copyTreeCommand({
|
||||
@@ -1894,17 +1955,6 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
|
||||
const topLevelDocIds = filterTopLevelDocIds(docIds, docParentById);
|
||||
if (topLevelDocIds.length > 0) {
|
||||
if (
|
||||
isInvalidDocDrop({
|
||||
sourceDocIds: topLevelDocIds,
|
||||
targetParentId: targetDocId,
|
||||
parentById: docParentById,
|
||||
})
|
||||
) {
|
||||
setTimeout(() => window.alert("不能把页面移动到自身或其子页面中"), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseIndex = childrenCountByParentId.get(targetDocId) ?? 0;
|
||||
setTree((prev) => {
|
||||
let next = prev;
|
||||
@@ -1915,12 +1965,19 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
});
|
||||
setExpanded((prev) => new Set(prev).add(targetDocId));
|
||||
|
||||
for (let i = 0; i < topLevelDocIds.length; i += 1) {
|
||||
await moveDocumentCommand({
|
||||
documentId: topLevelDocIds[i],
|
||||
parentId: targetDocId,
|
||||
position: baseIndex + i,
|
||||
});
|
||||
try {
|
||||
for (let i = 0; i < topLevelDocIds.length; i += 1) {
|
||||
await moveDocumentCommand({
|
||||
documentId: topLevelDocIds[i],
|
||||
parentId: targetDocId,
|
||||
position: baseIndex + i,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await refreshTree();
|
||||
const message = error instanceof Error ? error.message : "移动页面失败";
|
||||
setTimeout(() => window.alert(message), 0);
|
||||
return;
|
||||
}
|
||||
await refreshTree();
|
||||
emitDocumentsChanged(targetDocId);
|
||||
@@ -1943,7 +2000,11 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
return;
|
||||
}
|
||||
await sidebarQuery.refetch();
|
||||
const sourceDocIds = new Set(assetRows.map((row) => row.asset.document_id));
|
||||
const sourceDocIds = new Set(
|
||||
assetRows
|
||||
.map((row) => row.asset?.document_id ?? null)
|
||||
.filter((documentId): documentId is string => Boolean(documentId)),
|
||||
);
|
||||
sourceDocIds.forEach((id) => emitAssetsChanged(id));
|
||||
emitAssetsChanged(targetDocId);
|
||||
}
|
||||
@@ -1952,7 +2013,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
[
|
||||
childrenCountByParentId,
|
||||
docParentById,
|
||||
resourceRowById,
|
||||
resourceSelection.focusedRowId,
|
||||
activeId,
|
||||
resourceShellRowById,
|
||||
moveLocalNode,
|
||||
refreshTree,
|
||||
sidebarQuery,
|
||||
@@ -2719,17 +2782,21 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
mode="page"
|
||||
rendererFamily={treeRendererFamily}
|
||||
workspaceId={sidebarData.activeWorkspaceId ?? null}
|
||||
treeShellEnabled={filter.trim().length === 0}
|
||||
treeShellEnabled={isRustFamilyTreeRenderer ? true : filter.trim().length === 0}
|
||||
className="h-full"
|
||||
rows={visibleFilteredPrivatePageRows}
|
||||
rows={isRustFamilyTreeRenderer ? undefined : visibleFilteredPrivatePageRows}
|
||||
treeShellRows={effectivePageTreeShellRows}
|
||||
expanded={expanded}
|
||||
activeId={activeId}
|
||||
focusedDocumentId={pageTreeFocusedDocumentId}
|
||||
onToggleExpand={toggleExpand}
|
||||
onMove={handleMove}
|
||||
onCreateChild={handleCreate}
|
||||
onContextMenu={openContextMenu}
|
||||
onNavigate={handlePageTreeShellNavigate}
|
||||
onPageContextMenu={handlePageTreeShellContextMenu}
|
||||
onPageExpandChange={handlePageTreeShellExpandChange}
|
||||
onPageFocusChange={handlePageTreeShellFocusChange}
|
||||
onTreeMutation={handleTreeShellMutation}
|
||||
/>
|
||||
</div>
|
||||
@@ -2750,9 +2817,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
|
||||
mode="filetree"
|
||||
rendererFamily={treeRendererFamily}
|
||||
workspaceId={sidebarData.activeWorkspaceId ?? null}
|
||||
treeShellEnabled={filter.trim().length === 0}
|
||||
treeShellEnabled={isRustFamilyTreeRenderer ? true : filter.trim().length === 0}
|
||||
className="h-full"
|
||||
rows={resourceRows}
|
||||
rows={isRustFamilyTreeRenderer ? undefined : resourceRows}
|
||||
treeShellItems={effectiveResourceTreeShellItems}
|
||||
activeId={activeId}
|
||||
selectedRowIds={resourceSelection.selectedRowIds}
|
||||
onRowClick={handleResourceRowClick}
|
||||
|
||||
@@ -5,31 +5,62 @@ import {
|
||||
TreeShellIframeHost,
|
||||
type TreeShellPickerItem,
|
||||
} from "@/components/sidebar/tree-shell-iframe-host";
|
||||
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
|
||||
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TreeRendererFamily = "react" | "rust_family";
|
||||
|
||||
export type TreeShellHostMode = "page" | "filetree" | "picker";
|
||||
|
||||
export type TreeShellPickerCommand = {
|
||||
kind: "next" | "previous" | "home" | "end" | "pick";
|
||||
seq: number;
|
||||
};
|
||||
|
||||
export type FileTreeShellInternalDropPayload = {
|
||||
targetDocumentId: string | null;
|
||||
targetRowId: string | null;
|
||||
targetRowKind: string | null;
|
||||
targetAssetId: string | null;
|
||||
rowIds: string[];
|
||||
copy: boolean;
|
||||
};
|
||||
|
||||
export type FileTreeShellExternalDropPayload = {
|
||||
targetDocumentId: string | null;
|
||||
targetRowId: string | null;
|
||||
targetRowKind: string | null;
|
||||
targetAssetId: string | null;
|
||||
files: FileList | File[];
|
||||
};
|
||||
|
||||
type TreeShellHostProps = {
|
||||
mode: TreeShellHostMode;
|
||||
surfaceTestId: string;
|
||||
rendererFamily?: TreeRendererFamily;
|
||||
className?: string;
|
||||
treeShellEnabled?: boolean;
|
||||
fallbackImplementation?: string;
|
||||
workspaceId?: string | null;
|
||||
rootNodeId?: string | null;
|
||||
activeDocumentId?: string | null;
|
||||
focusedDocumentId?: string | null;
|
||||
activePickerItemKey?: string | null;
|
||||
allowRootPick?: boolean;
|
||||
excludeIds?: string[];
|
||||
pickerCommand?: TreeShellPickerCommand | null;
|
||||
pickerItems?: TreeShellPickerItem[];
|
||||
fileTreeRows?: FileTreeRow[];
|
||||
pageTreeItems?: PageTreeProjectionItem[];
|
||||
inlineFileTreeItems?: KernelFileTreeProjectionItem[];
|
||||
channel?: string;
|
||||
host?: string;
|
||||
onNavigate?: (documentId: string) => void;
|
||||
onPick?: (targetId: string | null) => void;
|
||||
onPickerFocusChange?: (payload: { itemKey: string | null; documentId: string | null }) => void;
|
||||
onPageContextMenu?: (payload: { documentId: string; x: number; y: number }) => void;
|
||||
onPageExpandChange?: (payload: { documentId: string | null; expanded: boolean }) => void;
|
||||
onPageFocusChange?: (payload: { documentId: string | null }) => void;
|
||||
onFileTreeContextMenu?: (payload: {
|
||||
documentId: string | null;
|
||||
assetId: string | null;
|
||||
@@ -43,8 +74,8 @@ type TreeShellHostProps = {
|
||||
anchorRowId: string | null;
|
||||
focusedRowId: string | null;
|
||||
}) => void;
|
||||
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
|
||||
onDropFiles?: (docId: string, files: FileList | File[], targetRow?: FileTreeRow) => void;
|
||||
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
|
||||
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
|
||||
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
|
||||
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
|
||||
children: ReactNode;
|
||||
@@ -56,18 +87,26 @@ export function TreeShellHost({
|
||||
rendererFamily = "react",
|
||||
className,
|
||||
treeShellEnabled = true,
|
||||
fallbackImplementation,
|
||||
workspaceId = null,
|
||||
rootNodeId = null,
|
||||
activeDocumentId = null,
|
||||
focusedDocumentId = null,
|
||||
activePickerItemKey = null,
|
||||
allowRootPick = false,
|
||||
excludeIds = [],
|
||||
pickerItems = [],
|
||||
fileTreeRows = [],
|
||||
pickerCommand = null,
|
||||
pickerItems,
|
||||
pageTreeItems,
|
||||
inlineFileTreeItems,
|
||||
channel,
|
||||
host,
|
||||
onNavigate,
|
||||
onPick,
|
||||
onPickerFocusChange,
|
||||
onPageContextMenu,
|
||||
onPageExpandChange,
|
||||
onPageFocusChange,
|
||||
onFileTreeContextMenu,
|
||||
onFileTreeSelectionChange,
|
||||
onInternalDrop,
|
||||
@@ -77,13 +116,12 @@ export function TreeShellHost({
|
||||
children,
|
||||
}: TreeShellHostProps) {
|
||||
const useRustHost = rendererFamily === "rust_family";
|
||||
const useIframeHost = useRustHost && treeShellEnabled && Boolean(workspaceId?.trim());
|
||||
const useIframeHost = useRustHost && Boolean(workspaceId?.trim());
|
||||
const hostKind = rendererFamily === "rust_family" ? "rust_family" : "react";
|
||||
const implementation = useIframeHost
|
||||
? "mnote_web_iframe_proxy"
|
||||
: rendererFamily === "rust_family"
|
||||
? "react_fallback"
|
||||
: "react_primary";
|
||||
: fallbackImplementation ??
|
||||
(rendererFamily === "rust_family" ? "react_fallback" : "react_primary");
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -92,6 +130,7 @@ export function TreeShellHost({
|
||||
data-renderer-family={rendererFamily}
|
||||
data-tree-host-kind={hostKind}
|
||||
data-tree-host-implementation={implementation}
|
||||
data-page-tree-focused-id={mode === "page" ? (focusedDocumentId ?? "") : undefined}
|
||||
className={cn(className)}
|
||||
>
|
||||
{useRustHost ? (
|
||||
@@ -109,15 +148,22 @@ export function TreeShellHost({
|
||||
workspaceId={workspaceId}
|
||||
rootNodeId={rootNodeId}
|
||||
activeDocumentId={activeDocumentId}
|
||||
focusedDocumentId={focusedDocumentId}
|
||||
activePickerItemKey={activePickerItemKey}
|
||||
allowRootPick={allowRootPick}
|
||||
excludeIds={excludeIds}
|
||||
pickerCommand={pickerCommand}
|
||||
pickerItems={pickerItems}
|
||||
fileTreeRows={fileTreeRows}
|
||||
pageTreeItems={pageTreeItems}
|
||||
inlineFileTreeItems={inlineFileTreeItems}
|
||||
channel={channel}
|
||||
host={host}
|
||||
onNavigate={onNavigate}
|
||||
onPick={onPick}
|
||||
onPickerFocusChange={onPickerFocusChange}
|
||||
onPageContextMenu={onPageContextMenu}
|
||||
onPageExpandChange={onPageExpandChange}
|
||||
onPageFocusChange={onPageFocusChange}
|
||||
onFileTreeContextMenu={onFileTreeContextMenu}
|
||||
onFileTreeSelectionChange={onFileTreeSelectionChange}
|
||||
onInternalDrop={onInternalDrop}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
|
||||
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import {
|
||||
TreeShellIframeHost,
|
||||
type TreeShellPickerItem,
|
||||
buildTreeShellInlineKernelFileTreeItems,
|
||||
buildTreeShellInlinePageItems,
|
||||
buildTreeShellIframeSrc,
|
||||
buildTreeShellInlinePickerItems,
|
||||
injectTreeShellInlineOverrides,
|
||||
@@ -24,6 +31,7 @@ describe("tree-shell-iframe-host", () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
@@ -32,6 +40,8 @@ describe("tree-shell-iframe-host", () => {
|
||||
mode: "picker",
|
||||
workspaceId: "ws_picker",
|
||||
activeDocumentId: "doc_active",
|
||||
focusedDocumentId: "doc_focus",
|
||||
activePickerItemKey: "__root__",
|
||||
allowRootPick: true,
|
||||
excludeIds: ["doc_hidden", "doc_other"],
|
||||
channel: "tree-picker-surface",
|
||||
@@ -43,6 +53,8 @@ describe("tree-shell-iframe-host", () => {
|
||||
expect(url.searchParams.get("workspaceId")).toBe("ws_picker");
|
||||
expect(url.searchParams.get("mode")).toBe("picker");
|
||||
expect(url.searchParams.get("activeDocumentId")).toBe("doc_active");
|
||||
expect(url.searchParams.get("focusedDocumentId")).toBe("doc_focus");
|
||||
expect(url.searchParams.get("activePickerItemKey")).toBe("__root__");
|
||||
expect(url.searchParams.get("allowRootPick")).toBe("1");
|
||||
expect(url.searchParams.get("excludeIds")).toBe("doc_hidden,doc_other");
|
||||
expect(url.searchParams.get("channel")).toBe("tree-picker-surface");
|
||||
@@ -85,9 +97,409 @@ describe("tree-shell-iframe-host", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("page tree 提供 inline items 时应直接生成 srcDoc,不再 fetch /api/tree/shell", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
|
||||
{ status: 200, headers: { "content-type": "text/html" } },
|
||||
),
|
||||
);
|
||||
const pageItems: PageTreeProjectionItem[] = [
|
||||
{
|
||||
rowId: "page:doc_parent",
|
||||
nodeId: "doc_parent",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
projectionKind: "page_tree",
|
||||
depth: 0,
|
||||
position: 0,
|
||||
title: "父页面",
|
||||
childCount: 1,
|
||||
expandable: true,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["expand", "open"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_parent",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
node: {
|
||||
id: "doc_parent",
|
||||
title: "父页面",
|
||||
workspace_id: "ws_1",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_archived: false,
|
||||
is_deleted: false,
|
||||
is_published: false,
|
||||
is_starred: false,
|
||||
access_scope: "private",
|
||||
created_at: "2026-04-24T00:00:00.000Z",
|
||||
updated_at: "2026-04-24T00:00:00.000Z",
|
||||
children: [],
|
||||
kernel: {
|
||||
nodeType: "page",
|
||||
depth: 0,
|
||||
position: 0,
|
||||
childCount: 1,
|
||||
expandedByDefault: false,
|
||||
},
|
||||
} as unknown as SidebarTreeNode,
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildTreeShellInlinePageItems(pageItems, new Set(["doc_parent"]))).toEqual([
|
||||
expect.objectContaining({
|
||||
nodeId: "doc_parent",
|
||||
parentNodeId: null,
|
||||
title: "父页面",
|
||||
childCount: 1,
|
||||
expandedByDefault: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TreeShellIframeHost
|
||||
mode="page"
|
||||
surfaceTestId="sidebar-page-tree-shell"
|
||||
workspaceId="ws_1"
|
||||
activeDocumentId="doc_parent"
|
||||
pageTreeItems={pageItems}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("父页面");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-menu");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-create");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("tree-action-rename");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("/api/tree/commands");
|
||||
expect(iframe?.getAttribute("srcdoc")).not.toContain("\n");
|
||||
});
|
||||
|
||||
it("SSR 时 inline tree shell 应先输出稳定 loading srcDoc,避免大模板属性水合不一致", () => {
|
||||
const pageItems: PageTreeProjectionItem[] = [
|
||||
{
|
||||
rowId: "page:doc_parent",
|
||||
nodeId: "doc_parent",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
projectionKind: "page_tree",
|
||||
depth: 0,
|
||||
position: 0,
|
||||
title: "父页面",
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["open"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_parent",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
node: {} as SidebarTreeNode,
|
||||
},
|
||||
];
|
||||
|
||||
const html = renderToString(
|
||||
<TreeShellIframeHost
|
||||
mode="page"
|
||||
surfaceTestId="sidebar-page-tree-shell"
|
||||
workspaceId="ws_1"
|
||||
activeDocumentId="doc_parent"
|
||||
pageTreeItems={pageItems}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Tree Shell Loading");
|
||||
expect(html).not.toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
|
||||
expect(html).not.toContain("父页面");
|
||||
});
|
||||
|
||||
it("page tree 提供空 inline items 时也应直接生成 srcDoc", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
|
||||
{ status: 200, headers: { "content-type": "text/html" } },
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TreeShellIframeHost
|
||||
mode="page"
|
||||
surfaceTestId="sidebar-page-tree-shell"
|
||||
workspaceId="ws_1"
|
||||
activeDocumentId={null}
|
||||
pageTreeItems={[]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const iframe = container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
|
||||
});
|
||||
|
||||
it("file tree 提供应直接消费的 kernel items 时,应本地生成 shell 并注入正式 item contract", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
|
||||
{ status: 200, headers: { "content-type": "text/html" } },
|
||||
),
|
||||
);
|
||||
const fileTreeItems: KernelFileTreeProjectionItem[] = [
|
||||
{
|
||||
rowId: "doc:doc_a",
|
||||
rowKind: "document",
|
||||
nodeId: "doc_a",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
projectionKind: "file_tree",
|
||||
title: "文档 A",
|
||||
depth: 0,
|
||||
position: 0,
|
||||
childCount: 2,
|
||||
expandable: true,
|
||||
expandedByDefault: true,
|
||||
capabilities: ["expand", "open", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_a",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
{
|
||||
rowId: "asset:asset_pdf",
|
||||
rowKind: "asset",
|
||||
nodeId: "asset:asset_pdf",
|
||||
parentNodeId: "doc_a",
|
||||
nodeType: "pdf",
|
||||
projectionKind: "file_tree",
|
||||
title: "guide.pdf",
|
||||
depth: 1,
|
||||
position: 1,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["open-asset", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "pdf",
|
||||
documentId: "doc_a",
|
||||
assetId: "asset_pdf",
|
||||
workspaceId: "ws_1",
|
||||
assetKind: "pdf",
|
||||
iconHint: "pdf",
|
||||
},
|
||||
iconHint: "pdf",
|
||||
},
|
||||
];
|
||||
|
||||
expect(buildTreeShellInlineKernelFileTreeItems(fileTreeItems)).toEqual([
|
||||
expect.objectContaining({
|
||||
nodeId: "doc_a",
|
||||
rowKind: "document",
|
||||
iconHint: "page",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
nodeId: "asset:asset_pdf",
|
||||
rowKind: "asset",
|
||||
iconHint: "pdf",
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TreeShellIframeHost
|
||||
mode="filetree"
|
||||
surfaceTestId="sidebar-file-tree-shell"
|
||||
workspaceId="ws_1"
|
||||
activeDocumentId="doc_a"
|
||||
inlineFileTreeItems={fileTreeItems}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const iframe = container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(iframe?.getAttribute("data-tree-shell-inline")).toBe("1");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("__MNOTE_TREE_SHELL_OVERRIDE__");
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"rowKind":"asset"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("guide.pdf");
|
||||
expect(iframe?.getAttribute("srcdoc")).not.toContain("\n");
|
||||
});
|
||||
|
||||
it("picker inline override 在高亮变化时应复用 bootstrap 文档,并通过 postMessage 同步状态", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
|
||||
{ status: 200, headers: { "content-type": "text/html" } },
|
||||
),
|
||||
);
|
||||
const pickerItems: TreeShellPickerItem[] = [{ kind: "doc", id: "doc_1", title: "页面 1", depth: 0 }];
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TreeShellIframeHost
|
||||
mode="picker"
|
||||
surfaceTestId="tree-picker-surface"
|
||||
workspaceId="ws_1"
|
||||
activeDocumentId="doc_1"
|
||||
activePickerItemKey="doc_1"
|
||||
pickerItems={pickerItems}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
||||
const postMessage = vi.fn();
|
||||
Object.defineProperty(iframe, "contentWindow", {
|
||||
configurable: true,
|
||||
value: { postMessage },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TreeShellIframeHost
|
||||
mode="picker"
|
||||
surfaceTestId="tree-picker-surface"
|
||||
workspaceId="ws_1"
|
||||
activeDocumentId="doc_2"
|
||||
activePickerItemKey="doc_2"
|
||||
pickerItems={pickerItems}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "tree-picker-surface",
|
||||
type: "tree.shell.state.patch",
|
||||
activeDocumentId: "doc_2",
|
||||
activePickerItemKey: "doc_2",
|
||||
}),
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
it("picker 宿主应向 iframe 下发键盘命令,并接回焦点变化事件", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
'<!doctype html><html><body><script id="tree-shell-state" type="application/json">{}</script></body></html>',
|
||||
{ status: 200, headers: { "content-type": "text/html" } },
|
||||
),
|
||||
);
|
||||
const onPickerFocusChange = vi.fn();
|
||||
const pickerItems: TreeShellPickerItem[] = [
|
||||
{ kind: "doc", id: "doc_1", title: "页面 1", depth: 0 },
|
||||
{ kind: "doc", id: "doc_2", title: "页面 2", depth: 0 },
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TreeShellIframeHost
|
||||
mode="picker"
|
||||
surfaceTestId="tree-picker-surface"
|
||||
workspaceId="ws_1"
|
||||
activeDocumentId="doc_1"
|
||||
activePickerItemKey="doc_1"
|
||||
pickerItems={pickerItems}
|
||||
onPickerFocusChange={onPickerFocusChange}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
||||
const postMessage = vi.fn();
|
||||
Object.defineProperty(iframe, "contentWindow", {
|
||||
configurable: true,
|
||||
value: window,
|
||||
});
|
||||
vi.spyOn(window, "postMessage").mockImplementation(postMessage);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TreeShellIframeHost
|
||||
mode="picker"
|
||||
surfaceTestId="tree-picker-surface"
|
||||
workspaceId="ws_1"
|
||||
activeDocumentId="doc_1"
|
||||
activePickerItemKey="doc_1"
|
||||
pickerItems={pickerItems}
|
||||
pickerCommand={{ kind: "next", seq: 1 }}
|
||||
onPickerFocusChange={onPickerFocusChange}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "tree-picker-surface",
|
||||
type: "tree.picker.command",
|
||||
command: "next",
|
||||
}),
|
||||
"*",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
channel: "tree-picker-surface",
|
||||
type: "tree.picker.focus.changed",
|
||||
documentId: "doc_2",
|
||||
itemKey: "doc_2",
|
||||
},
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onPickerFocusChange).toHaveBeenCalledWith({
|
||||
documentId: "doc_2",
|
||||
itemKey: "doc_2",
|
||||
});
|
||||
});
|
||||
|
||||
it("应把 iframe postMessage 桥接回宿主回调,并忽略错误 channel", async () => {
|
||||
const onNavigate = vi.fn();
|
||||
const onPageContextMenu = vi.fn();
|
||||
const onPageExpandChange = vi.fn();
|
||||
const onPageFocusChange = vi.fn();
|
||||
const onPick = vi.fn();
|
||||
const onFileTreeContextMenu = vi.fn();
|
||||
const onFileTreeSelectionChange = vi.fn();
|
||||
@@ -95,20 +507,6 @@ describe("tree-shell-iframe-host", () => {
|
||||
const onTreeMutation = vi.fn();
|
||||
const onInternalDrop = vi.fn();
|
||||
const onDropFiles = vi.fn();
|
||||
const targetRow = {
|
||||
kind: "doc",
|
||||
rowId: "doc:doc_target",
|
||||
depth: 0,
|
||||
docId: "doc_target",
|
||||
parentDocId: null,
|
||||
node: {
|
||||
_id: "doc_target",
|
||||
id: "doc_target",
|
||||
title: "目标页面",
|
||||
},
|
||||
hasChildren: false,
|
||||
isExpanded: false,
|
||||
};
|
||||
const droppedFile = new File(["hello"], "hello.txt", { type: "text/plain" });
|
||||
|
||||
await act(async () => {
|
||||
@@ -122,6 +520,8 @@ describe("tree-shell-iframe-host", () => {
|
||||
host="sidebar-file-tree-shell"
|
||||
onNavigate={onNavigate}
|
||||
onPageContextMenu={onPageContextMenu}
|
||||
onPageExpandChange={onPageExpandChange}
|
||||
onPageFocusChange={onPageFocusChange}
|
||||
onPick={onPick}
|
||||
onFileTreeContextMenu={onFileTreeContextMenu}
|
||||
onFileTreeSelectionChange={onFileTreeSelectionChange}
|
||||
@@ -178,6 +578,27 @@ describe("tree-shell-iframe-host", () => {
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
channel: "sidebar-file-tree-shell",
|
||||
type: "tree.page.expand.changed",
|
||||
documentId: "doc_2",
|
||||
expanded: true,
|
||||
},
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
channel: "sidebar-file-tree-shell",
|
||||
type: "tree.page.focus.changed",
|
||||
documentId: "doc_3",
|
||||
},
|
||||
source: window,
|
||||
}),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
@@ -244,6 +665,13 @@ describe("tree-shell-iframe-host", () => {
|
||||
x: 12,
|
||||
y: 34,
|
||||
});
|
||||
expect(onPageExpandChange).toHaveBeenCalledWith({
|
||||
documentId: "doc_2",
|
||||
expanded: true,
|
||||
});
|
||||
expect(onPageFocusChange).toHaveBeenCalledWith({
|
||||
documentId: "doc_3",
|
||||
});
|
||||
expect(onPick).toHaveBeenCalledWith(null);
|
||||
expect(onFileTreeContextMenu).toHaveBeenCalledWith({
|
||||
documentId: "doc_2",
|
||||
@@ -275,7 +703,9 @@ describe("tree-shell-iframe-host", () => {
|
||||
type: "tree.filetree.internal-drop",
|
||||
rowIds: ["doc:doc_source", "asset:asset_source"],
|
||||
copy: true,
|
||||
targetRow,
|
||||
rowId: "doc:doc_target",
|
||||
rowKind: "doc",
|
||||
documentId: "doc_target",
|
||||
},
|
||||
source: window,
|
||||
}),
|
||||
@@ -286,7 +716,8 @@ describe("tree-shell-iframe-host", () => {
|
||||
channel: "sidebar-file-tree-shell",
|
||||
type: "tree.filetree.drop-files",
|
||||
documentId: "doc_target",
|
||||
targetRow,
|
||||
rowId: "doc:doc_target",
|
||||
rowKind: "doc",
|
||||
files: [droppedFile],
|
||||
},
|
||||
source: window,
|
||||
@@ -295,10 +726,19 @@ describe("tree-shell-iframe-host", () => {
|
||||
});
|
||||
|
||||
expect(onInternalDrop).toHaveBeenCalledWith({
|
||||
targetRow,
|
||||
targetDocumentId: "doc_target",
|
||||
targetRowId: "doc:doc_target",
|
||||
targetRowKind: "doc",
|
||||
targetAssetId: null,
|
||||
rowIds: ["doc:doc_source", "asset:asset_source"],
|
||||
copy: true,
|
||||
});
|
||||
expect(onDropFiles).toHaveBeenCalledWith("doc_target", [droppedFile], targetRow);
|
||||
expect(onDropFiles).toHaveBeenCalledWith({
|
||||
targetDocumentId: "doc_target",
|
||||
targetRowId: "doc:doc_target",
|
||||
targetRowKind: "doc",
|
||||
targetAssetId: null,
|
||||
files: [droppedFile],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,14 +5,6 @@ import { SidebarTreeSurface, TreePickerSurface, type TreeRendererFamily } from "
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
vi.mock("@/components/sidebar/private-tree", () => ({
|
||||
PrivateTree: () => <div data-testid="private-tree-fallback" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/sidebar/file-tree", () => ({
|
||||
FileTree: () => <div data-testid="file-tree-fallback" />,
|
||||
}));
|
||||
|
||||
describe("tree-shell-surface", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
@@ -30,7 +22,7 @@ describe("tree-shell-surface", () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function renderPageSurface(rendererFamily: TreeRendererFamily) {
|
||||
function renderPageSurface(rendererFamily: TreeRendererFamily, focusedDocumentId?: string) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarTreeSurface
|
||||
@@ -41,6 +33,7 @@ describe("tree-shell-surface", () => {
|
||||
rows={[]}
|
||||
expanded={new Set<string>()}
|
||||
activeId=""
|
||||
focusedDocumentId={focusedDocumentId}
|
||||
onToggleExpand={() => undefined}
|
||||
onMove={() => undefined}
|
||||
onCreateChild={() => undefined}
|
||||
@@ -64,11 +57,21 @@ describe("tree-shell-surface", () => {
|
||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||
expect(rustHost).not.toBeNull();
|
||||
expect(iframe).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="private-tree-fallback"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("page tree 在禁用 tree shell 时应安全回退到 React fallback", () => {
|
||||
act(() => {
|
||||
it("page tree surface 在 rust_family 下应把 focusedDocumentId 透传到 iframe", () => {
|
||||
renderPageSurface("rust_family", "doc_focus");
|
||||
|
||||
const iframe = container.querySelector(
|
||||
'[data-testid="sidebar-page-tree-shell-rust-iframe"]',
|
||||
) as HTMLIFrameElement | null;
|
||||
|
||||
expect(iframe?.getAttribute("src")).toContain("focusedDocumentId=doc_focus");
|
||||
});
|
||||
|
||||
it("page tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarTreeSurface
|
||||
mode="page"
|
||||
@@ -87,8 +90,33 @@ describe("tree-shell-surface", () => {
|
||||
});
|
||||
|
||||
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
|
||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("react_fallback");
|
||||
expect(container.querySelector('[data-testid="private-tree-fallback"]')).not.toBeNull();
|
||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||
expect(container.querySelector('[data-testid="sidebar-page-tree-shell-rust-iframe"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("page tree 在缺少 workspaceId 时应显示 Rust 宿主占位,而不是回退旧 React renderer", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarTreeSurface
|
||||
mode="page"
|
||||
rendererFamily="rust_family"
|
||||
workspaceId={null}
|
||||
treeShellEnabled
|
||||
rows={[]}
|
||||
expanded={new Set<string>()}
|
||||
activeId=""
|
||||
onToggleExpand={() => undefined}
|
||||
onMove={() => undefined}
|
||||
onCreateChild={() => undefined}
|
||||
onContextMenu={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const surface = container.querySelector('[data-testid="sidebar-page-tree-shell"]');
|
||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("page_tree_renderer_removed");
|
||||
expect(container.querySelector('[data-testid="page-tree-renderer-removed"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("file tree surface 在 rust_family 下也应走同一 host 选择契约", () => {
|
||||
@@ -122,26 +150,63 @@ describe("tree-shell-surface", () => {
|
||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||
expect(rustHost).not.toBeNull();
|
||||
expect(iframe).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="file-tree-fallback"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("file tree 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarTreeSurface
|
||||
mode="filetree"
|
||||
rendererFamily="rust_family"
|
||||
workspaceId="ws_1"
|
||||
treeShellEnabled={false}
|
||||
rows={[]}
|
||||
activeId=""
|
||||
selectedRowIds={new Set<string>()}
|
||||
onRowClick={() => undefined}
|
||||
onRowDoubleClick={() => undefined}
|
||||
onRowContextMenu={() => undefined}
|
||||
onToggleExpand={() => undefined}
|
||||
onCreateChild={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const surface = container.querySelector('[data-testid="sidebar-file-tree-shell"]');
|
||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||
expect(container.querySelector('[data-testid="sidebar-file-tree-shell-rust-iframe"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("file tree 在缺少 workspaceId 时应显示 Rust 宿主占位,而不是回退旧 React renderer", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<SidebarTreeSurface
|
||||
mode="filetree"
|
||||
rendererFamily="rust_family"
|
||||
workspaceId={null}
|
||||
treeShellEnabled
|
||||
rows={[]}
|
||||
activeId=""
|
||||
selectedRowIds={new Set<string>()}
|
||||
onRowClick={() => undefined}
|
||||
onRowDoubleClick={() => undefined}
|
||||
onRowContextMenu={() => undefined}
|
||||
onToggleExpand={() => undefined}
|
||||
onCreateChild={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const surface = container.querySelector('[data-testid="sidebar-file-tree-shell"]');
|
||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("filetree_renderer_removed");
|
||||
expect(container.querySelector('[data-testid="file-tree-renderer-removed"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("file tree surface 应把 iframe 内部拖放与外部文件拖放桥接回宿主回调", async () => {
|
||||
const onInternalDrop = vi.fn();
|
||||
const onDropFiles = vi.fn();
|
||||
const targetRow = {
|
||||
kind: "doc",
|
||||
rowId: "doc:doc_target",
|
||||
depth: 0,
|
||||
docId: "doc_target",
|
||||
parentDocId: null,
|
||||
node: {
|
||||
_id: "doc_target",
|
||||
id: "doc_target",
|
||||
title: "目标页面",
|
||||
},
|
||||
hasChildren: false,
|
||||
isExpanded: false,
|
||||
};
|
||||
const droppedFile = new File(["bridge"], "bridge.txt", { type: "text/plain" });
|
||||
|
||||
await act(async () => {
|
||||
@@ -151,7 +216,7 @@ describe("tree-shell-surface", () => {
|
||||
rendererFamily="rust_family"
|
||||
workspaceId="ws_1"
|
||||
treeShellEnabled
|
||||
rows={[targetRow]}
|
||||
rows={[]}
|
||||
activeId=""
|
||||
selectedRowIds={new Set<string>()}
|
||||
onRowClick={() => undefined}
|
||||
@@ -182,7 +247,9 @@ describe("tree-shell-surface", () => {
|
||||
type: "tree.filetree.internal-drop",
|
||||
rowIds: ["doc:doc_source"],
|
||||
copy: false,
|
||||
targetRow,
|
||||
rowId: "doc:doc_target",
|
||||
rowKind: "doc",
|
||||
documentId: "doc_target",
|
||||
},
|
||||
source: window,
|
||||
}),
|
||||
@@ -193,7 +260,8 @@ describe("tree-shell-surface", () => {
|
||||
channel: "sidebar-file-tree-shell",
|
||||
type: "tree.filetree.external-drop",
|
||||
documentId: "doc_target",
|
||||
targetRow,
|
||||
rowId: "doc:doc_target",
|
||||
rowKind: "doc",
|
||||
files: [droppedFile],
|
||||
},
|
||||
source: window,
|
||||
@@ -202,11 +270,20 @@ describe("tree-shell-surface", () => {
|
||||
});
|
||||
|
||||
expect(onInternalDrop).toHaveBeenCalledWith({
|
||||
targetRow,
|
||||
targetDocumentId: "doc_target",
|
||||
targetRowId: "doc:doc_target",
|
||||
targetRowKind: "doc",
|
||||
targetAssetId: null,
|
||||
rowIds: ["doc:doc_source"],
|
||||
copy: false,
|
||||
});
|
||||
expect(onDropFiles).toHaveBeenCalledWith("doc_target", [droppedFile], targetRow);
|
||||
expect(onDropFiles).toHaveBeenCalledWith({
|
||||
targetDocumentId: "doc_target",
|
||||
targetRowId: "doc:doc_target",
|
||||
targetRowKind: "doc",
|
||||
targetAssetId: null,
|
||||
files: [droppedFile],
|
||||
});
|
||||
});
|
||||
|
||||
it("picker surface 在 rust_family 下也应挂到同一 host 边界", async () => {
|
||||
@@ -238,8 +315,8 @@ describe("tree-shell-surface", () => {
|
||||
expect(onPick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("picker 在 rust_family 但 tree shell 不可用时仍应保留 React fallback", () => {
|
||||
act(() => {
|
||||
it("picker 在 rust_family 下即使 tree shellEnabled=false 也应继续使用 iframe host", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TreePickerSurface
|
||||
rendererFamily="rust_family"
|
||||
@@ -254,13 +331,17 @@ describe("tree-shell-surface", () => {
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const surface = container.querySelector('[data-testid="tree-picker-surface"]');
|
||||
const rustHost = container.querySelector('[data-testid="tree-picker-surface-rust-host"]');
|
||||
const row = container.querySelector('[data-testid="tree-picker-row"]');
|
||||
const iframe = container.querySelector('[data-testid="tree-picker-surface-rust-iframe"]');
|
||||
|
||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("react_fallback");
|
||||
expect(surface?.getAttribute("data-tree-host-implementation")).toBe("mnote_web_iframe_proxy");
|
||||
expect(surface?.getAttribute("data-tree-host-kind")).toBe("rust_family");
|
||||
expect(rustHost).not.toBeNull();
|
||||
expect(row).not.toBeNull();
|
||||
expect(iframe).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import type { DragEvent, MouseEvent } from "react";
|
||||
import { FileTree } from "@/components/sidebar/file-tree";
|
||||
import { PrivateTree } from "@/components/sidebar/private-tree";
|
||||
import { TreeShellHost, type TreeRendererFamily } from "@/components/sidebar/tree-shell-host";
|
||||
import {
|
||||
TreeShellHost,
|
||||
type FileTreeShellExternalDropPayload,
|
||||
type FileTreeShellInternalDropPayload,
|
||||
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";
|
||||
@@ -16,9 +21,11 @@ type SidebarPageTreeSurfaceProps = {
|
||||
rendererFamily?: TreeRendererFamily;
|
||||
workspaceId: string | null;
|
||||
treeShellEnabled?: boolean;
|
||||
rows: PageTreeProjectionItem[];
|
||||
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;
|
||||
@@ -26,6 +33,8 @@ type SidebarPageTreeSurfaceProps = {
|
||||
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: { type: string; documentId: string | null }) => void;
|
||||
};
|
||||
|
||||
@@ -34,7 +43,8 @@ type SidebarFileTreeSurfaceProps = {
|
||||
rendererFamily?: TreeRendererFamily;
|
||||
workspaceId: string | null;
|
||||
treeShellEnabled?: boolean;
|
||||
rows: FileTreeRow[];
|
||||
rows?: FileTreeRow[];
|
||||
treeShellItems?: KernelFileTreeProjectionItem[];
|
||||
activeId: string;
|
||||
selectedRowIds: Set<string>;
|
||||
className?: string;
|
||||
@@ -46,8 +56,8 @@ type SidebarFileTreeSurfaceProps = {
|
||||
onToggleAssetFolderExpand?: (assetId: string) => void;
|
||||
onCreateChild: (parentId: string | null) => void;
|
||||
onBlankMouseDown?: (event: MouseEvent) => void;
|
||||
onDropFiles?: (docId: string, files: FileList | File[], targetRow?: FileTreeRow) => void;
|
||||
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
|
||||
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
|
||||
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
|
||||
onNavigate?: (documentId: string) => void;
|
||||
onFileTreeContextMenu?: (payload: {
|
||||
documentId: string | null;
|
||||
@@ -79,8 +89,10 @@ type TreePickerSurfaceProps = {
|
||||
workspaceId: string | null;
|
||||
treeShellEnabled?: boolean;
|
||||
activeDocumentId?: string | null;
|
||||
activePickerItemKey?: string | null;
|
||||
allowRootPick?: boolean;
|
||||
excludeIds?: string[];
|
||||
pickerCommand?: TreeShellPickerCommand | null;
|
||||
treeShellItems?: TreePickerSurfaceItem[];
|
||||
items: TreePickerSurfaceItem[];
|
||||
highlighted: number;
|
||||
@@ -88,6 +100,7 @@ type TreePickerSurfaceProps = {
|
||||
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) {
|
||||
@@ -96,33 +109,27 @@ export function SidebarTreeSurface(props: SidebarTreeSurfaceProps) {
|
||||
? "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" ? (
|
||||
<PrivateTree
|
||||
rows={props.rows}
|
||||
expanded={props.expanded}
|
||||
activeId={props.activeId}
|
||||
onToggleExpand={props.onToggleExpand}
|
||||
onMove={props.onMove}
|
||||
onCreateChild={props.onCreateChild}
|
||||
onContextMenu={props.onContextMenu}
|
||||
/>
|
||||
pageTreeFallback
|
||||
) : (
|
||||
<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}
|
||||
/>
|
||||
fileTreeFallback
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -131,11 +138,20 @@ export function SidebarTreeSurface(props: SidebarTreeSurfaceProps) {
|
||||
surfaceTestId={surfaceTestId}
|
||||
rendererFamily={rendererFamily}
|
||||
treeShellEnabled={props.treeShellEnabled}
|
||||
fallbackImplementation={
|
||||
props.mode === "page"
|
||||
? "page_tree_renderer_removed"
|
||||
: "filetree_renderer_removed"
|
||||
}
|
||||
workspaceId={props.workspaceId}
|
||||
activeDocumentId={props.activeId}
|
||||
fileTreeRows={props.mode === "filetree" ? props.rows : undefined}
|
||||
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}
|
||||
onInternalDrop={props.mode === "filetree" ? props.onInternalDrop : undefined}
|
||||
@@ -157,8 +173,10 @@ export function TreePickerSurface({
|
||||
workspaceId,
|
||||
treeShellEnabled = true,
|
||||
activeDocumentId = null,
|
||||
activePickerItemKey = null,
|
||||
allowRootPick = false,
|
||||
excludeIds = [],
|
||||
pickerCommand = null,
|
||||
treeShellItems,
|
||||
items,
|
||||
highlighted,
|
||||
@@ -166,8 +184,11 @@ export function TreePickerSurface({
|
||||
emptyText = "没有匹配结果",
|
||||
onHighlight,
|
||||
onPick,
|
||||
onPickerFocusChange,
|
||||
}: TreePickerSurfaceProps) {
|
||||
const hasItems = items.length > 0;
|
||||
const effectiveTreeShellItems =
|
||||
rendererFamily === "rust_family" ? (treeShellItems ?? items) : treeShellItems;
|
||||
|
||||
return (
|
||||
<TreeShellHost
|
||||
@@ -177,10 +198,13 @@ export function TreePickerSurface({
|
||||
treeShellEnabled={treeShellEnabled}
|
||||
workspaceId={workspaceId}
|
||||
activeDocumentId={activeDocumentId}
|
||||
activePickerItemKey={activePickerItemKey}
|
||||
allowRootPick={allowRootPick}
|
||||
excludeIds={excludeIds}
|
||||
pickerItems={treeShellItems}
|
||||
pickerCommand={pickerCommand}
|
||||
pickerItems={effectiveTreeShellItems}
|
||||
onPick={onPick}
|
||||
onPickerFocusChange={onPickerFocusChange}
|
||||
className={cn(hasItems ? "py-2" : null, className)}
|
||||
>
|
||||
{!hasItems ? (
|
||||
|
||||
@@ -2,20 +2,25 @@ import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
|
||||
Reference in New Issue
Block a user