it("upload 应通过统一资源 command client 构造 FormData transport", async () => {
    const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
      ok: true,
      json: async () => ({ asset: { id: "asset_upload_1" } }),
    } as Response);
    const file = new File(["content"], "demo.pdf", { type: "application/pdf" });

    await uploadFileTreeResourceAsset({
      file,
      workspaceId: "ws_1",
      documentId: "doc_1",
      mindmapId: "mind_1",
    });

    expect(fetchMock).toHaveBeenCalledWith(
      "/api/media/upload",
      expect.objectContaining({
        method: "POST",
        body: expect.any(FormData),
      }),
    );
    const body = fetchMock.mock.calls[0]?.[1]?.body as FormData;
    expect(body.get("file")).toBe(file);
    expect(body.get("workspaceId")).toBe("ws_1");
    expect(body.get("documentId")).toBe("doc_1");
    expect(body.get("mindmapId")).toBe("mind_1");
  });

  it("internal drop preflight 应发送到 tree filetree drop route 并返回 Rust plan", async () => {
    const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
      ok: true,
      json: async () => ({
        plan: {
          copy: false,
          targetDocumentId: "doc_target",
          targetMindmapId: null,
          targetSubPath: null,
          rowIds: ["doc:doc_1"],
          docIds: ["doc_1"],
          topLevelDocIds: ["doc_1"],
          copyableAssetIds: [],
          sourceAssetDocumentIds: [],
          documentTransferPlan: {
            action: "move",
            targetParentId: "doc_target",
            documentIds: ["doc_1"],
            topLevelDocumentIds: ["doc_1"],
            copyItems: [{ documentId: "doc_1", recursive: true }],
          },
          resourceTransferPlan: null,
        },
      }),
    } as Response);

    const plan = await preflightFileTreeInternalDrop({
      workspaceId: "ws_1",
      copy: false,
      targetDocumentId: "doc_target",
      targetRowId: null,
      focusedRowId: null,


},
          resourceTransferPlan: null,
        },
      }),
    } as Response);

    const plan = await preflightFileTreeInternalDrop({
      workspaceId: "ws_1",
      copy: false,
      targetDocumentId: "doc_target",
      targetRowId: null,
      focusedRowId: null,
      activeDocumentId: null,
      rowIds: ["doc:doc_1"],
      rows: [],
      documentParents: [],
    });

    expect(plan.topLevelDocIds).toEqual(["doc_1"]);
    expect(plan.documentTransferPlan?.topLevelDocumentIds).toEqual(["doc_1"]);
    expect(fetchMock).toHaveBeenCalledWith(
      "/api/tree/filetree/drop-preflight",
      expect.objectContaining({
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          workspaceId: "ws_1",
          copy: false,
          targetDocumentId: "doc_target",
          targetRowId: null,
          focusedRowId: null,
          activeDocumentId: null,
          rowIds: ["doc:doc_1"],
          rows: [],
          documentParents: [],
        }),
      }),
    );
  });

  it("delete preflight 应发送到 tree filetree delete route 并返回 Rust plan", async () => {
    const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
      ok: true,
      json: async () => ({
        plan: {
          rowIds: ["doc:doc_1", "asset:asset_1"],
          docIds: ["doc_1"],
          assetIds: ["asset_1"],
          assetDocumentIds: ["doc_other"],
        },
      }),
    } as Response);

    const plan = await preflightFileTreeDelete({
      workspaceId: "ws_1",
      rowIds: ["doc:doc_1", "asset:asset_1"],
      rows: [],
      documentParents: [],
    });


assetDocumentIds: ["doc_other"],
        },
      }),
    } as Response);

    const plan = await preflightFileTreeDelete({
      workspaceId: "ws_1",
      rowIds: ["doc:doc_1", "asset:asset_1"],
      rows: [],
      documentParents: [],
    });

    expect(plan.docIds).toEqual(["doc_1"]);
    expect(plan.assetIds).toEqual(["asset_1"]);
    expect(fetchMock).toHaveBeenCalledWith(
      "/api/tree/filetree/delete-preflight",
      expect.objectContaining({
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          workspaceId: "ws_1",
          rowIds: ["doc:doc_1", "asset:asset_1"],
          rows: [],
          documentParents: [],
        }),
      }),
    );
  });

  it("paste preflight 应发送到 tree filetree paste route 并返回 Rust plan", async () => {
    const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
      ok: true,
      json: async () => ({
        plan: {
          targetDocumentId: "doc_target",
          targetMindmapId: "mind_1",
          targetSubPath: "mindmaps/mind_1",
          rowIds: ["index:doc_1", "asset:asset_1"],
          docItems: [{ documentId: "doc_1", recursive: false }],
          copyableAssetIds: ["asset_1"],
          resourceTransferPlan: {
            action: "copy",
            assetIds: ["asset_1"],
            targetDocumentId: "doc_target",
            targetSubPath: "mindmaps/mind_1",
          },
        },
      }),
    } as Response);

    const plan = await preflightFileTreePaste({
      workspaceId: "ws_1",
      targetDocumentId: null,
      focusedRowId: "asset-folder:mind_1",
      activeDocumentId: "doc_active",
      rowIds: ["index:doc_1", "asset:asset_1"],
      rows: [],
    });

    expect(plan.docItems).toEqual([{ documentId: "doc_1", recursive: false }]);


} as Response);

    const plan = await preflightFileTreePaste({
      workspaceId: "ws_1",
      targetDocumentId: null,
      focusedRowId: "asset-folder:mind_1",
      activeDocumentId: "doc_active",
      rowIds: ["index:doc_1", "asset:asset_1"],
      rows: [],
    });

    expect(plan.docItems).toEqual([{ documentId: "doc_1", recursive: false }]);
    expect(plan.resourceTransferPlan?.targetSubPath).toBe("mindmaps/mind_1");
    expect(fetchMock).toHaveBeenCalledWith(
      "/api/tree/filetree/paste-preflight",
      expect.objectContaining({
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          workspaceId: "ws_1",
          targetDocumentId: null,
          focusedRowId: "asset-folder:mind_1",
          activeDocumentId: "doc_active",
          rowIds: ["index:doc_1", "asset:asset_1"],
          rows: [],
        }),
      }),
    );
  });

  it("upload target preflight 应发送到 tree filetree upload-target route 并返回 Rust plan", async () => {
    const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
      ok: true,
      json: async () => ({
        plan: {
          workspaceId: "ws_1",
          targetDocumentId: "doc_target",
          targetMindmapId: "mind_1",
          targetSubPath: "mindmaps/mind_1",
        },
      }),
    } as Response);

    const plan = await preflightFileTreeUploadTarget({
      workspaceId: "ws_fallback",
      targetDocumentId: null,
      targetRowId: "asset:asset_child_1",
      focusedRowId: null,
      activeDocumentId: "doc_active",
      rows: [],
      documentWorkspaces: [{ documentId: "doc_target", workspaceId: "ws_1" }],
    });

    expect(plan).toEqual({
      workspaceId: "ws_1",
      targetDocumentId: "doc_target",
      targetMindmapId: "mind_1",
      targetSubPath: "mindmaps/mind_1",
    });
    expect(fetchMock).toHaveBeenCalledWith(


activeDocumentId: "doc_active",
      rows: [],
      documentWorkspaces: [{ documentId: "doc_target", workspaceId: "ws_1" }],
    });

    expect(plan).toEqual({
      workspaceId: "ws_1",
      targetDocumentId: "doc_target",
      targetMindmapId: "mind_1",
      targetSubPath: "mindmaps/mind_1",
    });
    expect(fetchMock).toHaveBeenCalledWith(
      "/api/tree/filetree/upload-target-preflight",
      expect.objectContaining({
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          workspaceId: "ws_fallback",
          targetDocumentId: null,
          targetRowId: "asset:asset_child_1",
          focusedRowId: null,
          activeDocumentId: "doc_active",
          rows: [],
          documentWorkspaces: [{ documentId: "doc_target", workspaceId: "ws_1" }],
        }),
      }),
    );
  });
});


"use client";

import type { MediaAsset } from "@/types/media";
import type {
  FileTreeShellDeletePreflightPayload,
  FileTreeShellInternalDropPreflightPayload,
  FileTreeShellPastePreflightPayload,
  FileTreeShellUploadTargetPreflightPayload,
} from "@/lib/file-tree/shell";

type ResourceCommandAction = "copy" | "move" | "rename" | "delete" | "restore";

type ResourceCommandErrorPayload = {
  error?: string;
};

type ResourceCommandResponse = {
  ok?: boolean;
  items?: MediaAsset[];
  asset?: MediaAsset;
};

type TransferResourceAssetsInput = {
  assetIds: readonly string[];
  targetDocumentId: string;
  targetSubPath?: string | null;
};

type RenameResourceAssetInput = {
  assetId: string;
  newName: string;
};

type UploadResourceAssetInput = {
  file: File;
  workspaceId: string;
  documentId: string;
  mindmapId?: string | null;
};

export type FileTreeInternalDropPreflightPlan = {
  allowed?: boolean;
  blockedReason?: string | null;
  requiresConfirmation?: boolean;
  conflicts?: Array<{
    rowKind: string;
    sourceRowId: string;
    sourceDocumentId: string | null;
    sourceAssetId: string | null;
    existingDocumentId: string | null;
    existingAssetId: string | null;
    targetDocumentId: string;
    title: string;
    policy: string;
  }>;
  copy: boolean;
  targetDocumentId: string;
  targetMindmapId: string | null;
  targetSubPath?: string | null;
  rowIds: string[];


sourceAssetId: string | null;
    existingDocumentId: string | null;
    existingAssetId: string | null;
    targetDocumentId: string;
    title: string;
    policy: string;
  }>;
  copy: boolean;
  targetDocumentId: string;
  targetMindmapId: string | null;
  targetSubPath?: string | null;
  rowIds: string[];
  docIds: string[];
  topLevelDocIds: string[];
  copyableAssetIds: string[];
  sourceAssetDocumentIds: string[];
  documentTransferPlan?: {
    action: "copy" | "move";
    targetParentId: string;
    documentIds: string[];
    topLevelDocumentIds: string[];
    copyItems: Array<{ documentId: string; recursive: boolean }>;
  } | null;
  resourceTransferPlan?: {
    action: "copy" | "move";
    assetIds: string[];
    targetDocumentId: string;
    targetSubPath?: string | null;
  } | null;
};

type FileTreeInternalDropPreflightResponse = {
  plan?: FileTreeInternalDropPreflightPlan;
};

export type FileTreeDeletePreflightPlan = {
  rowIds: string[];
  docIds: string[];
  assetIds: string[];
  assetDocumentIds: string[];
};

type FileTreeDeletePreflightResponse = {
  plan?: FileTreeDeletePreflightPlan;
};

export type FileTreePastePreflightPlan = {
  targetDocumentId: string;
  targetMindmapId: string | null;
  targetSubPath?: string | null;
  rowIds: string[];
  docItems: Array<{ documentId: string; recursive: boolean }>;
  copyableAssetIds: string[];
  resourceTransferPlan?: {
    action: "copy";
    assetIds: string[];
    targetDocumentId: string;
    targetSubPath?: string | null;
  } | null;
};


targetMindmapId: string | null;
  targetSubPath?: string | null;
  rowIds: string[];
  docItems: Array<{ documentId: string; recursive: boolean }>;
  copyableAssetIds: string[];
  resourceTransferPlan?: {
    action: "copy";
    assetIds: string[];
    targetDocumentId: string;
    targetSubPath?: string | null;
  } | null;
};

type FileTreePastePreflightResponse = {
  plan?: FileTreePastePreflightPlan;
};

export type FileTreeUploadTargetPreflightPlan = {
  workspaceId: string;
  targetDocumentId: string;
  targetMindmapId: string | null;
  targetSubPath?: string | null;
};

type FileTreeUploadTargetPreflightResponse = {
  plan?: FileTreeUploadTargetPreflightPlan;
};

function normalizeAssetIds(assetIds: readonly string[]): string[] {
  return Array.from(
    new Set(
      assetIds
        .map((assetId) => (typeof assetId === "string" ? assetId.trim() : ""))
        .filter(Boolean),
    ),
  );
}

async function postResourceCommand<TResult>(
  payload: Record<string, unknown>,
  fallbackMessage: string,
  path = "/api/media/batch",
): Promise<TResult> {
  const response = await fetch(path, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });

  const body = (await response.json().catch(() => null)) as
    | TResult
    | ResourceCommandErrorPayload
    | null;
  if (!response.ok) {
    const message =
      body && typeof body === "object" && "error" in body && typeof body.error === "string"
        ? body.error
        : fallbackMessage;
    throw new Error(message);
  }


const body = (await response.json().catch(() => null)) as
    | TResult
    | ResourceCommandErrorPayload
    | null;
  if (!response.ok) {
    const message =
      body && typeof body === "object" && "error" in body && typeof body.error === "string"
        ? body.error
        : fallbackMessage;
    throw new Error(message);
  }

  return body as TResult;
}

async function postResourceForm<TResult>(
  path: string,
  formData: FormData,
  fallbackMessage: string,
): Promise<TResult> {
  const response = await fetch(path, {
    method: "POST",
    body: formData,
  });

  const body = (await response.json().catch(() => null)) as
    | TResult
    | ResourceCommandErrorPayload
    | null;
  if (!response.ok) {
    const message =
      body && typeof body === "object" && "error" in body && typeof body.error === "string"
        ? body.error
        : fallbackMessage;
    throw new Error(message);
  }

  return body as TResult;
}

function buildTransferPayload(
  action: Extract<ResourceCommandAction, "copy" | "move">,
  input: TransferResourceAssetsInput,
) {
  const payload: Record<string, unknown> = {
    action,
    assetIds: normalizeAssetIds(input.assetIds),
    targetDocumentId: input.targetDocumentId,
  };
  const targetSubPath = input.targetSubPath?.trim();
  if (targetSubPath) {
    payload.targetSubPath = targetSubPath;
  }
  return payload;
}

export async function copyFileTreeResourceAssets(
  input: TransferResourceAssetsInput,
): Promise<ResourceCommandResponse> {


targetDocumentId: input.targetDocumentId,
  };
  const targetSubPath = input.targetSubPath?.trim();
  if (targetSubPath) {
    payload.targetSubPath = targetSubPath;
  }
  return payload;
}

export async function copyFileTreeResourceAssets(
  input: TransferResourceAssetsInput,
): Promise<ResourceCommandResponse> {
  return postResourceCommand<ResourceCommandResponse>(
    buildTransferPayload("copy", input),
    "复制附件失败",
  );
}

export async function moveFileTreeResourceAssets(
  input: TransferResourceAssetsInput,
): Promise<ResourceCommandResponse> {
  return postResourceCommand<ResourceCommandResponse>(
    buildTransferPayload("move", input),
    "移动附件失败",
  );
}

export async function renameFileTreeResourceAsset(
  input: RenameResourceAssetInput,
): Promise<ResourceCommandResponse> {
  return postResourceCommand<ResourceCommandResponse>(
    {
      action: "rename",
      assetIds: normalizeAssetIds([input.assetId]),
      newName: input.newName,
    },
    "重命名失败",
  );
}

export async function deleteFileTreeResourceAssets(
  assetIds: readonly string[],
): Promise<ResourceCommandResponse> {
  return postResourceCommand<ResourceCommandResponse>(
    {
      action: "delete",
      assetIds: normalizeAssetIds(assetIds),
    },
    "删除失败",
  );
}

export async function restoreFileTreeResourceAssets(
  assetIds: readonly string[],
): Promise<ResourceCommandResponse> {
  return postResourceCommand<ResourceCommandResponse>(
    {
      action: "restore",
      assetIds: normalizeAssetIds(assetIds),
    },


"删除失败",
  );
}

export async function restoreFileTreeResourceAssets(
  assetIds: readonly string[],
): Promise<ResourceCommandResponse> {
  return postResourceCommand<ResourceCommandResponse>(
    {
      action: "restore",
      assetIds: normalizeAssetIds(assetIds),
    },
    "恢复附件失败，请稍后再试",
  );
}

export async function uploadFileTreeResourceAsset(
  input: UploadResourceAssetInput,
): Promise<ResourceCommandResponse> {
  const formData = new FormData();
  formData.append("file", input.file);
  formData.append("workspaceId", input.workspaceId);
  formData.append("documentId", input.documentId);
  const mindmapId = input.mindmapId?.trim();
  if (mindmapId) {
    formData.append("mindmapId", mindmapId);
  }
  return postResourceForm<ResourceCommandResponse>(
    "/api/media/upload",
    formData,
    "上传失败",
  );
}

export async function preflightFileTreeInternalDrop(
  input: FileTreeShellInternalDropPreflightPayload,
): Promise<FileTreeInternalDropPreflightPlan> {
  const response = await postResourceCommand<FileTreeInternalDropPreflightResponse>(
    input,
    "文件树拖放预检失败",
    "/api/tree/filetree/drop-preflight",
  );
  if (!response.plan) {
    throw new Error("文件树拖放预检失败");
  }
  return response.plan;
}

export async function preflightFileTreeDelete(
  input: FileTreeShellDeletePreflightPayload,
): Promise<FileTreeDeletePreflightPlan> {
  const response = await postResourceCommand<FileTreeDeletePreflightResponse>(
    input,
    "文件树删除预检失败",
    "/api/tree/filetree/delete-preflight",
  );
  if (!response.plan) {
    throw new Error("文件树删除预检失败");
  }
  return response.plan;


export async function preflightFileTreeDelete(
  input: FileTreeShellDeletePreflightPayload,
): Promise<FileTreeDeletePreflightPlan> {
  const response = await postResourceCommand<FileTreeDeletePreflightResponse>(
    input,
    "文件树删除预检失败",
    "/api/tree/filetree/delete-preflight",
  );
  if (!response.plan) {
    throw new Error("文件树删除预检失败");
  }
  return response.plan;
}

export async function preflightFileTreePaste(
  input: FileTreeShellPastePreflightPayload,
): Promise<FileTreePastePreflightPlan> {
  const response = await postResourceCommand<FileTreePastePreflightResponse>(
    input,
    "文件树粘贴预检失败",
    "/api/tree/filetree/paste-preflight",
  );
  if (!response.plan) {
    throw new Error("文件树粘贴预检失败");
  }
  return response.plan;
}

export async function preflightFileTreeUploadTarget(
  input: FileTreeShellUploadTargetPreflightPayload,
): Promise<FileTreeUploadTargetPreflightPlan> {
  const response = await postResourceCommand<FileTreeUploadTargetPreflightResponse>(
    input,
    "文件树上传目标预检失败",
    "/api/tree/filetree/upload-target-preflight",
  );
  if (!response.plan) {
    throw new Error("文件树上传目标预检失败");
  }
  return response.plan;
}


import { describe, expect, it } from "vitest";
import { buildVisibleRows } from "./rows";
import { parseFileTreeRowId } from "./types";

describe("buildVisibleRows", () => {
  it("缺少 kernel file_tree items 时不再回退 pageRows + assets 重建对象语义", () => {
    const a = {
      access_scope: "private" as const,
      id: "a",
      workspace_id: "w",
      title: "A",
      parent_id: null,
      sort_order: 0,
      is_starred: null,
      is_template: false,
      created_at: "",
      updated_at: null,
      children: [
        {
          access_scope: "private" as const,
          id: "b",
          workspace_id: "w",
          title: "B",
          parent_id: "a",
          sort_order: 0,
          is_starred: null,
          is_template: false,
          created_at: "",
          updated_at: null,
          children: [],
        },
      ],
    };

    const rows = buildVisibleRows({
      pageRows: [
        {
          nodeId: a.id,
          parentNodeId: null,
          depth: 0,
          childCount: 1,
          position: 0,
          title: a.title,
          node: a,
        },
      ],
      expanded: new Set(["a"]),
      assetsByDoc: {
        a: [
          {
            id: "x",
            workspace_id: "w",
            document_id: "a",
            asset_type: "file",
            file_url: null,
            thumbnail_url: null,
            bucket: "workspace",
            storage_path: "x",
            file_name: "x.png",
            file_size: null,


a: [
          {
            id: "x",
            workspace_id: "w",
            document_id: "a",
            asset_type: "file",
            file_url: null,
            thumbnail_url: null,
            bucket: "workspace",
            storage_path: "x",
            file_name: "x.png",
            file_size: null,
            mime_type: "image/png",
            ocr_payload: undefined,
            ocr_strategy: null,
            ocr_text: null,
            ocr_status: null,
            signed_url: null,
            created_at: "",
            updated_at: "",
          },
          {
            id: "y",
            workspace_id: "w",
            document_id: "a",
            asset_type: "file",
            file_url: null,
            thumbnail_url: null,
            bucket: "workspace",
            storage_path: "y",
            file_name: "y.pdf",
            file_size: null,
            mime_type: "application/pdf",
            ocr_payload: undefined,
            ocr_strategy: null,
            ocr_text: null,
            ocr_status: null,
            signed_url: null,
            created_at: "",
            updated_at: "",
          },
        ],
      },
    });

    expect(rows).toEqual([]);
  });

  it("优先消费 Rust file_tree projection 并保留 index 与资源文件夹语义", () => {
    const rows = buildVisibleRows({
      fileTreeItems: [
        {
          rowId: "doc:page_root",
          rowKind: "document",
          nodeId: "page_root",
          parentNodeId: null,
          nodeType: "page",
          projectionKind: "file_tree",
          title: "根页面",
          depth: 0,


it("优先消费 Rust file_tree projection 并保留 index 与资源文件夹语义", () => {
    const rows = buildVisibleRows({
      fileTreeItems: [
        {
          rowId: "doc:page_root",
          rowKind: "document",
          nodeId: "page_root",
          parentNodeId: null,
          nodeType: "page",
          projectionKind: "file_tree",
          title: "根页面",
          depth: 0,
          position: 0,
          childCount: 4,
          expandable: true,
          expandedByDefault: true,
          capabilities: ["expand", "open", "select"],
          resourceMeta: {
            resourceKind: "document",
            documentId: "page_root",
            workspaceId: "ws_1",
            iconHint: "page",
            extra: {
              source: {
                sourceKind: "convex_workspace",
                sourceUri: "convex://workspace/ws_1/documents/page_root",
                relativePath: "documents/page_root",
                storageIdentity: "page_root",
                operationProfile: "convex_workspace",
              },
            },
          },
          iconHint: "page",
        },
        {
          rowId: "index:page_root",
          rowKind: "index",
          nodeId: "index:page_root",
          parentNodeId: "page_root",
          nodeType: "index",
          projectionKind: "file_tree",
          title: "index.md",
          depth: 1,
          position: 0,
          childCount: 0,
          expandable: false,
          expandedByDefault: false,
          capabilities: ["open", "select"],
          resourceMeta: {
            resourceKind: "index",
            documentId: "page_root",
            workspaceId: "ws_1",
            iconHint: "index",
            extra: {
              source: {
                sourceKind: "convex_workspace",
                sourceUri: "convex://workspace/ws_1/documents/page_root/index",
                relativePath: "documents/page_root/index",
                storageIdentity: "page_root:index",
                operationProfile: "convex_workspace",


resourceMeta: {
            resourceKind: "index",
            documentId: "page_root",
            workspaceId: "ws_1",
            iconHint: "index",
            extra: {
              source: {
                sourceKind: "convex_workspace",
                sourceUri: "convex://workspace/ws_1/documents/page_root/index",
                relativePath: "documents/page_root/index",
                storageIdentity: "page_root:index",
                operationProfile: "convex_workspace",
              },
            },
          },
          iconHint: "index",
        },
        {
          rowId: "asset-folder:mind_1",
          rowKind: "asset_folder",
          nodeId: "asset-folder:mind_1",
          parentNodeId: "page_root",
          nodeType: "mindmap",
          projectionKind: "file_tree",
          title: "头脑风暴",
          depth: 1,
          position: 1,
          childCount: 1,
          expandable: true,
          expandedByDefault: false,
          capabilities: ["expand", "open-asset", "select"],
          resourceMeta: {
            resourceKind: "mindmap",
            documentId: "page_root",
            assetId: "mind_1",
            workspaceId: "ws_1",
            assetKind: "mindmap",
            iconHint: "mindmap",
            extra: {
              source: {
                sourceKind: "local_folder",
                sourceUri: "file:///workspace/ws_1/page_root/mindmap.json",
                relativePath: "page_root/mindmap.json",
                storageIdentity: "mind_1",
                operationProfile: "local_readonly",
              },
            },
          },
          iconHint: "mindmap",
        },
        {
          rowId: "asset:asset_child_1",
          rowKind: "asset",
          nodeId: "asset:asset_child_1",
          parentNodeId: "asset-folder:mind_1",
          nodeType: "asset",
          projectionKind: "file_tree",
          title: "节点图片.png",
          depth: 2,
          position: 0,


iconHint: "mindmap",
        },
        {
          rowId: "asset:asset_child_1",
          rowKind: "asset",
          nodeId: "asset:asset_child_1",
          parentNodeId: "asset-folder:mind_1",
          nodeType: "asset",
          projectionKind: "file_tree",
          title: "节点图片.png",
          depth: 2,
          position: 0,
          childCount: 0,
          expandable: false,
          expandedByDefault: false,
          capabilities: ["open-asset", "select"],
          resourceMeta: {
            resourceKind: "asset",
            documentId: "page_root",
            assetId: "asset_child_1",
            workspaceId: "ws_1",
            assetKind: "image",
            iconHint: "image",
            extra: {
              source: {
                sourceKind: "local_folder",
                sourceUri: "file:///workspace/ws_1/page_root/assets/node.png",
                relativePath: "page_root/assets/node.png",
                storageIdentity: "asset_child_1",
                operationProfile: "local_readonly",
              },
            },
          },
          iconHint: "image",
        },
        {
          rowId: "asset:table_1",
          rowKind: "asset",
          nodeId: "asset:table_1",
          parentNodeId: "page_root",
          nodeType: "table",
          projectionKind: "file_tree",
          title: "预算.luckysheet",
          depth: 1,
          position: 2,
          childCount: 0,
          expandable: false,
          expandedByDefault: false,
          capabilities: ["open-asset", "select"],
          resourceMeta: {
            resourceKind: "table",
            documentId: "page_root",
            assetId: "table_1",
            workspaceId: "ws_1",
            assetKind: "table",
            iconHint: "table",
            extra: {
              source: {
                sourceKind: "convex_workspace",
                sourceUri: "convex://workspace/ws_1/assets/table_1",


capabilities: ["open-asset", "select"],
          resourceMeta: {
            resourceKind: "table",
            documentId: "page_root",
            assetId: "table_1",
            workspaceId: "ws_1",
            assetKind: "table",
            iconHint: "table",
            extra: {
              source: {
                sourceKind: "convex_workspace",
                sourceUri: "convex://workspace/ws_1/assets/table_1",
                relativePath: "assets/table_1",
                storageIdentity: "table_1",
                operationProfile: "convex_workspace",
              },
            },
          },
          iconHint: "table",
        },
      ],
      expanded: new Set(["page_root"]),
      expandedAssetFolderIds: new Set(["mind_1"]),
    });

    expect(rows.map((row) => `${row.kind}:${row.depth}:${row.rowId}`)).toEqual([
      "doc:0:doc:page_root",
      "index:1:index:page_root",
      "asset-folder:1:asset-folder:mind_1",
      "asset:2:asset:asset_child_1",
      "asset:1:asset:table_1",
    ]);
    expect(rows[0]).toMatchObject({
      kind: "doc",
      docId: "page_root",
      hasChildren: true,
      isExpanded: true,
    });
    expect(rows[2]).toMatchObject({
      kind: "asset-folder",
      docId: "page_root",
      asset: {
        id: "mind_1",
        asset_type: "mindmap",
        file_name: "头脑风暴",
      },
      hasChildren: true,
      isExpanded: true,
    });
    expect(rows[3]).toMatchObject({
      kind: "asset",
      asset: {
        id: "asset_child_1",
        asset_type: "image",
        file_name: "节点图片.png",
      },
    });
    expect(rows[4]).toMatchObject({
      kind: "asset",
      asset: {


});
    expect(rows[3]).toMatchObject({
      kind: "asset",
      asset: {
        id: "asset_child_1",
        asset_type: "image",
        file_name: "节点图片.png",
      },
    });
    expect(rows[4]).toMatchObject({
      kind: "asset",
      asset: {
        id: "table_1",
        asset_type: "luckysheet",
        file_name: "预算.luckysheet",
      },
    });
  });

});

describe("parseFileTreeRowId", () => {
  it("可逆解析 rowId", () => {
    expect(parseFileTreeRowId("doc:abc")).toEqual({ kind: "doc", docId: "abc" });
    expect(parseFileTreeRowId("index:abc")).toEqual({ kind: "index", docId: "abc" });
    expect(parseFileTreeRowId("asset:xyz")).toEqual({ kind: "asset", assetId: "xyz" });
    expect(parseFileTreeRowId("bad")).toBeNull();
    expect(parseFileTreeRowId("doc:")).toBeNull();
  });
});


"use client";

import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import {
  getDocIdFromFileTreeItem,
  resolveFileTreeRowAsset,
  resolveFileTreeRowNode,
} from "@/lib/kernel-file-tree";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
import type { MediaAsset } from "@/types/media";
import type { FileTreeRow } from "./types";
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";

function buildRowsFromKernelFileTreeProjection(input: {
  fileTreeItems: KernelFileTreeProjectionItem[];
  expanded: Set<string>;
  expandedAssetFolderIds?: Set<string>;
  nodeById?: Map<string, SidebarTreeNode>;
  assetById?: Map<string, MediaAsset>;
}): FileTreeRow[] {
  const rows: FileTreeRow[] = [];
  const seen = new Set<string>();

  input.fileTreeItems.forEach((item) => {
    if (!item?.rowId || seen.has(item.rowId)) {
      return;
    }
    seen.add(item.rowId);
    const docId = getDocIdFromFileTreeItem(item);

    switch (item.rowKind) {
      case "document": {
        const node = resolveFileTreeRowNode(item, input.nodeById);
        rows.push({
          kind: "doc",
          rowId: makeDocRowId(docId),
          depth: item.depth,
          docId,
          parentDocId: item.parentNodeId,
          node,
          hasChildren: item.childCount > 0,
          isExpanded: input.expanded.has(docId),
        });
        return;
      }
      case "index": {
        const node = resolveFileTreeRowNode(item, input.nodeById);
        rows.push({
          kind: "index",
          rowId: makeIndexRowId(docId),
          depth: item.depth,
          docId,
          parentDocId: docId,
          node,
        });
        return;
      }
      case "asset_folder": {
        const asset = resolveFileTreeRowAsset(item, input.assetById);


rows.push({
          kind: "index",
          rowId: makeIndexRowId(docId),
          depth: item.depth,
          docId,
          parentDocId: docId,
          node,
        });
        return;
      }
      case "asset_folder": {
        const asset = resolveFileTreeRowAsset(item, input.assetById);
        rows.push({
          kind: "asset-folder",
          rowId: makeAssetFolderRowId(asset.id),
          depth: item.depth,
          docId,
          parentDocId: docId,
          asset,
          hasChildren: item.childCount > 0,
          isExpanded: input.expandedAssetFolderIds?.has(asset.id) ?? false,
        });
        return;
      }
      case "asset": {
        const asset = resolveFileTreeRowAsset(item, input.assetById);
        rows.push({
          kind: "asset",
          rowId: makeAssetRowId(asset.id),
          depth: item.depth,
          docId,
          parentDocId: docId,
          asset,
        });
        return;
      }
    }
  });

  return rows;
}

export function buildVisibleRows({
  fileTreeItems,
  expanded,
  expandedAssetFolderIds,
  nodeById,
  assetById,
}: {
  fileTreeItems?: KernelFileTreeProjectionItem[];
  // 兼容旧调用签名；主路径必须提供 kernel file_tree items，不能再从这些字段重建对象语义。
  pageRows?: PageTreeProjectionItem[];
  expanded: Set<string>;
  assetsByDoc?: Record<string, MediaAsset[]>;
  assetChildrenByAssetId?: Record<string, MediaAsset[]>;
  expandedAssetFolderIds?: Set<string>;
  nodeById?: Map<string, SidebarTreeNode>;
  assetById?: Map<string, MediaAsset>;
}): FileTreeRow[] {
  if (fileTreeItems && fileTreeItems.length > 0) {


}: {
  fileTreeItems?: KernelFileTreeProjectionItem[];
  // 兼容旧调用签名；主路径必须提供 kernel file_tree items，不能再从这些字段重建对象语义。
  pageRows?: PageTreeProjectionItem[];
  expanded: Set<string>;
  assetsByDoc?: Record<string, MediaAsset[]>;
  assetChildrenByAssetId?: Record<string, MediaAsset[]>;
  expandedAssetFolderIds?: Set<string>;
  nodeById?: Map<string, SidebarTreeNode>;
  assetById?: Map<string, MediaAsset>;
}): FileTreeRow[] {
  if (fileTreeItems && fileTreeItems.length > 0) {
    return buildRowsFromKernelFileTreeProjection({
      fileTreeItems,
      expanded,
      expandedAssetFolderIds,
      nodeById,
      assetById,
    });
  }

  return [];
}


import { describe, expect, it } from "vitest";
import type { FileTreeSelectionState } from "./selection";
import {
  createEmptyFileTreeSelectionState,
  materializeRendererSelectionSnapshot,
  resolveActiveFileTreeSelection,
} from "./selection-source";

function selection(
  selectedRowIds: string[],
  anchorRowId: string | null,
  focusedRowId: string | null,
): FileTreeSelectionState {
  return {
    selectedRowIds: new Set(selectedRowIds),
    anchorRowId,
    focusedRowId,
  };
}

describe("selection-source", () => {
  it("rust_family 应优先消费 renderer selection snapshot", () => {
    const legacySelection = selection(["legacy"], "legacy", "legacy");
    const rendererSelection = selection(["renderer"], "renderer", "renderer");

    expect(
      resolveActiveFileTreeSelection({
        preferRendererSnapshot: true,
        legacySelection,
        rendererSelection,
      }),
    ).toBe(rendererSelection);

    expect(
      resolveActiveFileTreeSelection({
        preferRendererSnapshot: false,
        legacySelection,
        rendererSelection,
      }),
    ).toBe(legacySelection);
  });

  it("renderer event snapshot 只过滤未知 row，不在宿主侧重算 focus/anchor", () => {
    const snapshot = materializeRendererSelectionSnapshot({
      payload: {
        selectedRowIds: ["doc:a", "missing"],
        anchorRowId: "missing",
        focusedRowId: "doc:a",
      },
      hasRowId: (rowId) => rowId === "doc:a",
    });

    expect(Array.from(snapshot.selectedRowIds)).toEqual(["doc:a"]);
    expect(snapshot.anchorRowId).toBeNull();
    expect(snapshot.focusedRowId).toBe("doc:a");
  });

  it("空 selection 工厂应返回互不共享的 Set 实例", () => {
    const a = createEmptyFileTreeSelectionState();
    const b = createEmptyFileTreeSelectionState();


},
      hasRowId: (rowId) => rowId === "doc:a",
    });

    expect(Array.from(snapshot.selectedRowIds)).toEqual(["doc:a"]);
    expect(snapshot.anchorRowId).toBeNull();
    expect(snapshot.focusedRowId).toBe("doc:a");
  });

  it("空 selection 工厂应返回互不共享的 Set 实例", () => {
    const a = createEmptyFileTreeSelectionState();
    const b = createEmptyFileTreeSelectionState();

    a.selectedRowIds.add("doc:a");

    expect(a.selectedRowIds.has("doc:a")).toBe(true);
    expect(b.selectedRowIds.has("doc:a")).toBe(false);
    expect(a.selectedRowIds).not.toBe(b.selectedRowIds);
  });
});


import type { FileTreeSelectionState } from "./selection";

export type FileTreeSelectionSnapshotPayload = {
  selectedRowIds: readonly string[];
  anchorRowId: string | null;
  focusedRowId: string | null;
};

export function createEmptyFileTreeSelectionState(): FileTreeSelectionState {
  return {
    selectedRowIds: new Set<string>(),
    anchorRowId: null,
    focusedRowId: null,
  };
}

export function materializeRendererSelectionSnapshot(input: {
  payload: FileTreeSelectionSnapshotPayload;
  hasRowId: (rowId: string) => boolean;
}): FileTreeSelectionState {
  const selectedRowIds = new Set(
    input.payload.selectedRowIds.filter((rowId) => input.hasRowId(rowId)),
  );

  return {
    selectedRowIds,
    anchorRowId:
      input.payload.anchorRowId && input.hasRowId(input.payload.anchorRowId)
        ? input.payload.anchorRowId
        : null,
    focusedRowId:
      input.payload.focusedRowId && input.hasRowId(input.payload.focusedRowId)
        ? input.payload.focusedRowId
        : null,
  };
}

export function resolveActiveFileTreeSelection(input: {
  preferRendererSnapshot: boolean;
  legacySelection: FileTreeSelectionState;
  rendererSelection: FileTreeSelectionState;
}): FileTreeSelectionState {
  return input.preferRendererSnapshot
    ? input.rendererSelection
    : input.legacySelection;
}


import { describe, expect, it } from "vitest";
import { normalizeFileTreeSelectionForVisibleRows, reduceFileTreeSelection } from "./selection";

describe("reduceFileTreeSelection", () => {
  const visible = ["a", "b", "c", "d"];

  it("单击：清空并仅选中当前；更新 anchor/focus", () => {
    const next = reduceFileTreeSelection(
      { selectedRowIds: new Set(["x"]), anchorRowId: "x", focusedRowId: "x" },
      { type: "click", rowId: "b", visibleRowIds: visible, modifiers: {} },
    );
    expect(Array.from(next.selectedRowIds)).toEqual(["b"]);
    expect(next.anchorRowId).toBe("b");
    expect(next.focusedRowId).toBe("b");
  });

  it("Ctrl/Cmd+单击：切换选中；不清空其他", () => {
    const next = reduceFileTreeSelection(
      { selectedRowIds: new Set(["b"]), anchorRowId: "b", focusedRowId: "b" },
      { type: "click", rowId: "d", visibleRowIds: visible, modifiers: { ctrlKey: true } },
    );
    expect(next.selectedRowIds.has("b")).toBe(true);
    expect(next.selectedRowIds.has("d")).toBe(true);
  });

  it("Shift+单击：按 visibleRows 做区间选择（覆盖式）", () => {
    const next = reduceFileTreeSelection(
      { selectedRowIds: new Set(["b"]), anchorRowId: "b", focusedRowId: "b" },
      { type: "click", rowId: "d", visibleRowIds: visible, modifiers: { shiftKey: true } },
    );
    expect(Array.from(next.selectedRowIds)).toEqual(["b", "c", "d"]);
    expect(next.focusedRowId).toBe("d");
  });

  it("右键：未选中项右键 → 先切为单选；已选中项 → 保持多选集合", () => {
    const a = reduceFileTreeSelection(
      { selectedRowIds: new Set(["b", "c"]), anchorRowId: "b", focusedRowId: "c" },
      { type: "contextmenu", rowId: "d" },
    );
    expect(Array.from(a.selectedRowIds)).toEqual(["d"]);

    const b = reduceFileTreeSelection(
      { selectedRowIds: new Set(["b", "c"]), anchorRowId: "b", focusedRowId: "c" },
      { type: "contextmenu", rowId: "c" },
    );
    expect(Array.from(b.selectedRowIds).sort()).toEqual(["b", "c"]);
    expect(b.focusedRowId).toBe("c");
  });

  it("空白处单击清空选择", () => {
    const next = reduceFileTreeSelection(
      { selectedRowIds: new Set(["b"]), anchorRowId: "b", focusedRowId: "b" },
      { type: "clear" },
    );
    expect(next.selectedRowIds.size).toBe(0);
    expect(next.anchorRowId).toBeNull();
    expect(next.focusedRowId).toBeNull();
  });

  it("可见行变化时保留仍可见的选择", () => {


it("空白处单击清空选择", () => {
    const next = reduceFileTreeSelection(
      { selectedRowIds: new Set(["b"]), anchorRowId: "b", focusedRowId: "b" },
      { type: "clear" },
    );
    expect(next.selectedRowIds.size).toBe(0);
    expect(next.anchorRowId).toBeNull();
    expect(next.focusedRowId).toBeNull();
  });

  it("可见行变化时保留仍可见的选择", () => {
    const next = normalizeFileTreeSelectionForVisibleRows(
      { selectedRowIds: new Set(["b", "x"]), anchorRowId: "b", focusedRowId: "x" },
      ["a", "b", "c"],
    );
    expect(Array.from(next.selectedRowIds)).toEqual(["b"]);
    expect(next.anchorRowId).toBe("b");
    expect(next.focusedRowId).toBe("b");
  });
});


};
    }
    case "click": {
      const { rowId, visibleRowIds, modifiers } = action;
      const withMeta = Boolean(modifiers.metaKey);
      const withCtrl = Boolean(modifiers.ctrlKey);
      const withShift = Boolean(modifiers.shiftKey);
      const withToggle = withMeta || withCtrl;

      if (withShift) {
        const anchor = prev.anchorRowId ?? prev.focusedRowId ?? rowId;
        const range = getRangeRowIds(visibleRowIds, anchor, rowId);
        const next = withToggle ? new Set(prev.selectedRowIds) : new Set<string>();
        range.forEach((id) => next.add(id));
        return {
          selectedRowIds: next,
          anchorRowId: prev.anchorRowId ?? anchor,
          focusedRowId: rowId,
        };
      }

      if (withToggle) {
        const next = new Set(prev.selectedRowIds);
        if (next.has(rowId)) {
          next.delete(rowId);
        } else {
          next.add(rowId);
        }
        return { selectedRowIds: next, anchorRowId: rowId, focusedRowId: rowId };
      }

      return { selectedRowIds: new Set([rowId]), anchorRowId: rowId, focusedRowId: rowId };
    }
  }
}

export function normalizeFileTreeSelectionForVisibleRows(
  prev: FileTreeSelectionState,
  visibleRowIds: string[],
): FileTreeSelectionState {
  const visibleSet = new Set(visibleRowIds);

  let selectedRowIds = prev.selectedRowIds;
  let removedAny = false;
  const kept: string[] = [];
  prev.selectedRowIds.forEach((id) => {
    if (visibleSet.has(id)) {
      kept.push(id);
      return;
    }
    removedAny = true;
  });
  if (removedAny) {
    selectedRowIds = new Set(kept);
  }

  let anchorRowId = prev.anchorRowId;
  if (anchorRowId && !visibleSet.has(anchorRowId)) {
    anchorRowId = null;
  }


return;
    }
    removedAny = true;
  });
  if (removedAny) {
    selectedRowIds = new Set(kept);
  }

  let anchorRowId = prev.anchorRowId;
  if (anchorRowId && !visibleSet.has(anchorRowId)) {
    anchorRowId = null;
  }

  let focusedRowId = prev.focusedRowId;
  if (focusedRowId && !visibleSet.has(focusedRowId)) {
    focusedRowId = null;
  }

  if (!focusedRowId && selectedRowIds.size > 0) {
    focusedRowId = visibleRowIds.find((id) => selectedRowIds.has(id)) ?? null;
  }

  if (!anchorRowId && focusedRowId) {
    anchorRowId = focusedRowId;
  }

  if (
    selectedRowIds === prev.selectedRowIds &&
    anchorRowId === prev.anchorRowId &&
    focusedRowId === prev.focusedRowId
  ) {
    return prev;
  }

  return { selectedRowIds, anchorRowId, focusedRowId };
}


import { describe, expect, it } from "vitest";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { MediaAsset } from "@/types/media";
import {
  buildFileTreeShellDeletePreflightPayload,
  buildFileTreeShellInternalDropPreflightPayload,
  buildFileTreeShellPastePreflightPayload,
  buildFileTreeShellUploadTargetPreflightPayload,
  buildFileTreeShellRowById,
  buildFileTreeShellVisibleRowIds,
  collectFileTreeShellAssetHints,
  getOrderedFileTreeShellRows,
  inferFileTreeShellTargetDocumentId,
  resolveFileTreeShellMindmapTargetId,
} from "./shell";

describe("file-tree shell helpers", () => {
  const nodeById = new Map<string, SidebarTreeNode>([
    [
      "doc_root",
      {
        id: "doc_root",
        title: "根页面",
      } as SidebarTreeNode,
    ],
  ]);

  const assetById = new Map<string, MediaAsset>([
    [
      "mind_1",
      {
        id: "mind_1",
        document_id: "doc_root",
        workspace_id: "ws_1",
        asset_type: "mindmap",
        file_name: "mindmap.json",
        storage_path: "mindmaps/mind_1/mindmap.json",
      } as MediaAsset,
    ],
    [
      "asset_child_1",
      {
        id: "asset_child_1",
        document_id: "doc_root",
        workspace_id: "ws_1",
        asset_type: "file",
        file_name: "node.png",
        storage_path: "mindmaps/mind_1/assets/node.png",
      } as MediaAsset,
    ],
    [
      "pdf_1",
      {
        id: "pdf_1",
        document_id: "doc_root",
        workspace_id: "ws_1",
        asset_type: "file",
        file_name: "guide.pdf",
        storage_path: "uploads/guide.pdf",


storage_path: "mindmaps/mind_1/assets/node.png",
      } as MediaAsset,
    ],
    [
      "pdf_1",
      {
        id: "pdf_1",
        document_id: "doc_root",
        workspace_id: "ws_1",
        asset_type: "file",
        file_name: "guide.pdf",
        storage_path: "uploads/guide.pdf",
      } as MediaAsset,
    ],
  ]);

  const fileTreeItems: KernelFileTreeProjectionItem[] = [
    {
      rowId: "doc:doc_root",
      rowKind: "document",
      nodeId: "doc_root",
      parentNodeId: null,
      nodeType: "page",
      projectionKind: "file_tree",
      title: "根页面",
      depth: 0,
      position: 0,
      childCount: 3,
      expandable: true,
      expandedByDefault: true,
      capabilities: ["expand", "open", "select"],
      resourceMeta: {
        resourceKind: "document",
        documentId: "doc_root",
        workspaceId: "ws_1",
        iconHint: "page",
      },
      iconHint: "page",
    },
    {
      rowId: "index:doc_root",
      rowKind: "index",
      nodeId: "index:doc_root",
      parentNodeId: "doc_root",
      nodeType: "index",
      projectionKind: "file_tree",
      title: "index.md",
      depth: 1,
      position: 0,
      childCount: 0,
      expandable: false,
      expandedByDefault: false,
      capabilities: ["open", "select"],
      resourceMeta: {
        resourceKind: "index",
        documentId: "doc_root",
        workspaceId: "ws_1",
        iconHint: "index",
      },
      iconHint: "index",


position: 0,
      childCount: 0,
      expandable: false,
      expandedByDefault: false,
      capabilities: ["open", "select"],
      resourceMeta: {
        resourceKind: "index",
        documentId: "doc_root",
        workspaceId: "ws_1",
        iconHint: "index",
      },
      iconHint: "index",
    },
    {
      rowId: "asset-folder:mind_1",
      rowKind: "asset_folder",
      nodeId: "asset-folder:mind_1",
      parentNodeId: "doc_root",
      nodeType: "mindmap",
      projectionKind: "file_tree",
      title: "mindmap",
      depth: 1,
      position: 1,
      childCount: 1,
      expandable: true,
      expandedByDefault: false,
      capabilities: ["expand", "open-asset", "select"],
      resourceMeta: {
        resourceKind: "mindmap",
        documentId: "doc_root",
        assetId: "mind_1",
        workspaceId: "ws_1",
        assetKind: "mindmap",
        iconHint: "mindmap",
      },
      iconHint: "mindmap",
    },
    {
      rowId: "asset:asset_child_1",
      rowKind: "asset",
      nodeId: "asset:asset_child_1",
      parentNodeId: "asset-folder:mind_1",
      nodeType: "asset",
      projectionKind: "file_tree",
      title: "node.png",
      depth: 2,
      position: 0,
      childCount: 0,
      expandable: false,
      expandedByDefault: false,
      capabilities: ["open-asset", "select"],
      resourceMeta: {
        resourceKind: "asset",
        documentId: "doc_root",
        assetId: "asset_child_1",
        workspaceId: "ws_1",
        assetKind: "image",
        iconHint: "image",
      },
      iconHint: "image",


expandable: false,
      expandedByDefault: false,
      capabilities: ["open-asset", "select"],
      resourceMeta: {
        resourceKind: "asset",
        documentId: "doc_root",
        assetId: "asset_child_1",
        workspaceId: "ws_1",
        assetKind: "image",
        iconHint: "image",
      },
      iconHint: "image",
    },
    {
      rowId: "asset:pdf_1",
      rowKind: "asset",
      nodeId: "asset:pdf_1",
      parentNodeId: "doc_root",
      nodeType: "pdf",
      projectionKind: "file_tree",
      title: "guide.pdf",
      depth: 1,
      position: 2,
      childCount: 0,
      expandable: false,
      expandedByDefault: false,
      capabilities: ["open-asset", "select"],
      resourceMeta: {
        resourceKind: "pdf",
        documentId: "doc_root",
        assetId: "pdf_1",
        workspaceId: "ws_1",
        assetKind: "pdf",
        iconHint: "pdf",
      },
      iconHint: "pdf",
    },
  ];

  it("应直接从 kernel file_tree items 构造宿主 row map", () => {
    const rowById = buildFileTreeShellRowById({
      fileTreeItems: [...fileTreeItems],
      nodeById,
      assetById,
    });

    expect(buildFileTreeShellVisibleRowIds([...fileTreeItems])).toEqual([
      "doc:doc_root",
      "index:doc_root",
      "asset-folder:mind_1",
      "asset:asset_child_1",
      "asset:pdf_1",
    ]);

    expect(rowById.get("doc:doc_root")).toMatchObject({
      rowId: "doc:doc_root",
      rowKind: "doc",
      documentId: "doc_root",
      node: expect.objectContaining({
        id: "doc_root",


"index:doc_root",
      "asset-folder:mind_1",
      "asset:asset_child_1",
      "asset:pdf_1",
    ]);

    expect(rowById.get("doc:doc_root")).toMatchObject({
      rowId: "doc:doc_root",
      rowKind: "doc",
      documentId: "doc_root",
      node: expect.objectContaining({
        id: "doc_root",
      }),
    });
    expect(rowById.get("asset-folder:mind_1")).toMatchObject({
      rowId: "asset-folder:mind_1",
      rowKind: "asset-folder",
      documentId: "doc_root",
      assetId: "mind_1",
      asset: expect.objectContaining({
        id: "mind_1",
      }),
    });
  });

  it("应正确解析 file tree shell 的导图投放目标", () => {
    const rowById = buildFileTreeShellRowById({
      fileTreeItems: [...fileTreeItems],
      nodeById,
      assetById,
    });

    expect(resolveFileTreeShellMindmapTargetId(rowById.get("asset-folder:mind_1") ?? null)).toBe(
      "mind_1",
    );
    expect(resolveFileTreeShellMindmapTargetId(rowById.get("asset:asset_child_1") ?? null)).toBe(
      "mind_1",
    );
    expect(resolveFileTreeShellMindmapTargetId(rowById.get("asset:pdf_1") ?? null)).toBeNull();
  });

  it("应能仅凭 focusedRowId 从 shell row map 推回目标页面", () => {
    const rowById = buildFileTreeShellRowById({
      fileTreeItems: [...fileTreeItems],
      nodeById,
      assetById,
    });

    expect(
      inferFileTreeShellTargetDocumentId({
        focusedRowId: "asset-folder:mind_1",
        rowById,
        activeDocId: null,
      }),
    ).toBe("doc_root");
    expect(
      inferFileTreeShellTargetDocumentId({
        focusedRowId: "asset:pdf_1",
        rowById,
        activeDocId: null,


expect(
      inferFileTreeShellTargetDocumentId({
        focusedRowId: "asset-folder:mind_1",
        rowById,
        activeDocId: null,
      }),
    ).toBe("doc_root");
    expect(
      inferFileTreeShellTargetDocumentId({
        focusedRowId: "asset:pdf_1",
        rowById,
        activeDocId: null,
      }),
    ).toBe("doc_root");
    expect(
      inferFileTreeShellTargetDocumentId({
        focusedRowId: null,
        rowById,
        activeDocId: "doc_root",
      }),
    ).toBe("doc_root");
  });

  it("应按当前可见顺序返回选中的 shell rows", () => {
    const rowById = buildFileTreeShellRowById({
      fileTreeItems: [...fileTreeItems],
      nodeById,
      assetById,
    });

    expect(
      getOrderedFileTreeShellRows({
        rowIds: ["asset:pdf_1", "doc:doc_root", "missing"],
        visibleRowIds: buildFileTreeShellVisibleRowIds([...fileTreeItems]),
        rowById,
      }).map((row) => row.rowId),
    ).toEqual(["doc:doc_root", "asset:pdf_1"]);
  });

  it("内部拖放 preflight payload 应只收集 Rust 所需的行与父子快照", () => {
    const rowById = buildFileTreeShellRowById({
      fileTreeItems: [...fileTreeItems],
      nodeById,
      assetById,
    });

    const result = buildFileTreeShellInternalDropPreflightPayload({
      workspaceId: "ws_1",
      copy: false,
      targetDocumentId: null,
      targetRowId: "asset-folder:mind_1",
      focusedRowId: null,
      activeDocId: null,
      rowIds: ["asset:asset_child_1", "doc:doc_root", "asset:pdf_1", "asset:pdf_1"],
      rowById,
      parentById: new Map([
        ["doc_root", null],
        ["doc_child", "doc_root"],
      ]),
    });


copy: false,
      targetDocumentId: null,
      targetRowId: "asset-folder:mind_1",
      focusedRowId: null,
      activeDocId: null,
      rowIds: ["asset:asset_child_1", "doc:doc_root", "asset:pdf_1", "asset:pdf_1"],
      rowById,
      parentById: new Map([
        ["doc_root", null],
        ["doc_child", "doc_root"],
      ]),
    });

    expect(result).toEqual({
      workspaceId: "ws_1",
      copy: false,
      sourceCapabilities: ["read", "write", "move"],
      targetCapabilities: ["read", "write", "drop"],
      targetDocumentId: "doc_root",
      targetRowId: "asset-folder:mind_1",
      focusedRowId: null,
      activeDocumentId: null,
      rowIds: ["asset:asset_child_1", "doc:doc_root", "asset:pdf_1", "asset:pdf_1"],
      rows: [
        {
          rowId: "asset-folder:mind_1",
          rowKind: "asset-folder",
          documentId: "doc_root",
          assetId: "mind_1",
          assetDocumentId: "doc_root",
          assetType: "mindmap",
          storagePath: "mindmaps/mind_1/mindmap.json",
          title: "mindmap.json",
        },
        {
          rowId: "asset:asset_child_1",
          rowKind: "asset",
          documentId: "doc_root",
          assetId: "asset_child_1",
          assetDocumentId: "doc_root",
          assetType: "file",
          storagePath: "mindmaps/mind_1/assets/node.png",
          title: "node.png",
        },
        {
          rowId: "doc:doc_root",
          rowKind: "doc",
          documentId: "doc_root",
          assetId: null,
          assetDocumentId: null,
          assetType: null,
          storagePath: null,
          title: "根页面",
        },
        {
          rowId: "asset:pdf_1",
          rowKind: "asset",
          documentId: "doc_root",
          assetId: "pdf_1",
          assetDocumentId: "doc_root",


assetId: null,
          assetDocumentId: null,
          assetType: null,
          storagePath: null,
          title: "根页面",
        },
        {
          rowId: "asset:pdf_1",
          rowKind: "asset",
          documentId: "doc_root",
          assetId: "pdf_1",
          assetDocumentId: "doc_root",
          assetType: "file",
          storagePath: "uploads/guide.pdf",
          title: "guide.pdf",
        },
      ],
      targetChildren: [
        {
          rowKind: "asset-folder",
          documentId: "doc_root",
          assetId: "mind_1",
          title: "mindmap.json",
        },
        {
          rowKind: "asset",
          documentId: "doc_root",
          assetId: "asset_child_1",
          title: "node.png",
        },
        {
          rowKind: "asset",
          documentId: "doc_root",
          assetId: "pdf_1",
          title: "guide.pdf",
        },
      ],
      documentParents: [
        { documentId: "doc_root", parentId: null },
        { documentId: "doc_child", parentId: "doc_root" },
      ],
      conflictPolicy: "prompt",
    });
  });

  it("删除 preflight payload 应只收集选中行与父子快照", () => {
    const rowById = buildFileTreeShellRowById({
      fileTreeItems: [...fileTreeItems],
      nodeById,
      assetById,
    });

    const result = buildFileTreeShellDeletePreflightPayload({
      workspaceId: "ws_1",
      rowIds: ["doc:doc_root", "asset:pdf_1", "missing"],
      rowById,
      parentById: new Map([
        ["doc_root", null],
        ["doc_child", "doc_root"],
      ]),


nodeById,
      assetById,
    });

    const result = buildFileTreeShellDeletePreflightPayload({
      workspaceId: "ws_1",
      rowIds: ["doc:doc_root", "asset:pdf_1", "missing"],
      rowById,
      parentById: new Map([
        ["doc_root", null],
        ["doc_child", "doc_root"],
      ]),
    });

    expect(result).toEqual({
      workspaceId: "ws_1",
      rowIds: ["doc:doc_root", "asset:pdf_1", "missing"],
      rows: [
        {
          rowId: "doc:doc_root",
          rowKind: "doc",
          documentId: "doc_root",
          assetId: null,
          assetDocumentId: null,
          assetType: null,
          storagePath: null,
          title: "根页面",
        },
        {
          rowId: "asset:pdf_1",
          rowKind: "asset",
          documentId: "doc_root",
          assetId: "pdf_1",
          assetDocumentId: "doc_root",
          assetType: "file",
          storagePath: "uploads/guide.pdf",
          title: "guide.pdf",
        },
      ],
      documentParents: [
        { documentId: "doc_root", parentId: null },
        { documentId: "doc_child", parentId: "doc_root" },
      ],
    });
  });

  it("粘贴 preflight payload 应只收集剪贴板行与当前 focused 目标行", () => {
    const rowById = buildFileTreeShellRowById({
      fileTreeItems: [...fileTreeItems],
      nodeById,
      assetById,
    });

    const result = buildFileTreeShellPastePreflightPayload({
      workspaceId: "ws_1",
      targetDocumentId: null,
      focusedRowId: "asset-folder:mind_1",
      activeDocId: "doc_active",
      rowIds: ["index:doc_root", "asset:pdf_1", "missing"],
      rowById,


fileTreeItems: [...fileTreeItems],
      nodeById,
      assetById,
    });

    const result = buildFileTreeShellPastePreflightPayload({
      workspaceId: "ws_1",
      targetDocumentId: null,
      focusedRowId: "asset-folder:mind_1",
      activeDocId: "doc_active",
      rowIds: ["index:doc_root", "asset:pdf_1", "missing"],
      rowById,
    });

    expect(result).toEqual({
      workspaceId: "ws_1",
      targetDocumentId: null,
      focusedRowId: "asset-folder:mind_1",
      activeDocumentId: "doc_active",
      rowIds: ["index:doc_root", "asset:pdf_1", "missing"],
      rows: [
        {
          rowId: "asset-folder:mind_1",
          rowKind: "asset-folder",
          documentId: "doc_root",
          assetId: "mind_1",
          assetDocumentId: "doc_root",
          assetType: "mindmap",
          storagePath: "mindmaps/mind_1/mindmap.json",
          title: "mindmap.json",
        },
        {
          rowId: "index:doc_root",
          rowKind: "index",
          documentId: "doc_root",
          assetId: null,
          assetDocumentId: null,
          assetType: null,
          storagePath: null,
          title: "根页面",
        },
        {
          rowId: "asset:pdf_1",
          rowKind: "asset",
          documentId: "doc_root",
          assetId: "pdf_1",
          assetDocumentId: "doc_root",
          assetType: "file",
          storagePath: "uploads/guide.pdf",
          title: "guide.pdf",
        },
      ],
    });
  });

  it("上传目标 preflight payload 应只收集目标行、focused 行与文档工作区快照", () => {
    const rowById = buildFileTreeShellRowById({
      fileTreeItems: [...fileTreeItems],
      nodeById,
      assetById,


storagePath: "uploads/guide.pdf",
          title: "guide.pdf",
        },
      ],
    });
  });

  it("上传目标 preflight payload 应只收集目标行、focused 行与文档工作区快照", () => {
    const rowById = buildFileTreeShellRowById({
      fileTreeItems: [...fileTreeItems],
      nodeById,
      assetById,
    });

    const result = buildFileTreeShellUploadTargetPreflightPayload({
      workspaceId: "ws_fallback",
      targetDocumentId: null,
      targetRowId: "asset:asset_child_1",
      focusedRowId: "asset-folder:mind_1",
      activeDocId: "doc_active",
      rowById,
      documentWorkspaceById: new Map([
        ["doc_root", "ws_1"],
        ["doc_active", "ws_active"],
      ]),
    });

    expect(result).toEqual({
      workspaceId: "ws_fallback",
      targetDocumentId: null,
      targetRowId: "asset:asset_child_1",
      focusedRowId: "asset-folder:mind_1",
      activeDocumentId: "doc_active",
      rows: [
        {
          rowId: "asset:asset_child_1",
          rowKind: "asset",
          documentId: "doc_root",
          assetId: "asset_child_1",
          assetDocumentId: "doc_root",
          assetType: "file",
          storagePath: "mindmaps/mind_1/assets/node.png",
          title: "node.png",
        },
        {
          rowId: "asset-folder:mind_1",
          rowKind: "asset-folder",
          documentId: "doc_root",
          assetId: "mind_1",
          assetDocumentId: "doc_root",
          assetType: "mindmap",
          storagePath: "mindmaps/mind_1/mindmap.json",
          title: "mindmap.json",
        },
      ],
      documentWorkspaces: [
        { documentId: "doc_root", workspaceId: "ws_1" },
        { documentId: "doc_active", workspaceId: "ws_active" },
      ],
    });


assetId: "mind_1",
          assetDocumentId: "doc_root",
          assetType: "mindmap",
          storagePath: "mindmaps/mind_1/mindmap.json",
          title: "mindmap.json",
        },
      ],
      documentWorkspaces: [
        { documentId: "doc_root", workspaceId: "ws_1" },
        { documentId: "doc_active", workspaceId: "ws_active" },
      ],
    });
  });

  it("应能按 assetId 从 shell row map 回填 asset 与 asset-folder 的提示元数据", () => {
    const rowById = buildFileTreeShellRowById({
      fileTreeItems: [...fileTreeItems],
      nodeById,
      assetById,
    });

    expect(
      collectFileTreeShellAssetHints({
        rowById,
        assetIds: ["mind_1", "pdf_1", "missing"],
      }).map((asset) => asset.id),
    ).toEqual(["mind_1", "pdf_1"]);
  });
});


"use client";

import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import {
  getDocIdFromFileTreeItem,
  resolveFileTreeRowAsset,
  resolveFileTreeRowNode,
} from "@/lib/kernel-file-tree";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { MediaAsset } from "@/types/media";

export type FileTreeShellRowKind = "doc" | "index" | "asset" | "asset-folder";

export type FileTreeShellRow = {
  rowId: string;
  rowKind: FileTreeShellRowKind;
  documentId: string;
  assetId: string | null;
  node: SidebarTreeNode | null;
  asset: MediaAsset | null;
};

export type FileTreeShellInternalDropPreflightRow = {
  rowId: string;
  rowKind: FileTreeShellRowKind;
  documentId: string | null;
  assetId: string | null;
  assetDocumentId: string | null;
  assetType: string | null;
  storagePath: string | null;
  title: string | null;
  operationProfile?: string | null;
};

export type FileTreeShellInternalDropPreflightTargetChild = {
  rowKind: FileTreeShellRowKind;
  documentId: string | null;
  assetId: string | null;
  title: string | null;
};

export type FileTreeShellInternalDropPreflightPayload = {
  workspaceId: string | null;
  copy: boolean;
  sourceCapabilities: string[];
  targetCapabilities: string[];
  targetDocumentId: string | null;
  targetRowId: string | null;
  focusedRowId: string | null;
  activeDocumentId: string | null;
  rowIds: string[];
  rows: FileTreeShellInternalDropPreflightRow[];
  targetChildren: FileTreeShellInternalDropPreflightTargetChild[];
  documentParents: Array<{ documentId: string; parentId: string | null }>;
  conflictPolicy: "prompt";
};

export type FileTreeShellDeletePreflightPayload = {
  workspaceId: string | null;
  rowIds: string[];


focusedRowId: string | null;
  activeDocumentId: string | null;
  rowIds: string[];
  rows: FileTreeShellInternalDropPreflightRow[];
  targetChildren: FileTreeShellInternalDropPreflightTargetChild[];
  documentParents: Array<{ documentId: string; parentId: string | null }>;
  conflictPolicy: "prompt";
};

export type FileTreeShellDeletePreflightPayload = {
  workspaceId: string | null;
  rowIds: string[];
  rows: FileTreeShellInternalDropPreflightRow[];
  documentParents: Array<{ documentId: string; parentId: string | null }>;
};

export type FileTreeShellPastePreflightPayload = {
  workspaceId: string | null;
  targetDocumentId: string | null;
  focusedRowId: string | null;
  activeDocumentId: string | null;
  rowIds: string[];
  rows: FileTreeShellInternalDropPreflightRow[];
};

export type FileTreeShellUploadTargetPreflightPayload = {
  workspaceId: string | null;
  targetDocumentId: string | null;
  targetRowId: string | null;
  focusedRowId: string | null;
  activeDocumentId: string | null;
  rows: FileTreeShellInternalDropPreflightRow[];
  documentWorkspaces: Array<{ documentId: string; workspaceId: string | null }>;
};

function toShellRowKind(item: KernelFileTreeProjectionItem): FileTreeShellRowKind {
  switch (item.rowKind) {
    case "document":
      return "doc";
    case "asset_folder":
      return "asset-folder";
    default:
      return item.rowKind;
  }
}

export function buildFileTreeShellVisibleRowIds(
  fileTreeItems: readonly KernelFileTreeProjectionItem[],
): string[] {
  return fileTreeItems
    .map((item) => item.rowId)
    .filter((rowId): rowId is string => typeof rowId === "string" && rowId.trim().length > 0);
}

export function buildFileTreeShellRowById(input: {
  fileTreeItems: readonly KernelFileTreeProjectionItem[];
  nodeById?: Map<string, SidebarTreeNode>;
  assetById?: Map<string, MediaAsset>;
}): Map<string, FileTreeShellRow> {
  const rowById = new Map<string, FileTreeShellRow>();


): string[] {
  return fileTreeItems
    .map((item) => item.rowId)
    .filter((rowId): rowId is string => typeof rowId === "string" && rowId.trim().length > 0);
}

export function buildFileTreeShellRowById(input: {
  fileTreeItems: readonly KernelFileTreeProjectionItem[];
  nodeById?: Map<string, SidebarTreeNode>;
  assetById?: Map<string, MediaAsset>;
}): Map<string, FileTreeShellRow> {
  const rowById = new Map<string, FileTreeShellRow>();

  input.fileTreeItems.forEach((item) => {
    const rowId = typeof item.rowId === "string" ? item.rowId.trim() : "";
    if (!rowId || rowById.has(rowId)) {
      return;
    }

    const rowKind = toShellRowKind(item);
    const documentId = getDocIdFromFileTreeItem(item);
    const isDocumentRow = rowKind === "doc" || rowKind === "index";

    rowById.set(rowId, {
      rowId,
      rowKind,
      documentId,
      assetId: isDocumentRow ? null : item.resourceMeta.assetId ?? null,
      node: isDocumentRow ? resolveFileTreeRowNode(item, input.nodeById) : null,
      asset: isDocumentRow ? null : resolveFileTreeRowAsset(item, input.assetById),
    });
  });

  return rowById;
}

export function getOrderedFileTreeShellRows(input: {
  rowIds: Iterable<string>;
  visibleRowIds: readonly string[];
  rowById: Map<string, FileTreeShellRow>;
}): FileTreeShellRow[] {
  const selectedRowIds = new Set<string>();
  for (const rowId of input.rowIds) {
    if (typeof rowId !== "string" || rowId.trim().length === 0) {
      continue;
    }
    if (!input.rowById.has(rowId)) {
      continue;
    }
    selectedRowIds.add(rowId);
  }

  return input.visibleRowIds
    .map((rowId) => (selectedRowIds.has(rowId) ? input.rowById.get(rowId) ?? null : null))
    .filter((row): row is FileTreeShellRow => Boolean(row));
}

export function extractMindmapAssetIdFromStoragePath(
  storagePath: string | null | undefined,
): string | null {


}
    selectedRowIds.add(rowId);
  }

  return input.visibleRowIds
    .map((rowId) => (selectedRowIds.has(rowId) ? input.rowById.get(rowId) ?? null : null))
    .filter((row): row is FileTreeShellRow => Boolean(row));
}

export function extractMindmapAssetIdFromStoragePath(
  storagePath: string | null | undefined,
): string | null {
  if (!storagePath) return null;
  const normalized = storagePath.replaceAll("\\", "/");

  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;
}

export function resolveFileTreeShellMindmapTargetId(
  row: FileTreeShellRow | null,
): string | null {
  if (!row?.asset) {
    return null;
  }
  if (row.rowKind === "asset-folder" && row.asset.asset_type === "mindmap") {
    return row.asset.id;
  }
  if (row.rowKind === "asset") {
    return extractMindmapAssetIdFromStoragePath(row.asset.storage_path);
  }
  return null;
}

export function inferFileTreeShellTargetDocumentId(input: {
  focusedRowId: string | null;
  rowById: Map<string, FileTreeShellRow>;
  activeDocId: string | null;
}): string | null {
  if (input.focusedRowId) {
    const row = input.rowById.get(input.focusedRowId) ?? null;
    if (row?.documentId) {
      return row.documentId;
    }
  }
  return input.activeDocId || null;
}

function normalizeShellText(value: string | null | undefined): string | null {


activeDocId: string | null;
}): string | null {
  if (input.focusedRowId) {
    const row = input.rowById.get(input.focusedRowId) ?? null;
    if (row?.documentId) {
      return row.documentId;
    }
  }
  return input.activeDocId || null;
}

function normalizeShellText(value: string | null | undefined): string | null {
  if (typeof value !== "string") return null;
  const trimmed = value.trim();
  return trimmed.length > 0 ? trimmed : null;
}

function getFileTreeShellRowTitle(row: FileTreeShellRow): string | null {
  if (row.rowKind === "doc" || row.rowKind === "index") {
    return normalizeShellText(row.node?.title ?? null);
  }
  return normalizeShellText(row.asset?.file_name ?? null);
}

function buildFileTreeShellDropPreflightRow(
  row: FileTreeShellRow,
): FileTreeShellInternalDropPreflightRow {
  return {
    rowId: row.rowId,
    rowKind: row.rowKind,
    documentId: normalizeShellText(row.documentId),
    assetId: normalizeShellText(row.assetId),
    assetDocumentId: normalizeShellText(row.asset?.document_id ?? null),
    assetType: normalizeShellText(row.asset?.asset_type ?? null),
    storagePath: normalizeShellText(row.asset?.storage_path ?? null),
    title: getFileTreeShellRowTitle(row),
  };
}

function buildFileTreeShellTargetChildren(input: {
  targetDocumentId: string | null;
  rowById: Map<string, FileTreeShellRow>;
  parentById: Map<string, string | null>;
}): FileTreeShellInternalDropPreflightTargetChild[] {
  const targetDocumentId = normalizeShellText(input.targetDocumentId);
  if (!targetDocumentId) {
    return [];
  }
  return Array.from(input.rowById.values())
    .filter((row) => {
      if (row.rowKind === "doc" || row.rowKind === "index") {
        return input.parentById.get(row.documentId) === targetDocumentId;
      }
      return row.documentId === targetDocumentId;
    })
    .map((row) => ({
      rowKind: row.rowKind,
      documentId: normalizeShellText(row.documentId),
      assetId: normalizeShellText(row.assetId),
      title: getFileTreeShellRowTitle(row),


return Array.from(input.rowById.values())
    .filter((row) => {
      if (row.rowKind === "doc" || row.rowKind === "index") {
        return input.parentById.get(row.documentId) === targetDocumentId;
      }
      return row.documentId === targetDocumentId;
    })
    .map((row) => ({
      rowKind: row.rowKind,
      documentId: normalizeShellText(row.documentId),
      assetId: normalizeShellText(row.assetId),
      title: getFileTreeShellRowTitle(row),
    }));
}

export function buildFileTreeShellInternalDropPreflightPayload(input: {
  workspaceId: string | null;
  copy: boolean;
  targetDocumentId: string | null;
  targetRowId: string | null;
  focusedRowId: string | null;
  activeDocId: string | null;
  rowIds: readonly string[];
  rowById: Map<string, FileTreeShellRow>;
  parentById: Map<string, string | null>;
}): FileTreeShellInternalDropPreflightPayload {
  const rowIdSet = new Set<string>();
  const appendRowId = (value: string | null | undefined) => {
    const rowId = normalizeShellText(value);
    if (rowId) {
      rowIdSet.add(rowId);
    }
  };
  const rowIds = input.rowIds.map((rowId) => (typeof rowId === "string" ? rowId : ""));
  const targetRowId = normalizeShellText(input.targetRowId);
  const focusedRowId = normalizeShellText(input.focusedRowId);
  appendRowId(targetRowId);
  appendRowId(focusedRowId);
  rowIds.forEach(appendRowId);

  const rows = Array.from(rowIdSet)
    .map((rowId) => input.rowById.get(rowId) ?? null)
    .filter((row): row is FileTreeShellRow => Boolean(row))
    .map(buildFileTreeShellDropPreflightRow);
  const targetDocumentId =
    normalizeShellText(input.targetDocumentId) ??
    inferFileTreeShellTargetDocumentId({
      focusedRowId: targetRowId ?? focusedRowId,
      rowById: input.rowById,
      activeDocId: input.activeDocId,
    });

  return {
    workspaceId: normalizeShellText(input.workspaceId),
    copy: input.copy,
    sourceCapabilities: ["read", "write", input.copy ? "copy" : "move"],
    targetCapabilities: ["read", "write", "drop"],
    targetDocumentId,
    targetRowId,
    focusedRowId,


rowById: input.rowById,
      activeDocId: input.activeDocId,
    });

  return {
    workspaceId: normalizeShellText(input.workspaceId),
    copy: input.copy,
    sourceCapabilities: ["read", "write", input.copy ? "copy" : "move"],
    targetCapabilities: ["read", "write", "drop"],
    targetDocumentId,
    targetRowId,
    focusedRowId,
    activeDocumentId: normalizeShellText(input.activeDocId),
    rowIds,
    rows,
    targetChildren: buildFileTreeShellTargetChildren({
      targetDocumentId,
      rowById: input.rowById,
      parentById: input.parentById,
    }),
    documentParents: Array.from(input.parentById.entries()).map(([documentId, parentId]) => ({
      documentId,
      parentId: normalizeShellText(parentId),
    })),
    conflictPolicy: "prompt",
  };
}

export function buildFileTreeShellDeletePreflightPayload(input: {
  workspaceId: string | null;
  rowIds: readonly string[];
  rowById: Map<string, FileTreeShellRow>;
  parentById: Map<string, string | null>;
}): FileTreeShellDeletePreflightPayload {
  const rowIds = input.rowIds.map((rowId) => (typeof rowId === "string" ? rowId : ""));
  const rowIdSet = new Set<string>();
  rowIds.forEach((rowId) => {
    const normalized = normalizeShellText(rowId);
    if (normalized) {
      rowIdSet.add(normalized);
    }
  });

  const rows = Array.from(rowIdSet)
    .map((rowId) => input.rowById.get(rowId) ?? null)
    .filter((row): row is FileTreeShellRow => Boolean(row))
    .map(buildFileTreeShellDropPreflightRow);

  return {
    workspaceId: normalizeShellText(input.workspaceId),
    rowIds,
    rows,
    documentParents: Array.from(input.parentById.entries()).map(([documentId, parentId]) => ({
      documentId,
      parentId: normalizeShellText(parentId),
    })),
  };
}

export function buildFileTreeShellPastePreflightPayload(input: {


return {
    workspaceId: normalizeShellText(input.workspaceId),
    rowIds,
    rows,
    documentParents: Array.from(input.parentById.entries()).map(([documentId, parentId]) => ({
      documentId,
      parentId: normalizeShellText(parentId),
    })),
  };
}

export function buildFileTreeShellPastePreflightPayload(input: {
  workspaceId: string | null;
  targetDocumentId: string | null;
  focusedRowId: string | null;
  activeDocId: string | null;
  rowIds: readonly string[];
  rowById: Map<string, FileTreeShellRow>;
}): FileTreeShellPastePreflightPayload {
  const rowIds = input.rowIds.map((rowId) => (typeof rowId === "string" ? rowId : ""));
  const rowIdSet = new Set<string>();
  const focusedRowId = normalizeShellText(input.focusedRowId);
  if (focusedRowId) {
    rowIdSet.add(focusedRowId);
  }
  rowIds.forEach((rowId) => {
    const normalized = normalizeShellText(rowId);
    if (normalized) {
      rowIdSet.add(normalized);
    }
  });

  const rows = Array.from(rowIdSet)
    .map((rowId) => input.rowById.get(rowId) ?? null)
    .filter((row): row is FileTreeShellRow => Boolean(row))
    .map(buildFileTreeShellDropPreflightRow);

  return {
    workspaceId: normalizeShellText(input.workspaceId),
    targetDocumentId: normalizeShellText(input.targetDocumentId),
    focusedRowId,
    activeDocumentId: normalizeShellText(input.activeDocId),
    rowIds,
    rows,
  };
}

export function buildFileTreeShellUploadTargetPreflightPayload(input: {
  workspaceId: string | null;
  targetDocumentId: string | null;
  targetRowId: string | null;
  focusedRowId: string | null;
  activeDocId: string | null;
  rowById: Map<string, FileTreeShellRow>;
  documentWorkspaceById: Map<string, string | null>;
}): FileTreeShellUploadTargetPreflightPayload {
  const rowIdSet = new Set<string>();
  const appendRowId = (value: string | null | undefined) => {
    const rowId = normalizeShellText(value);
    if (rowId) {


workspaceId: string | null;
  targetDocumentId: string | null;
  targetRowId: string | null;
  focusedRowId: string | null;
  activeDocId: string | null;
  rowById: Map<string, FileTreeShellRow>;
  documentWorkspaceById: Map<string, string | null>;
}): FileTreeShellUploadTargetPreflightPayload {
  const rowIdSet = new Set<string>();
  const appendRowId = (value: string | null | undefined) => {
    const rowId = normalizeShellText(value);
    if (rowId) {
      rowIdSet.add(rowId);
    }
  };
  const targetRowId = normalizeShellText(input.targetRowId);
  const focusedRowId = normalizeShellText(input.focusedRowId);
  appendRowId(targetRowId);
  appendRowId(focusedRowId);

  const rows = Array.from(rowIdSet)
    .map((rowId) => input.rowById.get(rowId) ?? null)
    .filter((row): row is FileTreeShellRow => Boolean(row))
    .map(buildFileTreeShellDropPreflightRow);

  return {
    workspaceId: normalizeShellText(input.workspaceId),
    targetDocumentId: normalizeShellText(input.targetDocumentId),
    targetRowId,
    focusedRowId,
    activeDocumentId: normalizeShellText(input.activeDocId),
    rows,
    documentWorkspaces: Array.from(input.documentWorkspaceById.entries()).map(
      ([documentId, workspaceId]) => ({
        documentId,
        workspaceId: normalizeShellText(workspaceId),
      }),
    ),
  };
}

export function collectFileTreeShellAssetHints(input: {
  rowById: Map<string, FileTreeShellRow>;
  assetIds: readonly string[];
}): MediaAsset[] {
  const hints: MediaAsset[] = [];
  const seen = new Set<string>();
  input.assetIds.forEach((assetId) => {
    const normalized = normalizeShellText(assetId);
    if (!normalized || seen.has(normalized)) {
      return;
    }
    const asset =
      input.rowById.get(`asset:${normalized}`)?.asset ??
      input.rowById.get(`asset-folder:${normalized}`)?.asset ??
      null;
    if (!asset) {
      return;
    }
    seen.add(normalized);


const normalized = normalizeShellText(assetId);
    if (!normalized || seen.has(normalized)) {
      return;
    }
    const asset =
      input.rowById.get(`asset:${normalized}`)?.asset ??
      input.rowById.get(`asset-folder:${normalized}`)?.asset ??
      null;
    if (!asset) {
      return;
    }
    seen.add(normalized);
    hints.push(asset);
  });
  return hints;
}


"use client";

import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { MediaAsset } from "@/types/media";

export type FileTreeRowKind = "doc" | "index" | "asset" | "asset-folder";

export type FileTreeRowId =
  | `doc:${string}`
  | `index:${string}`
  | `asset:${string}`
  | `asset-folder:${string}`;

export type ParsedFileTreeRowId =
  | { kind: "doc"; docId: string }
  | { kind: "index"; docId: string }
  | { kind: "asset"; assetId: string }
  | { kind: "asset-folder"; assetId: string };

export function makeDocRowId(docId: string): FileTreeRowId {
  return `doc:${docId}`;
}

export function makeIndexRowId(docId: string): FileTreeRowId {
  return `index:${docId}`;
}

export function makeAssetRowId(assetId: string): FileTreeRowId {
  return `asset:${assetId}`;
}

export function makeAssetFolderRowId(assetId: string): FileTreeRowId {
  return `asset-folder:${assetId}`;
}

export function parseFileTreeRowId(rowId: string): ParsedFileTreeRowId | null {
  const idx = rowId.indexOf(":");
  if (idx <= 0) return null;
  const prefix = rowId.slice(0, idx);
  const rest = rowId.slice(idx + 1);
  if (!rest) return null;
  switch (prefix) {
    case "doc":
      return { kind: "doc", docId: rest };
    case "index":
      return { kind: "index", docId: rest };
    case "asset":
      return { kind: "asset", assetId: rest };
    case "asset-folder":
      return { kind: "asset-folder", assetId: rest };
    default:
      return null;
  }
}

export type FileTreeRow =
  | {
      kind: "doc";
      rowId: FileTreeRowId;
      depth: number;


case "asset-folder":
      return { kind: "asset-folder", assetId: rest };
    default:
      return null;
  }
}

export type FileTreeRow =
  | {
      kind: "doc";
      rowId: FileTreeRowId;
      depth: number;
      docId: string;
      parentDocId: string | null;
      node: SidebarTreeNode;
      hasChildren: boolean;
      isExpanded: boolean;
    }
  | {
      kind: "index";
      rowId: FileTreeRowId;
      depth: number;
      docId: string;
      parentDocId: string;
      node: SidebarTreeNode;
    }
  | {
      kind: "asset-folder";
      rowId: FileTreeRowId;
      depth: number;
      docId: string;
      parentDocId: string;
      asset: MediaAsset;
      hasChildren: boolean;
      isExpanded: boolean;
    }
  | {
      kind: "asset";
      rowId: FileTreeRowId;
      depth: number;
      docId: string;
      parentDocId: string;
      asset: MediaAsset;
    };

export function getFileTreeRowLabel(row: FileTreeRow): string {
  switch (row.kind) {
    case "doc":
      return row.node.title || "无标题";
    case "index":
      return "index.md";
    case "asset-folder": {
      const name = row.asset.file_name || "附件";
      // 思维导图展示为“文件夹”时，去掉 .json 结尾更直观
      return row.asset.asset_type === "mindmap" ? name.replace(/\.json$/i, "") : name;
    }
    case "asset":
      return row.asset.file_name || "附件";
  }
}


return row.node.title || "无标题";
    case "index":
      return "index.md";
    case "asset-folder": {
      const name = row.asset.file_name || "附件";
      // 思维导图展示为“文件夹”时，去掉 .json 结尾更直观
      return row.asset.asset_type === "mindmap" ? name.replace(/\.json$/i, "") : name;
    }
    case "asset":
      return row.asset.file_name || "附件";
  }
}

export function getOwningDocId(row: FileTreeRow): string {
  switch (row.kind) {
    case "doc":
    case "index":
      return row.docId;
    case "asset-folder":
    case "asset":
      return row.asset.document_id;
  }
}


import { afterEach, describe, expect, it, vi } from "vitest";
import type { ConvexHttpClient } from "convex/browser";

vi.mock("@/lib/auth/authContext", () => ({
  HttpError: class HttpError extends Error {
    status: number;

    constructor(status: number, message: string) {
      super(message);
      this.status = status;
    }
  },
  requireAuthContext: vi.fn(async () => ({
    userId: "user_1",
  })),
}));

vi.mock("@/lib/api-utils", () => ({
  apiErrorResponse: vi.fn((message: string, status = 500, details?: unknown) => ({
    message,
    status,
    details,
  })),
}));

vi.mock("@/lib/convex/api", () => ({
  api: {
    documents: {
      getContent: "documents:getContent",
    },
  },
}));

vi.mock("@/lib/convex/route", () => ({
  getAuthedConvexClient: vi.fn(),
}));

vi.mock("@/lib/documents/bridge-log", () => ({
  recordBridgeCommandArtifacts: vi.fn(),
  recordBridgeCommandFailureArtifacts: vi.fn(),
  recordRustBridgeCommandArtifacts: vi.fn(),
}));

vi.mock("@/lib/documents/rust-runtime", () => ({
  resolveRustBridgeCommandPlan: vi.fn(),
  resolveRustBridgeQueryPlan: vi.fn(),
  executeRustBridgeQueryTransport: vi.fn(),
  executeRustBridgeMutationTransport: vi.fn(),
}));

vi.mock("@/lib/document-content", () => ({
  extractBlocksFromContent: vi.fn(),
}));

afterEach(() => {
  vi.clearAllMocks();
});

describe("documents/block-command-adapter", () => {
  it("executeBlockPatchBridgeCommand 应改走 Rust artifact writer", async () => {


}));

vi.mock("@/lib/document-content", () => ({
  extractBlocksFromContent: vi.fn(),
}));

afterEach(() => {
  vi.clearAllMocks();
});

describe("documents/block-command-adapter", () => {
  it("executeBlockPatchBridgeCommand 应改走 Rust artifact writer", async () => {
    const { getAuthedConvexClient } = await import("@/lib/convex/route");
    const {
      recordBridgeCommandArtifacts,
      recordRustBridgeCommandArtifacts,
    } = await import("@/lib/documents/bridge-log");
    const {
      resolveRustBridgeCommandPlan,
      executeRustBridgeMutationTransport,
    } = await import("@/lib/documents/rust-runtime");
    const { extractBlocksFromContent } = await import("@/lib/document-content");
    const { executeBlockPatchBridgeCommand } = await import("./block-command-adapter");

    vi.mocked(getAuthedConvexClient).mockResolvedValue({
      auth: { userId: "user_1" },
      client: {
        query: vi.fn().mockResolvedValue({
          content: { type: "doc" },
        }),
        mutation: vi.fn(),
      } as unknown as ConvexHttpClient,
    });
    vi.mocked(extractBlocksFromContent).mockReturnValue([
      {
        id: "blk_1",
        type: "paragraph",
      },
    ]);
    vi.mocked(resolveRustBridgeCommandPlan).mockResolvedValue({
      kind: "command",
      commandName: "blocks.patch",
      commandId: "cmd_block_patch_1",
      functionName: "documents:updateContent",
      workspaceId: "ws_1",
      requestId: "req_1",
      traceId: "trace_1",
      actorId: "user_1",
      idempotencyKey: "idem_1",
      payloadJson: "{\"kind\":\"command\"}",
      argsJson: {
        id: "doc_1",
        blockId: "blk_1",
        nextBlock: {
          id: "blk_1",
          type: "heading",
        },
      },
    });
    vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({


idempotencyKey: "idem_1",
      payloadJson: "{\"kind\":\"command\"}",
      argsJson: {
        id: "doc_1",
        blockId: "blk_1",
        nextBlock: {
          id: "blk_1",
          type: "heading",
        },
      },
    });
    vi.mocked(executeRustBridgeMutationTransport).mockResolvedValue({
      revision: 8,
      conflict_detection_key: "doc_1:8",
    });

    const result = await executeBlockPatchBridgeCommand({
      request: new Request("http://127.0.0.1:3000/api/documents/blocks/patch", {
        method: "POST",
      }),
      sourceDocumentId: "doc_1",
      workspaceId: "ws_1",
      blockId: "blk_1",
      nextBlock: {
        id: "blk_1",
        type: "heading",
      },
    });

    expect(result.commandName).toBe("blocks.patch");
    expect(recordRustBridgeCommandArtifacts).toHaveBeenCalledWith({
      client: expect.any(Object),
      context: expect.objectContaining({
        workspaceId: "ws_1",
      }),
      envelope: expect.objectContaining({
        name: "blocks.patch",
      }),
      plan: expect.objectContaining({
        commandName: "blocks.patch",
      }),
      result: {
        revision: 8,
        conflict_detection_key: "doc_1:8",
      },
    });
    expect(recordBridgeCommandArtifacts).not.toHaveBeenCalled();
  });
});


import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
  buildDocumentBridgeContext,
  buildDocumentCommandEnvelope,
  buildDocumentQueryEnvelope,
  documentBridgeErrorResponse,
  type BridgeContext,
} from "@/lib/documents/bridge";
import {
  recordBridgeCommandFailureArtifacts,
  recordRustBridgeCommandArtifacts,
} from "@/lib/documents/bridge-log";
import {
  executeRustBridgeMutationTransport,
  executeRustBridgeQueryTransport,
  resolveRustBridgeCommandPlan,
  resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
import { extractBlocksFromContent } from "@/lib/document-content";

type BlockLike = {
  id: string;
  type?: string;
  props?: Record<string, unknown>;
  content?: unknown;
  children?: BlockLike[];
};

function cloneBlock(block: BlockLike): BlockLike {
  return {
    ...block,
    props: block.props ? { ...block.props } : undefined,
    content: Array.isArray(block.content) ? [...block.content] : block.content,
    children: Array.isArray(block.children) ? block.children.map((item) => cloneBlock(item)) : block.children,
  };
}

function findBlockInTree(
  blocks: BlockLike[],
  blockId: string,
): { block: BlockLike; parent: BlockLike | null; index: number } | null {
  const stack: Array<{ list: BlockLike[]; parent: BlockLike | null }> = [{ list: blocks, parent: null }];
  while (stack.length > 0) {
    const current = stack.pop();
    if (!current) continue;
    for (let i = 0; i < current.list.length; i += 1) {
      const block = current.list[i]!;
      if (block.id === blockId) {
        return { block, parent: current.parent, index: i };
      }
      if (Array.isArray(block.children) && block.children.length > 0) {
        stack.push({ list: block.children, parent: block });
      }
    }
  }
  return null;
}

function removeBlockSubtree(blocks: BlockLike[], blockId: string) {


if (block.id === blockId) {
        return { block, parent: current.parent, index: i };
      }
      if (Array.isArray(block.children) && block.children.length > 0) {
        stack.push({ list: block.children, parent: block });
      }
    }
  }
  return null;
}

function removeBlockSubtree(blocks: BlockLike[], blockId: string) {
  const nextTop = blocks.map((item) => cloneBlock(item));
  const hit = findBlockInTree(nextTop, blockId);
  if (!hit) {
    return { removed: null as BlockLike | null, nextBlocks: nextTop };
  }
  if (hit.parent) {
    const nextChildren = Array.isArray(hit.parent.children) ? hit.parent.children.map((item) => cloneBlock(item)) : [];
    const removed = nextChildren.splice(hit.index, 1)[0] ?? null;
    hit.parent.children = nextChildren;
    return { removed, nextBlocks: nextTop };
  }
  const removed = nextTop.splice(hit.index, 1)[0] ?? null;
  return { removed, nextBlocks: nextTop };
}

function replaceBlockInTree(blocks: BlockLike[], blockId: string, nextBlock: unknown) {
  const nextTop = blocks.map((item) => cloneBlock(item));
  const hit = findBlockInTree(nextTop, blockId);
  if (!hit || !nextBlock || typeof nextBlock !== "object" || Array.isArray(nextBlock)) {
    return { ok: false, nextBlocks: nextTop };
  }
  const normalized = cloneBlock({ ...(nextBlock as BlockLike), id: blockId });
  if (hit.parent) {
    const nextChildren = Array.isArray(hit.parent.children) ? hit.parent.children.map((item) => cloneBlock(item)) : [];
    nextChildren[hit.index] = normalized;
    hit.parent.children = nextChildren;
    return { ok: true, nextBlocks: nextTop };
  }
  nextTop[hit.index] = normalized;
  return { ok: true, nextBlocks: nextTop };
}

async function buildBridgeContext(request: Request, workspaceId: string | null): Promise<BridgeContext> {
  return await buildDocumentBridgeContext({ request, workspaceId });
}

export async function executeBlockGetBridgeQuery(input: {
  request: Request;
  sourceDocumentId: string;
  blockId: string;
}) {
  const context = await buildBridgeContext(input.request, null);
  const envelope = buildDocumentQueryEnvelope({
    name: "blocks.get",
    payload: {
      sourceDocumentId: input.sourceDocumentId,
      blockId: input.blockId,
    },


export async function executeBlockGetBridgeQuery(input: {
  request: Request;
  sourceDocumentId: string;
  blockId: string;
}) {
  const context = await buildBridgeContext(input.request, null);
  const envelope = buildDocumentQueryEnvelope({
    name: "blocks.get",
    payload: {
      sourceDocumentId: input.sourceDocumentId,
      blockId: input.blockId,
    },
  });
  const plan = await resolveRustBridgeQueryPlan({ context, envelope });
  const { client } = await getAuthedConvexClient();
  const doc = await executeRustBridgeQueryTransport<{ content?: unknown } | null>({
    client,
    plan,
  });
  if (!doc) {
    throw new Error("页面不存在或无权限");
  }
  const blocks = extractBlocksFromContent(doc.content) as BlockLike[];
  const hit = findBlockInTree(blocks, input.blockId);
  if (!hit) {
    throw new Error("块不存在或无权限");
  }
  return {
    requestId: context.requestId,
    traceId: context.traceId,
    queryName: envelope.name,
    result: { block: hit.block },
  };
}

export async function executeBlockPatchBridgeCommand(input: {
  request: Request;
  sourceDocumentId: string;
  workspaceId: string | null;
  blockId: string;
  nextBlock: unknown;
}) {
  const context = await buildBridgeContext(input.request, input.workspaceId);
  const envelope = buildDocumentCommandEnvelope({
    name: "blocks.patch",
    payload: {
      documentId: input.sourceDocumentId,
      workspaceId: input.workspaceId,
      blockId: input.blockId,
      nextBlock: input.nextBlock,
    },
    context,
    target: {
      workspaceId: input.workspaceId,
      pageId: input.sourceDocumentId,
      blockId: input.blockId,
    },
  });
  const plan = await resolveRustBridgeCommandPlan({ context, envelope });
  const { client } = await getAuthedConvexClient();


blockId: input.blockId,
      nextBlock: input.nextBlock,
    },
    context,
    target: {
      workspaceId: input.workspaceId,
      pageId: input.sourceDocumentId,
      blockId: input.blockId,
    },
  });
  const plan = await resolveRustBridgeCommandPlan({ context, envelope });
  const { client } = await getAuthedConvexClient();
  const doc = await client.query(api.documents.getContent, { id: input.sourceDocumentId });
  if (!doc) {
    throw new Error("页面不存在或无权限");
  }
  const blocks = extractBlocksFromContent(doc.content) as BlockLike[];
  const replaced = replaceBlockInTree(blocks, input.blockId, input.nextBlock);
  if (!replaced.ok) {
    throw new Error("块不存在或无权限");
  }
  try {
    const transportResult = await executeRustBridgeMutationTransport({
      client,
      plan,
    });
    await recordRustBridgeCommandArtifacts({
      client,
      context,
      envelope,
      plan,
      result: transportResult,
    });
  } catch (error) {
    await recordBridgeCommandFailureArtifacts({
      client,
      context,
      envelope,
      error,
    });
    throw error;
  }
  return {
    requestId: context.requestId,
    traceId: context.traceId,
    commandId: envelope.commandId,
    commandName: envelope.name,
    result: { ok: true as const },
  };
}

export async function executeBlockMoveBridgeCommand(input: {
  request: Request;
  sourceDocumentId: string;
  targetDocumentId: string;
  blockId: string;
}) {
  const { client } = await getAuthedConvexClient();
  const source = await client.query(api.documents.getContent, { id: input.sourceDocumentId });
  if (!source) throw new Error("源页面不存在或无权限");


};
}

export async function executeBlockMoveBridgeCommand(input: {
  request: Request;
  sourceDocumentId: string;
  targetDocumentId: string;
  blockId: string;
}) {
  const { client } = await getAuthedConvexClient();
  const source = await client.query(api.documents.getContent, { id: input.sourceDocumentId });
  if (!source) throw new Error("源页面不存在或无权限");
  const target = await client.query(api.documents.getContent, { id: input.targetDocumentId });
  if (!target) throw new Error("目标页面不存在或无权限");
  const sourceBlocks = extractBlocksFromContent(source.content) as BlockLike[];
  const removedRes = removeBlockSubtree(sourceBlocks, input.blockId);
  if (!removedRes.removed) throw new Error("源块不存在或无权限");
  const context = await buildBridgeContext(input.request, null);
  const envelope = buildDocumentCommandEnvelope({
    name: "blocks.move",
    payload: {
      sourceDocumentId: input.sourceDocumentId,
      targetDocumentId: input.targetDocumentId,
      blockId: input.blockId,
    },
    context,
    target: {
      pageId: input.targetDocumentId,
      blockId: input.blockId,
    },
  });
  const plan = await resolveRustBridgeCommandPlan({ context, envelope });
  try {
    const transportResult = await executeRustBridgeMutationTransport({ client, plan });
    await recordRustBridgeCommandArtifacts({
      client,
      context,
      envelope,
      plan,
      result: transportResult,
    });
  } catch (error) {
    await recordBridgeCommandFailureArtifacts({
      client,
      context,
      envelope,
      error,
    });
    throw error;
  }
  return {
    requestId: context.requestId,
    traceId: context.traceId,
    commandId: envelope.commandId,
    commandName: envelope.name,
    result: { ok: true as const },
  };
}

export async function executeBlockEmbedBridgeCommand(input: {


throw error;
  }
  return {
    requestId: context.requestId,
    traceId: context.traceId,
    commandId: envelope.commandId,
    commandName: envelope.name,
    result: { ok: true as const },
  };
}

export async function executeBlockEmbedBridgeCommand(input: {
  request: Request;
  sourceDocumentId: string;
  targetDocumentId: string;
  blockId: string;
}) {
  const { client } = await getAuthedConvexClient();
  const source = await client.query(api.documents.getContent, { id: input.sourceDocumentId });
  if (!source) throw new Error("源页面不存在或无权限");
  const target = await client.query(api.documents.getContent, { id: input.targetDocumentId });
  if (!target) throw new Error("目标页面不存在或无权限");
  const targetMeta = await client.query(api.documents.getMeta, { id: input.targetDocumentId });
  const sourceBlocks = extractBlocksFromContent(source.content) as BlockLike[];
  const hit = findBlockInTree(sourceBlocks, input.blockId);
  if (!hit) throw new Error("源块不存在或无权限");
  const anchorId = (targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id ?? null;
  const anchorIndex =
    typeof anchorId === "string" && anchorId.trim()
      ? extractBlocksFromContent(target.content).findIndex((block) => String((block as BlockLike).id ?? "") === anchorId)
      : -1;
  const targetBlocks = extractBlocksFromContent(target.content) as BlockLike[];
  void targetBlocks;
  void anchorIndex;
  const context = await buildBridgeContext(input.request, null);
  const envelope = buildDocumentCommandEnvelope({
    name: "blocks.embed",
    payload: {
      sourceDocumentId: input.sourceDocumentId,
      targetDocumentId: input.targetDocumentId,
      blockId: input.blockId,
    },
    context,
    target: {
      pageId: input.targetDocumentId,
      blockId: input.blockId,
    },
  });
  const plan = await resolveRustBridgeCommandPlan({ context, envelope });
  try {
    const transportResult = await executeRustBridgeMutationTransport({ client, plan });
    await recordRustBridgeCommandArtifacts({
      client,
      context,
      envelope,
      plan,
      result: transportResult,
    });
  } catch (error) {
    await recordBridgeCommandFailureArtifacts({


const plan = await resolveRustBridgeCommandPlan({ context, envelope });
  try {
    const transportResult = await executeRustBridgeMutationTransport({ client, plan });
    await recordRustBridgeCommandArtifacts({
      client,
      context,
      envelope,
      plan,
      result: transportResult,
    });
  } catch (error) {
    await recordBridgeCommandFailureArtifacts({
      client,
      context,
      envelope,
      error,
    });
    throw error;
  }
  return {
    requestId: context.requestId,
    traceId: context.traceId,
    commandId: envelope.commandId,
    commandName: envelope.name,
    result: { ok: true as const },
  };
}

export function handleBlockBridgeError(error: unknown) {
  return documentBridgeErrorResponse(error);
}