value: unknown;
  controlType: MindmapSidebarOptionControlType;
  preview?: string;
  description?: string;
  readonly?: boolean;
  compatPath?: string;
};

export type MindmapNavigatorItem = {
  id: "stats" | "centerRoot" | "search" | "zoomOut" | "zoom" | "zoomIn" | "fullscreen" | "readonly";
  label: string;
  actionId: MindmapUiActionId | null;
  readOnly: boolean;
  displayMode: "text" | "button" | "input";
};

export type MindmapContextMenuItem = {
  id: string;
  label: string;
  actionId: MindmapUiActionId;
  requiresNode: boolean;
  phase: 1;
};

export type MindmapUiSchema = {
  toolbarGroups: MindmapToolbarGroup[];
  toolbarActionMeta: Record<MindmapUiActionId, MindmapToolbarActionMeta>;
  sidebarPanels: MindmapSidebarPanel[];
  navigatorItems: MindmapNavigatorItem[];
  contextMenuItems: MindmapContextMenuItem[];
};

export const mindmapToolbarPrimaryActionOrder = [
  "undo",
  "redo",
  "editNode",
  "insertSiblingAfter",
  "deleteNode",
  "insertChild",
  "tag",
  "hyperlink",
  "note",
  "image",
  "icon",
  "summary",
  "associativeLine",
  "formula",
] as const satisfies readonly MindmapUiActionId[];

export const mindmapToolbarFileActionOrder = ["import", "export"] as const satisfies readonly MindmapUiActionId[];

const createToolbarActionMeta = (
  id: MindmapUiActionId,
  iconKey: string,
  shortLabel: string,
  longLabel: string,
  priority: number,
  overflowGroup: MindmapToolbarOverflowGroup,
  cluster: MindmapToolbarActionCluster,
): MindmapToolbarActionMeta => ({


export const mindmapToolbarFileActionOrder = ["import", "export"] as const satisfies readonly MindmapUiActionId[];

const createToolbarActionMeta = (
  id: MindmapUiActionId,
  iconKey: string,
  shortLabel: string,
  longLabel: string,
  priority: number,
  overflowGroup: MindmapToolbarOverflowGroup,
  cluster: MindmapToolbarActionCluster,
): MindmapToolbarActionMeta => ({
  id,
  iconKey,
  shortLabel,
  longLabel,
  priority,
  overflowGroup,
  cluster,
});

export const mindmapDefaultUiSchema: MindmapUiSchema = {
  toolbarGroups: [
    {
      id: "history",
      label: "历史",
      actions: ["undo", "redo"],
      collapsePriority: 4,
    },
    {
      id: "node",
      label: "节点",
      actions: ["editNode", "insertSiblingAfter", "deleteNode", "insertChild"],
      collapsePriority: 1,
    },
    {
      id: "insert",
      label: "插入",
      actions: ["tag", "hyperlink", "note", "image", "icon", "summary", "associativeLine", "formula"],
      collapsePriority: 2,
    },
    {
      id: "file",
      label: "文件",
      actions: ["import", "export"],
      collapsePriority: 5,
    },
    {
      id: "view",
      label: "视图",
      actions: ["painter", "centerRoot", "zoomOut", "zoomIn", "search", "readonly"],
      collapsePriority: 3,
    },
  ],
  toolbarActionMeta: {
    undo: createToolbarActionMeta("undo", "undo", "撤销", "撤销", 10, "history", "main"),
    redo: createToolbarActionMeta("redo", "redo", "重做", "重做", 20, "history", "main"),
    editNode: createToolbarActionMeta("editNode", "type", "编辑", "编辑节点", 30, "node", "main"),
    insertSiblingAfter: createToolbarActionMeta("insertSiblingAfter", "sibling", "同级", "插入同级节点", 40, "node", "main"),
    deleteNode: createToolbarActionMeta("deleteNode", "trash", "删除", "删除节点", 50, "node", "main"),


id: "view",
      label: "视图",
      actions: ["painter", "centerRoot", "zoomOut", "zoomIn", "search", "readonly"],
      collapsePriority: 3,
    },
  ],
  toolbarActionMeta: {
    undo: createToolbarActionMeta("undo", "undo", "撤销", "撤销", 10, "history", "main"),
    redo: createToolbarActionMeta("redo", "redo", "重做", "重做", 20, "history", "main"),
    editNode: createToolbarActionMeta("editNode", "type", "编辑", "编辑节点", 30, "node", "main"),
    insertSiblingAfter: createToolbarActionMeta("insertSiblingAfter", "sibling", "同级", "插入同级节点", 40, "node", "main"),
    deleteNode: createToolbarActionMeta("deleteNode", "trash", "删除", "删除节点", 50, "node", "main"),
    insertChild: createToolbarActionMeta("insertChild", "child", "子级", "插入子节点", 60, "node", "main"),
    tag: createToolbarActionMeta("tag", "tag", "标签", "标签", 70, "insert", "main"),
    hyperlink: createToolbarActionMeta("hyperlink", "link", "链接", "超链接", 80, "insert", "main"),
    note: createToolbarActionMeta("note", "note", "备注", "备注", 90, "insert", "main"),
    image: createToolbarActionMeta("image", "image", "图片", "图片", 100, "insert", "main"),
    icon: createToolbarActionMeta("icon", "smile", "图标", "图标", 110, "insert", "main"),
    summary: createToolbarActionMeta("summary", "summary", "概要", "概要", 120, "insert", "main"),
    associativeLine: createToolbarActionMeta("associativeLine", "route", "关联", "关联线", 130, "insert", "main"),
    formula: createToolbarActionMeta("formula", "formula", "公式", "公式", 140, "insert", "main"),
    painter: createToolbarActionMeta("painter", "paintbrush", "格式", "格式刷", 210, "view", "view"),
    import: createToolbarActionMeta("import", "import", "导入", "导入", 310, "file", "file"),
    export: createToolbarActionMeta("export", "export", "导出", "导出", 320, "file", "file"),
    setTheme: createToolbarActionMeta("setTheme", "palette", "主题", "主题", 410, "view", "view"),
    setLayout: createToolbarActionMeta("setLayout", "layout", "结构", "结构", 420, "view", "view"),
    zoomIn: createToolbarActionMeta("zoomIn", "zoom-in", "放大", "放大", 510, "view", "view"),
    zoomOut: createToolbarActionMeta("zoomOut", "zoom-out", "缩小", "缩小", 500, "view", "view"),
    fitView: createToolbarActionMeta("fitView", "fit", "适应", "适应画布", 520, "view", "view"),
    centerRoot: createToolbarActionMeta("centerRoot", "target", "回根", "回到根节点", 490, "view", "view"),
    fullscreenCanvas: createToolbarActionMeta("fullscreenCanvas", "fullscreen", "全屏", "全屏查看", 550, "view", "view"),
    fullscreenPage: createToolbarActionMeta("fullscreenPage", "fullscreen-page", "全页", "全屏编辑", 560, "view", "view"),
    exitFullscreen: createToolbarActionMeta("exitFullscreen", "exit-fullscreen", "退出", "退出全屏", 570, "view", "view"),
    search: createToolbarActionMeta("search", "search", "搜索", "搜索", 530, "view", "view"),
    showMenu: createToolbarActionMeta("showMenu", "menu", "菜单", "显示菜单", 580, "view", "view"),
    expandCollapse: createToolbarActionMeta("expandCollapse", "expand", "展开", "展开/收起", 610, "view", "view"),
    copyNodeText: createToolbarActionMeta("copyNodeText", "copy", "复制", "复制文本", 620, "view", "view"),
    readonly: createToolbarActionMeta("readonly", "lock", "只读", "只读", 540, "view", "view"),
  },
  sidebarPanels: [
    {
      id: "nodeStyle",
      kind: "nodeStyle",
      label: "节点样式",
      icon: "palette",
      runtimeCapability: "node-style",
      phase: 1,
      options: [
        { id: "node-fill-blue", label: "海蓝", actionId: "painter", value: "#dbeafe", controlType: "swatch", preview: "#dbeafe", compatPath: "nodes.$active.data.fillColor" },
        { id: "node-fill-green", label: "薄荷", actionId: "painter", value: "#dcfce7", controlType: "swatch", preview: "#dcfce7", compatPath: "nodes.$active.data.fillColor" },
        { id: "node-fill-amber", label: "暖黄", actionId: "painter", value: "#fef3c7", controlType: "swatch", preview: "#fef3c7", compatPath: "nodes.$active.data.fillColor" },


        { id: "node-text-dark", label: "深色文字", actionId: "painter", value: "#0f172a", controlType: "swatch", preview: "#0f172a", compatPath: "nodes.$active.data.color" },
        { id: "node-text-blue", label: "蓝色文字", actionId: "painter", value: "#1d4ed8", controlType: "swatch", preview: "#1d4ed8", compatPath: "nodes.$active.data.color" },
        { id: "node-font-14", label: "14 px", actionId: "painter", value: 14, controlType: "segmented", compatPath: "nodes.$active.data.fontSize" },
        { id: "node-font-18", label: "18 px", actionId: "painter", value: 18, controlType: "segmented", compatPath: "nodes.$active.data.fontSize" },
        { id: "node-font-bold", label: "加粗", actionId: "painter", value: true, controlType: "toggle", compatPath: "nodes.$active.data.fontWeight" },
        { id: "node-font-italic", label: "斜体", actionId: "painter", value: true, controlType: "toggle", compatPath: "nodes.$active.data.fontStyle" },
        { id: "node-shape-round", label: "圆角矩形", actionId: "painter", value: "roundedRectangle", controlType: "button", compatPath: "nodes.$active.data.shape" },
        { id: "node-shape-rect", label: "矩形", actionId: "painter", value: "rectangle", controlType: "button", compatPath: "nodes.$active.data.shape" },
        { id: "node-border-blue", label: "蓝色边框", actionId: "painter", value: "#60a5fa", controlType: "swatch", preview: "#60a5fa", compatPath: "nodes.$active.data.borderColor" },


{ id: "node-fill-blue", label: "海蓝", actionId: "painter", value: "#dbeafe", controlType: "swatch", preview: "#dbeafe", compatPath: "nodes.$active.data.fillColor" },
        { id: "node-fill-green", label: "薄荷", actionId: "painter", value: "#dcfce7", controlType: "swatch", preview: "#dcfce7", compatPath: "nodes.$active.data.fillColor" },
        { id: "node-fill-amber", label: "暖黄", actionId: "painter", value: "#fef3c7", controlType: "swatch", preview: "#fef3c7", compatPath: "nodes.$active.data.fillColor" },
        { id: "node-text-dark", label: "深色文字", actionId: "painter", value: "#0f172a", controlType: "swatch", preview: "#0f172a", compatPath: "nodes.$active.data.color" },
        { id: "node-text-blue", label: "蓝色文字", actionId: "painter", value: "#1d4ed8", controlType: "swatch", preview: "#1d4ed8", compatPath: "nodes.$active.data.color" },
        { id: "node-font-14", label: "14 px", actionId: "painter", value: 14, controlType: "segmented", compatPath: "nodes.$active.data.fontSize" },
        { id: "node-font-18", label: "18 px", actionId: "painter", value: 18, controlType: "segmented", compatPath: "nodes.$active.data.fontSize" },
        { id: "node-font-bold", label: "加粗", actionId: "painter", value: true, controlType: "toggle", compatPath: "nodes.$active.data.fontWeight" },
        { id: "node-font-italic", label: "斜体", actionId: "painter", value: true, controlType: "toggle", compatPath: "nodes.$active.data.fontStyle" },
        { id: "node-shape-round", label: "圆角矩形", actionId: "painter", value: "roundedRectangle", controlType: "button", compatPath: "nodes.$active.data.shape" },
        { id: "node-shape-rect", label: "矩形", actionId: "painter", value: "rectangle", controlType: "button", compatPath: "nodes.$active.data.shape" },
        { id: "node-border-blue", label: "蓝色边框", actionId: "painter", value: "#60a5fa", controlType: "swatch", preview: "#60a5fa", compatPath: "nodes.$active.data.borderColor" },
        { id: "node-line-teal", label: "青色分支线", actionId: "painter", value: "#14b8a6", controlType: "swatch", preview: "#14b8a6", compatPath: "nodes.$active.data.lineColor" },
        { id: "node-line-width-2", label: "边线 2", actionId: "painter", value: 2, controlType: "segmented", compatPath: "nodes.$active.data.lineWidth" },
      ],
    },
    {
      id: "baseStyle",
      kind: "baseStyle",
      label: "导图样式",
      icon: "sliders",
      runtimeCapability: "base-style",
      phase: 1,
      options: [
        { id: "base-curve-line", label: "曲线", actionId: "painter", value: "curve", controlType: "segmented", compatPath: "style.map.lineStyle" },
        { id: "base-direct-line", label: "直线", actionId: "painter", value: "straight", controlType: "segmented", compatPath: "style.map.lineStyle" },
        { id: "base-rainbow-lines", label: "彩虹线条", actionId: "painter", value: { enabled: true }, controlType: "toggle", compatPath: "style.map.rainbowLines" },
        { id: "base-line-width-2", label: "线宽 2", actionId: "painter", value: 2, controlType: "segmented", compatPath: "style.map.lineWidth" },
        { id: "base-line-width-4", label: "线宽 4", actionId: "painter", value: 4, controlType: "segmented", compatPath: "style.map.lineWidth" },
        { id: "base-background-light", label: "浅色背景", actionId: "painter", value: "#f8fafc", controlType: "swatch", preview: "#f8fafc", compatPath: "style.map.backgroundColor" },
        { id: "base-node-spacing-36", label: "节点间距 36", actionId: "painter", value: 36, controlType: "numberInput", compatPath: "style.map.nodeSpacing" },
        { id: "base-summary-bracket", label: "括号概要", actionId: "painter", value: "bracket", controlType: "select", compatPath: "style.map.summaryStyle" },
      ],
    },
    {
      id: "theme",
      kind: "theme",
      label: "主题",
      icon: "swatch",
      runtimeCapability: "theme",
      phase: 1,
      options: [


        { id: "theme-classic", label: "Classic", actionId: "setTheme", value: "classic", controlType: "swatch", preview: "#60a5fa", description: "默认主题" },
        { id: "theme-classic4", label: "KMind", actionId: "setTheme", value: "classic4", controlType: "swatch", preview: "#22c55e", description: "KMind-like" },
        { id: "theme-simple", label: "Simple", actionId: "setTheme", value: "simple", controlType: "swatch", preview: "#f59e0b", description: "轻量主题" },
        { id: "theme-dark", label: "Dark", actionId: "setTheme", value: "dark", controlType: "swatch", preview: "#334155", description: "深色主题" },
      ],
    },
    {
      id: "structure",
      kind: "structure",
      label: "结构",
      icon: "layout",
      runtimeCapability: "layout",
      phase: 1,
      options: [
        { id: "layout-logical", label: "逻辑结构图", actionId: "setLayout", value: "logicalStructure", controlType: "layoutCard", preview: "logicalStructure" },
        { id: "layout-mind-map", label: "思维导图", actionId: "setLayout", value: "mindMap", controlType: "layoutCard", preview: "mindMap" },
        { id: "layout-organization", label: "组织结构图", actionId: "setLayout", value: "organizationStructure", controlType: "layoutCard", preview: "organizationStructure" },
        { id: "layout-catalog", label: "目录组织图", actionId: "setLayout", value: "catalogOrganization", controlType: "layoutCard", preview: "catalogOrganization" },


{
      id: "structure",
      kind: "structure",
      label: "结构",
      icon: "layout",
      runtimeCapability: "layout",
      phase: 1,
      options: [
        { id: "layout-logical", label: "逻辑结构图", actionId: "setLayout", value: "logicalStructure", controlType: "layoutCard", preview: "logicalStructure" },
        { id: "layout-mind-map", label: "思维导图", actionId: "setLayout", value: "mindMap", controlType: "layoutCard", preview: "mindMap" },
        { id: "layout-organization", label: "组织结构图", actionId: "setLayout", value: "organizationStructure", controlType: "layoutCard", preview: "organizationStructure" },
        { id: "layout-catalog", label: "目录组织图", actionId: "setLayout", value: "catalogOrganization", controlType: "layoutCard", preview: "catalogOrganization" },
        { id: "layout-timeline", label: "时间轴", actionId: "setLayout", value: "timeline", controlType: "layoutCard", preview: "timeline" },
        { id: "layout-fishbone", label: "鱼骨图", actionId: "setLayout", value: "fishbone", controlType: "layoutCard", preview: "fishbone" },
      ],
    },
    {
      id: "outline",
      kind: "outline",
      label: "大纲",
      icon: "list-tree",
      runtimeCapability: "outline",
      phase: 1,
      options: [],
    },
    {
      id: "shortcutKey",
      kind: "shortcutKey",
      label: "快捷键",
      icon: "sparkles",
      runtimeCapability: "shortcut-key",
      phase: 1,
      options: [
        { id: "shortcut-insert-child", label: "Tab", description: "插入子节点", actionId: null, value: null, controlType: "treeItem", readonly: true },
        { id: "shortcut-insert-sibling", label: "Enter", description: "插入同级节点", actionId: null, value: null, controlType: "treeItem", readonly: true },
        { id: "shortcut-delete", label: "Delete", description: "删除节点", actionId: null, value: null, controlType: "treeItem", readonly: true },
      ],
    },
    {
      id: "settings",
      kind: "settings",
      label: "设置",
      icon: "hexagon",
      runtimeCapability: "settings",
      phase: 1,
      options: [
        { id: "settings-readonly-hint", label: "只读模式", description: "导航栏切换", actionId: null, value: null, controlType: "toggle", readonly: true },
        { id: "settings-mouse", label: "鼠标行为", description: "左键选中，右键拖拽", actionId: null, value: "leftSelectRightDrag", controlType: "select", readonly: true },
      ],
    },
  ],
  navigatorItems: [
    { id: "stats", label: "统计", actionId: null, readOnly: true, displayMode: "text" },
    { id: "centerRoot", label: "回根节点", actionId: "centerRoot", readOnly: true, displayMode: "button" },
    { id: "search", label: "搜索", actionId: "search", readOnly: true, displayMode: "button" },
    { id: "zoomOut", label: "缩小", actionId: "zoomOut", readOnly: true, displayMode: "button" },
    { id: "zoom", label: "缩放", actionId: null, readOnly: true, displayMode: "input" },
    { id: "zoomIn", label: "放大", actionId: "zoomIn", readOnly: true, displayMode: "button" },
    { id: "fullscreen", label: "全屏", actionId: "fullscreenCanvas", readOnly: true, displayMode: "button" },
    { id: "readonly", label: "只读", actionId: "readonly", readOnly: true, displayMode: "button" },


],
    },
  ],
  navigatorItems: [
    { id: "stats", label: "统计", actionId: null, readOnly: true, displayMode: "text" },
    { id: "centerRoot", label: "回根节点", actionId: "centerRoot", readOnly: true, displayMode: "button" },
    { id: "search", label: "搜索", actionId: "search", readOnly: true, displayMode: "button" },
    { id: "zoomOut", label: "缩小", actionId: "zoomOut", readOnly: true, displayMode: "button" },
    { id: "zoom", label: "缩放", actionId: null, readOnly: true, displayMode: "input" },
    { id: "zoomIn", label: "放大", actionId: "zoomIn", readOnly: true, displayMode: "button" },
    { id: "fullscreen", label: "全屏", actionId: "fullscreenCanvas", readOnly: true, displayMode: "button" },
    { id: "readonly", label: "只读", actionId: "readonly", readOnly: true, displayMode: "button" },
  ],
  contextMenuItems: [
    { id: "insertChild", label: "插入子节点", actionId: "insertChild", requiresNode: true, phase: 1 },
    { id: "insertSiblingAfter", label: "插入同级节点", actionId: "insertSiblingAfter", requiresNode: true, phase: 1 },
    { id: "deleteNode", label: "删除节点", actionId: "deleteNode", requiresNode: true, phase: 1 },
    { id: "summary", label: "概要", actionId: "summary", requiresNode: true, phase: 1 },
    { id: "associativeLine", label: "关联线", actionId: "associativeLine", requiresNode: true, phase: 1 },
    { id: "expandCollapse", label: "展开/收起", actionId: "expandCollapse", requiresNode: true, phase: 1 },
    { id: "copyNodeText", label: "复制文本", actionId: "copyNodeText", requiresNode: true, phase: 1 },
    { id: "centerRoot", label: "回根节点", actionId: "centerRoot", requiresNode: false, phase: 1 },
    { id: "fitView", label: "适应画布", actionId: "fitView", requiresNode: false, phase: 1 },
    { id: "search", label: "搜索", actionId: "search", requiresNode: false, phase: 1 },
    { id: "readonly", label: "只读切换", actionId: "readonly", requiresNode: false, phase: 1 },
    { id: "showMenu", label: "显示菜单", actionId: "showMenu", requiresNode: false, phase: 1 },
  ],
};

export const isMindmapDebugChromeEnabled = (href: string): boolean => {
  try {
    const value = new URL(href).searchParams.get(MINDMAP_DEBUG_CHROME_QUERY_PARAM);
    return value === "1" || value === "true";
  } catch {
    return false;
  }
};


import { describe, expect, it } from "vitest";
import {
  createDefaultMindmapShellInteractionState,
  deriveMindmapUiState,
  reduceMindmapChromeVisibility,
} from "./mindmap-ui-state";

describe("mindmap UI state", () => {
  it("无 active node 时禁用节点编辑动作，但保留视图动作", () => {
    const state = deriveMindmapUiState({ activeNodeId: null, readonly: false });

    expect(state.disabledActions.insertChild).toBe(true);
    expect(state.disabledActions.insertSiblingAfter).toBe(true);
    expect(state.disabledActions.deleteNode).toBe(true);
    expect(state.disabledActions.expandCollapse).toBe(true);
    expect(state.disabledActions.zoomIn).toBe(false);
    expect(state.disabledActions.centerRoot).toBe(false);
    expect(state.disabledActions.fullscreenCanvas).toBe(false);
    expect(state.disabledActions.exitFullscreen).toBe(false);
  });

  it("readonly 时禁用编辑动作，保留搜索和视图动作", () => {
    const state = deriveMindmapUiState({ activeNodeId: "node_1", readonly: true });

    expect(state.disabledActions.insertChild).toBe(true);
    expect(state.disabledActions.note).toBe(true);
    expect(state.disabledActions.setTheme).toBe(true);
    expect(state.disabledActions.expandCollapse).toBe(true);
    expect(state.disabledActions.copyNodeText).toBe(false);
    expect(state.disabledActions.search).toBe(false);
    expect(state.disabledActions.zoomOut).toBe(false);
    expect(state.disabledActions.fullscreenCanvas).toBe(false);
    expect(state.disabledActions.fullscreenPage).toBe(false);
  });

  it("缺少 runtime capability 时禁用对应 action", () => {
    const state = deriveMindmapUiState({
      activeNodeId: "node_1",
      readonly: false,
      runtimeCapabilities: ["runtimeCommand", "kernelCommand"],
    });

    expect(state.disabledActions.note).toBe(true);
    expect(state.disabledActions.insertChild).toBe(false);
  });

  it("导出能力暂缓时在 UI state 中保持禁用", () => {
    const state = deriveMindmapUiState({ activeNodeId: "node_1", readonly: false });

    expect(state.disabledActions.export).toBe(true);
  });

  it("派生默认 shell state，给 Leptos shell 提供稳定合同", () => {
    const state = deriveMindmapUiState({
      activeNodeId: "node_1",
      readonly: true,
      shell: {
        chromeVisibility: "hiddenByPointerLeave",
        toolbarOverflow: {
          availableWidth: 640,


expect(state.disabledActions.export).toBe(true);
  });

  it("派生默认 shell state，给 Leptos shell 提供稳定合同", () => {
    const state = deriveMindmapUiState({
      activeNodeId: "node_1",
      readonly: true,
      shell: {
        chromeVisibility: "hiddenByPointerLeave",
        toolbarOverflow: {
          availableWidth: 640,
          visibleActionIds: ["undo", "redo"],
          overflowActionIds: ["image"],
          moreOpen: true,
        },
        fullscreen: {
          mode: "canvas",
          isFullscreen: true,
          apiAvailable: false,
        },
        sidebar: {
          triggerVisible: false,
          panelOpen: false,
          activePanelId: "structure",
          collapsedByToggle: true,
        },
        navigator: {
          searchOpen: true,
          minimapOpen: true,
          zoomPercent: 138.4,
        },
      },
    });

    expect(state.shell.chromeVisibility).toBe("hiddenByPointerLeave");
    expect(state.shell.toolbarOverflow).toMatchObject({
      availableWidth: 640,
      visibleActionIds: ["undo", "redo"],
      overflowActionIds: ["image"],
      moreOpen: true,
    });
    expect(state.shell.fullscreen).toMatchObject({
      mode: "canvas",
      isFullscreen: true,
      target: "mindmap-root",
      apiAvailable: false,
    });
    expect(state.shell.sidebar).toMatchObject({
      triggerVisible: false,
      panelOpen: false,
      activePanelId: "structure",
      drawerWidth: 300,
      collapsedByToggle: true,
    });
    expect(state.shell.navigator).toMatchObject({
      searchOpen: true,
      minimapOpen: true,
      readonly: true,
      zoomPercent: 138,


expect(state.shell.sidebar).toMatchObject({
      triggerVisible: false,
      panelOpen: false,
      activePanelId: "structure",
      drawerWidth: 300,
      collapsedByToggle: true,
    });
    expect(state.shell.navigator).toMatchObject({
      searchOpen: true,
      minimapOpen: true,
      readonly: true,
      zoomPercent: 138,
      mouseBehavior: "leftSelectRightDrag",
    });
  });

  it("鼠标移出隐藏 chrome，鼠标移入不自动恢复，点击才恢复", () => {
    const hidden = reduceMindmapChromeVisibility("visible", "pointerLeave");
    expect(hidden).toBe("hiddenByPointerLeave");
    expect(reduceMindmapChromeVisibility(hidden, "pointerEnter")).toBe("hiddenByPointerLeave");
    expect(reduceMindmapChromeVisibility(hidden, "restoreClick")).toBe("visible");
  });

  it("sidebar 隐藏触发条后保留最近 active panel", () => {
    const state = createDefaultMindmapShellInteractionState({
      sidebar: {
        triggerVisible: false,
        panelOpen: false,
        activePanelId: "structure",
        collapsedByToggle: true,
      },
    });

    expect(state.sidebar.triggerVisible).toBe(false);
    expect(state.sidebar.panelOpen).toBe(false);
    expect(state.sidebar.activePanelId).toBe("structure");
    expect(state.sidebar.collapsedByToggle).toBe(true);

    const restored = createDefaultMindmapShellInteractionState({
      sidebar: {
        ...state.sidebar,
        triggerVisible: true,
        panelOpen: true,
        collapsedByToggle: false,
      },
    });
    expect(restored.sidebar.activePanelId).toBe("structure");
  });
});


import { getMindmapActionMapping, listMindmapActionMappings, type MindmapActionTarget } from "./mindmap-action-map";
import { mindmapDefaultUiSchema, type MindmapUiActionId } from "./mindmap-ui-schema";

export type MindmapRuntimeCapability = MindmapActionTarget;

export type MindmapChromeVisibilityState =
  | "visible"
  | "hiddenByPointerLeave"
  | "hiddenByToggle"
  | "hiddenByFullscreen";

export type MindmapToolbarOverflowState = {
  availableWidth: number | null;
  visibleActionIds: MindmapUiActionId[];
  overflowActionIds: MindmapUiActionId[];
  moreOpen: boolean;
};

export type MindmapFullscreenState = {
  mode: "none" | "canvas" | "page";
  isFullscreen: boolean;
  target: "mindmap-root" | "document-body";
  apiAvailable: boolean;
};

export type MindmapSidebarState = {
  triggerVisible: boolean;
  panelOpen: boolean;
  activePanelId: string | null;
  drawerWidth: number;
  collapsedByToggle: boolean;
};

export type MindmapNavigatorState = {
  searchOpen: boolean;
  minimapOpen: boolean;
  readonly: boolean;
  zoomPercent: number;
  mouseBehavior: "leftSelectRightDrag" | "leftDragRightMenu";
};

export type MindmapShellInteractionState = {
  chromeVisibility: MindmapChromeVisibilityState;
  toolbarOverflow: MindmapToolbarOverflowState;
  fullscreen: MindmapFullscreenState;
  sidebar: MindmapSidebarState;
  navigator: MindmapNavigatorState;
};

export type MindmapUiStateInput = {
  activeNodeId?: string | null;
  readonly: boolean;
  runtimeCapabilities?: MindmapRuntimeCapability[];
  shell?: PartialMindmapShellInteractionState;
};

export type MindmapUiState = {
  activeNodeId: string | null;
  readonly: boolean;
  disabledActions: Record<MindmapUiActionId, boolean>;


export type MindmapUiStateInput = {
  activeNodeId?: string | null;
  readonly: boolean;
  runtimeCapabilities?: MindmapRuntimeCapability[];
  shell?: PartialMindmapShellInteractionState;
};

export type MindmapUiState = {
  activeNodeId: string | null;
  readonly: boolean;
  disabledActions: Record<MindmapUiActionId, boolean>;
  shell: MindmapShellInteractionState;
};

export type PartialMindmapShellInteractionState = {
  chromeVisibility?: MindmapChromeVisibilityState;
  toolbarOverflow?: Partial<MindmapToolbarOverflowState>;
  fullscreen?: Partial<MindmapFullscreenState>;
  sidebar?: Partial<MindmapSidebarState>;
  navigator?: Partial<MindmapNavigatorState>;
};

export type MindmapChromeVisibilityEvent =
  | "pointerLeave"
  | "pointerEnter"
  | "restoreClick"
  | "hideToggle"
  | "showToggle"
  | "enterFullscreen"
  | "exitFullscreen";

const allActionIds = (): MindmapUiActionId[] => {
  const ids = [
    ...mindmapDefaultUiSchema.toolbarGroups.flatMap((group) => group.actions),
    ...mindmapDefaultUiSchema.navigatorItems.flatMap((item) => (item.actionId ? [item.actionId] : [])),
    ...mindmapDefaultUiSchema.contextMenuItems.map((item) => item.actionId),
    ...listMindmapActionMappings().map((mapping) => mapping.actionId),
  ];
  return [...new Set(ids)];
};

const normalizeActiveNodeId = (value: string | null | undefined): string | null => {
  const trimmed = value?.trim();
  return trimmed ? trimmed : null;
};

const normalizeZoomPercent = (value: number | undefined): number => {
  if (typeof value !== "number" || !Number.isFinite(value)) return 100;
  return Math.max(10, Math.min(500, Math.round(value)));
};

const unsupportedActionIds = new Set<MindmapUiActionId>([
  "tag",
  "hyperlink",
  "note",
  "image",
  "icon",
  "associativeLine",
  "formula",


if (typeof value !== "number" || !Number.isFinite(value)) return 100;
  return Math.max(10, Math.min(500, Math.round(value)));
};

const unsupportedActionIds = new Set<MindmapUiActionId>([
  "tag",
  "hyperlink",
  "note",
  "image",
  "icon",
  "associativeLine",
  "formula",
  "import",
  "export",
]);

export const createDefaultMindmapShellInteractionState = (
  input: PartialMindmapShellInteractionState = {},
): MindmapShellInteractionState => ({
  chromeVisibility: input.chromeVisibility ?? "visible",
  toolbarOverflow: {
    availableWidth: input.toolbarOverflow?.availableWidth ?? null,
    visibleActionIds: input.toolbarOverflow?.visibleActionIds ?? [],
    overflowActionIds: input.toolbarOverflow?.overflowActionIds ?? [],
    moreOpen: input.toolbarOverflow?.moreOpen ?? false,
  },
  fullscreen: {
    mode: input.fullscreen?.mode ?? "none",
    isFullscreen: input.fullscreen?.isFullscreen ?? false,
    target: input.fullscreen?.target ?? "mindmap-root",
    apiAvailable: input.fullscreen?.apiAvailable ?? true,
  },
  sidebar: {
    triggerVisible: input.sidebar?.triggerVisible ?? true,
    panelOpen: input.sidebar?.panelOpen ?? true,
    activePanelId: input.sidebar?.activePanelId ?? mindmapDefaultUiSchema.sidebarPanels[0]?.id ?? null,
    drawerWidth: input.sidebar?.drawerWidth ?? 300,
    collapsedByToggle: input.sidebar?.collapsedByToggle ?? false,
  },
  navigator: {
    searchOpen: input.navigator?.searchOpen ?? false,
    minimapOpen: input.navigator?.minimapOpen ?? false,
    readonly: input.navigator?.readonly ?? false,
    zoomPercent: normalizeZoomPercent(input.navigator?.zoomPercent),
    mouseBehavior: input.navigator?.mouseBehavior ?? "leftSelectRightDrag",
  },
});

export const reduceMindmapChromeVisibility = (
  state: MindmapChromeVisibilityState,
  event: MindmapChromeVisibilityEvent,
): MindmapChromeVisibilityState => {
  if (event === "pointerLeave") return "hiddenByPointerLeave";
  if (event === "pointerEnter") return state;
  if (event === "restoreClick") return "visible";
  if (event === "hideToggle") return "hiddenByToggle";
  if (event === "showToggle") return "visible";
  if (event === "enterFullscreen") return state === "visible" ? "visible" : "hiddenByFullscreen";
  if (event === "exitFullscreen") return state === "hiddenByFullscreen" ? "visible" : state;
  return state;


export const reduceMindmapChromeVisibility = (
  state: MindmapChromeVisibilityState,
  event: MindmapChromeVisibilityEvent,
): MindmapChromeVisibilityState => {
  if (event === "pointerLeave") return "hiddenByPointerLeave";
  if (event === "pointerEnter") return state;
  if (event === "restoreClick") return "visible";
  if (event === "hideToggle") return "hiddenByToggle";
  if (event === "showToggle") return "visible";
  if (event === "enterFullscreen") return state === "visible" ? "visible" : "hiddenByFullscreen";
  if (event === "exitFullscreen") return state === "hiddenByFullscreen" ? "visible" : state;
  return state;
};

export const deriveMindmapUiState = (input: MindmapUiStateInput): MindmapUiState => {
  const activeNodeId = normalizeActiveNodeId(input.activeNodeId);
  const capabilities = input.runtimeCapabilities ? new Set<MindmapRuntimeCapability>(input.runtimeCapabilities) : null;
  const disabledActions = {} as Record<MindmapUiActionId, boolean>;
  const shell = createDefaultMindmapShellInteractionState({
    ...input.shell,
    navigator: {
      ...input.shell?.navigator,
      readonly: input.readonly,
    },
  });

  allActionIds().forEach((actionId) => {
    const mapping = getMindmapActionMapping(actionId);
    if (!mapping) {
      disabledActions[actionId] = true;
      return;
    }
    if (mapping.requiresActiveNode && !activeNodeId) {
      disabledActions[actionId] = true;
      return;
    }
    if (input.readonly && !mapping.readonlyAllowed) {
      disabledActions[actionId] = true;
      return;
    }
    if (capabilities && !capabilities.has(mapping.target)) {
      disabledActions[actionId] = true;
      return;
    }
    if (unsupportedActionIds.has(actionId)) {
      disabledActions[actionId] = true;
      return;
    }
    disabledActions[actionId] = false;
  });

  return {
    activeNodeId,
    readonly: input.readonly,
    disabledActions,
    shell,
  };
};


import "server-only";

import path from "path";
import { promises as fs } from "fs";
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";

export function resolveMindmapFileName(mindmapId: string) {
  if (mindmapId === "legacy" || mindmapId.startsWith("legacy-")) {
    return "mindmap.json";
  }
  return `mindmap-${mindmapId}.json`;
}

async function ensureDir(dir: string) {
  await fs.mkdir(dir, { recursive: true });
}

async function ensureIndexFile(folder: string, title = "无标题") {
  const indexFile = path.join(folder, "index.md");
  try {
    await fs.access(indexFile);
  } catch {
    await fs.writeFile(indexFile, `# ${title}\n`, "utf8");
  }
}

async function tryReadJson(file: string) {
  try {
    const content = await fs.readFile(file, "utf8");
    return JSON.parse(content);
  } catch {
    return null;
  }
}

export type ReadMindmapResult =
  | { ok: true; data: any; source: "preferred" | "legacy" }
  | { ok: false; data: null; source: null };

export async function readMindmapLocal(docId: string, mindmapId: string): Promise<ReadMindmapResult> {
  const preferredBaseDir = getDocumentsBaseDir();
  const legacyBaseDir = getLegacyMindmapsBaseDir();
  const preferredFolder = path.join(preferredBaseDir, docId);
  const preferredFile = path.join(preferredFolder, resolveMindmapFileName(mindmapId));
  const preferredLegacy = path.join(preferredFolder, "mindmap.json");
  const legacyDirFile =
    path.basename(preferredFile) === "mindmap.json"
      ? path.join(legacyBaseDir, docId, "mindmap.json")
      : null;

  const data =
    (await tryReadJson(preferredFile)) ??
    (await tryReadJson(preferredLegacy)) ??
    (legacyDirFile ? await tryReadJson(legacyDirFile) : null);

  if (data) return { ok: true, data, source: "preferred" };
  return { ok: false, data: null, source: null };
}

export async function writeMindmapLocal(


: null;

  const data =
    (await tryReadJson(preferredFile)) ??
    (await tryReadJson(preferredLegacy)) ??
    (legacyDirFile ? await tryReadJson(legacyDirFile) : null);

  if (data) return { ok: true, data, source: "preferred" };
  return { ok: false, data: null, source: null };
}

export async function writeMindmapLocal(
  docId: string,
  mindmapId: string,
  data: unknown,
  docTitle: string,
) {
  const folder = path.join(getDocumentsBaseDir(), docId);
  const file = path.join(folder, resolveMindmapFileName(mindmapId));
  await ensureDir(folder);
  await ensureIndexFile(folder, docTitle || "无标题");
  await fs.writeFile(file, JSON.stringify(data ?? { data: { text: "中心主题" }, children: [] }, null, 2), "utf8");
  return { folder, file };
}


import "server-only";

export type NodeRef = {
  kind: "pdf" | "docx" | "pptx" | "url";
  assetId?: string;
  fileUrl?: string;
  page?: number; // 1-based
  slide?: number; // 1-based
  title?: string;
  snippet?: string;
};

export type MindmapNodeData = {
  uid?: string;
  text?: string;
  hyperlink?: string;
  note?: string;
  refs?: NodeRef[];
  [k: string]: unknown;
};

export type MindmapTreeNode = {
  data: MindmapNodeData;
  children?: MindmapTreeNode[];
  [k: string]: unknown;
};

export type MindmapOp =
  | {
      op: "addChild";
      parentUid: string;
      node: { uid?: string; text: string; hyperlink?: string; refs?: NodeRef[]; note?: string };
    }
  | {
      op: "addSiblingAfter";
      targetUid: string;
      node: { uid?: string; text: string; hyperlink?: string; refs?: NodeRef[]; note?: string };
    }
  | { op: "updateText"; uid: string; text: string }
  | { op: "setHyperlink"; uid: string; hyperlink: string | null }
  | { op: "setRefs"; uid: string; refs: NodeRef[] }
  | { op: "appendNote"; uid: string; markdown: string }
  | { op: "deleteNode"; uid: string };

export type MindmapOpErrorKind = "rejected" | "failed";

const createUid = () => {
  const anyCrypto = globalThis.crypto as unknown as { randomUUID?: () => string } | undefined;
  if (anyCrypto?.randomUUID) return anyCrypto.randomUUID();
  return Math.random().toString(36).slice(2);
};

export const ensureMindmapUids = (root: MindmapTreeNode) => {
  const walk = (node: MindmapTreeNode) => {
    if (!node.data) node.data = {};
    if (!node.data.uid) node.data.uid = createUid();
    if (typeof node.data.text !== "string") node.data.text = String(node.data.text ?? "新节点");
    if (!Array.isArray(node.children)) node.children = [];
    node.children.forEach(walk);
  };


if (anyCrypto?.randomUUID) return anyCrypto.randomUUID();
  return Math.random().toString(36).slice(2);
};

export const ensureMindmapUids = (root: MindmapTreeNode) => {
  const walk = (node: MindmapTreeNode) => {
    if (!node.data) node.data = {};
    if (!node.data.uid) node.data.uid = createUid();
    if (typeof node.data.text !== "string") node.data.text = String(node.data.text ?? "新节点");
    if (!Array.isArray(node.children)) node.children = [];
    node.children.forEach(walk);
  };
  walk(root);
  return root;
};

type Indexed = {
  node: MindmapTreeNode;
  parent: MindmapTreeNode | null;
  index: number;
};

const buildIndex = (root: MindmapTreeNode) => {
  const map = new Map<string, Indexed>();
  const walk = (node: MindmapTreeNode, parent: MindmapTreeNode | null) => {
    const uid = String(node?.data?.uid || "");
    if (uid) map.set(uid, { node, parent, index: -1 });
    const children = Array.isArray(node.children) ? node.children : [];
    children.forEach((child, idx) => {
      const cuid = String(child?.data?.uid || "");
      if (cuid) map.set(cuid, { node: child, parent: node, index: idx });
      walk(child, node);
    });
  };
  walk(root, null);
  return map;
};

const safeUrlOrNull = (value: unknown) => {
  const s = typeof value === "string" ? value.trim() : "";
  if (!s) return null;
  try {
    const u = new URL(s);
    if (u.protocol !== "http:" && u.protocol !== "https:") return null;
    return u.toString();
  } catch {
    return null;
  }
};

export const classifyMindmapOpErrors = (errors: string[]): MindmapOpErrorKind | null => {
  if (!Array.isArray(errors) || errors.length === 0) return null;
  const rejectedPatterns = [
    "无效 op",
    "找不到",
    "不能删除根节点",
    "不支持的 op",
  ];
  const rejectedOnly = errors.every((error) =>
    rejectedPatterns.some((pattern) => String(error || "").includes(pattern)),


};

export const classifyMindmapOpErrors = (errors: string[]): MindmapOpErrorKind | null => {
  if (!Array.isArray(errors) || errors.length === 0) return null;
  const rejectedPatterns = [
    "无效 op",
    "找不到",
    "不能删除根节点",
    "不支持的 op",
  ];
  const rejectedOnly = errors.every((error) =>
    rejectedPatterns.some((pattern) => String(error || "").includes(pattern)),
  );
  return rejectedOnly ? "rejected" : "failed";
};

export const applyMindmapOps = (
  raw: unknown,
  ops: MindmapOp[],
): { data: MindmapTreeNode; applied: number; errors: string[] } => {
  const root = (raw && typeof raw === "object" ? (raw as MindmapTreeNode) : null) ?? {
    data: { text: "中心主题" },
    children: [],
  };
  ensureMindmapUids(root);

  const errors: string[] = [];
  let applied = 0;

  for (const op of ops) {
    ensureMindmapUids(root);
    const index = buildIndex(root);

    if (!op || typeof op !== "object" || !("op" in op)) {
      errors.push("无效 op");
      continue;
    }

    if (op.op === "addChild") {
      const parent = index.get(op.parentUid)?.node ?? null;
      if (!parent) {
        errors.push(`addChild: 找不到 parentUid=${op.parentUid}`);
        continue;
      }
      if (!Array.isArray(parent.children)) parent.children = [];
      const uid = op.node.uid || createUid();
      parent.children.push({
        data: {
          uid,
          text: String(op.node.text ?? "新节点"),
          ...(op.node.note ? { note: String(op.node.note) } : {}),
          ...(op.node.refs ? { refs: op.node.refs } : {}),
          ...(op.node.hyperlink ? { hyperlink: safeUrlOrNull(op.node.hyperlink) ?? undefined } : {}),
        },
        children: [],
      });
      applied += 1;
      continue;
    }


uid,
          text: String(op.node.text ?? "新节点"),
          ...(op.node.note ? { note: String(op.node.note) } : {}),
          ...(op.node.refs ? { refs: op.node.refs } : {}),
          ...(op.node.hyperlink ? { hyperlink: safeUrlOrNull(op.node.hyperlink) ?? undefined } : {}),
        },
        children: [],
      });
      applied += 1;
      continue;
    }

    if (op.op === "addSiblingAfter") {
      const hit = index.get(op.targetUid);
      if (!hit?.parent) {
        errors.push(`addSiblingAfter: 找不到 targetUid=${op.targetUid} 或无父节点（不能对根节点加同级）`);
        continue;
      }
      if (!Array.isArray(hit.parent.children)) hit.parent.children = [];
      const uid = op.node.uid || createUid();
      const insertAt = Math.max(0, Math.min(hit.parent.children.length, hit.index + 1));
      hit.parent.children.splice(insertAt, 0, {
        data: {
          uid,
          text: String(op.node.text ?? "新节点"),
          ...(op.node.note ? { note: String(op.node.note) } : {}),
          ...(op.node.refs ? { refs: op.node.refs } : {}),
          ...(op.node.hyperlink ? { hyperlink: safeUrlOrNull(op.node.hyperlink) ?? undefined } : {}),
        },
        children: [],
      });
      applied += 1;
      continue;
    }

    if (op.op === "updateText") {
      const hit = index.get(op.uid)?.node ?? null;
      if (!hit) {
        errors.push(`updateText: 找不到 uid=${op.uid}`);
        continue;
      }
      hit.data = hit.data || {};
      hit.data.text = String(op.text ?? "");
      applied += 1;
      continue;
    }

    if (op.op === "setHyperlink") {
      const hit = index.get(op.uid)?.node ?? null;
      if (!hit) {
        errors.push(`setHyperlink: 找不到 uid=${op.uid}`);
        continue;
      }
      hit.data = hit.data || {};
      const url = safeUrlOrNull(op.hyperlink);
      if (!url) {
        delete (hit.data as any).hyperlink;
      } else {
        hit.data.hyperlink = url;
      }


const hit = index.get(op.uid)?.node ?? null;
      if (!hit) {
        errors.push(`setHyperlink: 找不到 uid=${op.uid}`);
        continue;
      }
      hit.data = hit.data || {};
      const url = safeUrlOrNull(op.hyperlink);
      if (!url) {
        delete (hit.data as any).hyperlink;
      } else {
        hit.data.hyperlink = url;
      }
      applied += 1;
      continue;
    }

    if (op.op === "setRefs") {
      const hit = index.get(op.uid)?.node ?? null;
      if (!hit) {
        errors.push(`setRefs: 找不到 uid=${op.uid}`);
        continue;
      }
      hit.data = hit.data || {};
      hit.data.refs = Array.isArray(op.refs) ? op.refs : [];
      applied += 1;
      continue;
    }

    if (op.op === "appendNote") {
      const hit = index.get(op.uid)?.node ?? null;
      if (!hit) {
        errors.push(`appendNote: 找不到 uid=${op.uid}`);
        continue;
      }
      hit.data = hit.data || {};
      const prev = typeof hit.data.note === "string" ? hit.data.note : "";
      const next = String(op.markdown ?? "");
      hit.data.note = prev ? `${prev}\n\n${next}` : next;
      applied += 1;
      continue;
    }

    if (op.op === "deleteNode") {
      const hit = index.get(op.uid);
      if (!hit) {
        errors.push(`deleteNode: 找不到 uid=${op.uid}`);
        continue;
      }
      if (!hit.parent) {
        errors.push("deleteNode: 不能删除根节点");
        continue;
      }
      if (!Array.isArray(hit.parent.children)) hit.parent.children = [];
      hit.parent.children = hit.parent.children.filter((c) => String(c?.data?.uid || "") !== op.uid);
      applied += 1;
      continue;
    }

    errors.push(`不支持的 op: ${(op as any).op}`);
  }


if (!hit.parent) {
        errors.push("deleteNode: 不能删除根节点");
        continue;
      }
      if (!Array.isArray(hit.parent.children)) hit.parent.children = [];
      hit.parent.children = hit.parent.children.filter((c) => String(c?.data?.uid || "") !== op.uid);
      applied += 1;
      continue;
    }

    errors.push(`不支持的 op: ${(op as any).op}`);
  }

  ensureMindmapUids(root);
  return { data: root, applied, errors };
};


import { randomUUID } from "crypto";

export type MindmapRouteMeta = {
  requestId: string;
  traceId: string;
  workspaceId: string | null;
  documentId: string;
  pageId: string;
  mindmapId: string;
  attachmentId: string;
  ownerUserId: string;
  source: "convex";
};

function readHeaderValue(headerList: Headers, ...candidates: string[]): string | null {
  for (const candidate of candidates) {
    const value = headerList.get(candidate);
    if (typeof value === "string" && value.trim()) {
      return value.trim();
    }
  }
  return null;
}

function makeFallbackId(prefix: string): string {
  return `${prefix}_${randomUUID()}`;
}

export function buildMindmapRouteMeta(
  request: Request,
  input: {
    workspaceId?: string | null;
    documentId: string;
    mindmapId: string;
    ownerUserId: string;
  },
): MindmapRouteMeta {
  const requestId =
    readHeaderValue(request.headers, "x-request-id", "x-mnote-request-id") ??
    makeFallbackId("req");
  const traceId =
    readHeaderValue(request.headers, "x-trace-id", "x-mnote-trace-id", "x-request-id") ??
    makeFallbackId("trace");

  return {
    requestId,
    traceId,
    workspaceId: input.workspaceId?.trim() || null,
    documentId: input.documentId,
    pageId: input.documentId,
    mindmapId: input.mindmapId,
    attachmentId: input.mindmapId,
    ownerUserId: input.ownerUserId,
    source: "convex",
  };
}


import { describe, expect, it } from "vitest";
import {
  SIMPLE_MIND_MAP_BRIDGE_EVENTS,
  SIMPLE_MIND_MAP_REQUIRED_PLUGINS,
  buildSimpleMindMapOptions,
  createFallbackMindmapAdapterProjection,
  createSafeMindmapCommandExecutor,
  defaultSimpleMindMapPluginNames,
} from "./simple-mind-map-bridge";

describe("simple-mind-map bridge contract", () => {
  it("登记 Phase 6 第一阶段必需插件和事件", () => {
    expect(SIMPLE_MIND_MAP_REQUIRED_PLUGINS).toEqual([
      "Drag",
      "KeyboardNavigation",
      "Export",
      "Select",
      "AssociativeLine",
      "Search",
      "OuterFrame",
    ]);
    expect(defaultSimpleMindMapPluginNames()).toEqual(
      expect.arrayContaining(["Scrollbar", "MiniMap", "Painter", "Formula"]),
    );
    expect(defaultSimpleMindMapPluginNames()).not.toContain("RichText");
    expect(SIMPLE_MIND_MAP_BRIDGE_EVENTS).toEqual([
      "data_change",
      "view_data_change",
      "node_active",
      "back_forward",
      "scale",
      "translate",
    ]);
  });

  it("从 adapter projection 构建 simple-mind-map 初始化参数", () => {
    const projection = createFallbackMindmapAdapterProjection({
      mindmapId: "mind_1",
      root: { data: { text: "KMIND" }, children: [] },
      kernelRevision: 7,
    });
    const options = buildSimpleMindMapOptions({
      el: {} as HTMLElement,
      projection: {
        ...projection,
        theme: { template: "classic4" },
        themeConfig: { root: { fillColor: "#c62828" } },
        view: { state: { scale: 0.8, x: 1, y: 2 }, transform: { scaleX: 0.8, scaleY: 0.8 } },
      },
    });

    expect(options.data).toEqual({ data: { text: "KMIND" }, children: [] });
    expect(options.layout).toBe("logicalStructure");
    expect(options.theme).toBe("classic4");
    expect(options.themeConfig).toEqual({ root: { fillColor: "#c62828" } });
    expect(options.viewData).toEqual({ state: { scale: 0.8, x: 1, y: 2 }, transform: { scaleX: 0.8, scaleY: 0.8 } });
  });

  it("fallback projection 默认使用 KMind 风格主题与右向逻辑结构", () => {
    const projection = createFallbackMindmapAdapterProjection({


},
    });

    expect(options.data).toEqual({ data: { text: "KMIND" }, children: [] });
    expect(options.layout).toBe("logicalStructure");
    expect(options.theme).toBe("classic4");
    expect(options.themeConfig).toEqual({ root: { fillColor: "#c62828" } });
    expect(options.viewData).toEqual({ state: { scale: 0.8, x: 1, y: 2 }, transform: { scaleX: 0.8, scaleY: 0.8 } });
  });

  it("fallback projection 默认使用 KMind 风格主题与右向逻辑结构", () => {
    const projection = createFallbackMindmapAdapterProjection({
      mindmapId: "mind_1",
      root: { data: { text: "KMIND" }, children: [] },
    });

    expect(projection.layout).toBe("logicalStructure");
    expect(projection.theme).toBe("default");
    expect(projection.themeConfig).toMatchObject({
      lineStyle: "curve",
      root: {
        fillColor: "#e25563",
        color: "#ffffff",
        fontWeight: "bold",
      },
      second: {
        fillColor: "#4f7df3",
        color: "#ffffff",
      },
      node: {
        color: "#315aa9",
      },
      generalizationLineColor: "#ef6a5b",
    });
  });

  it("保留 runtimeOptions 中的 fit 设置，避免初始导图被裁切", () => {
    const projection = createFallbackMindmapAdapterProjection({
      mindmapId: "mind_1",
      root: { data: { text: "KMIND" }, children: [] },
      kernelRevision: 7,
    });

    expect(
      buildSimpleMindMapOptions({
        el: {} as HTMLElement,
        projection,
      }).fit,
    ).toBe(true);
    expect(
      buildSimpleMindMapOptions({
        el: {} as HTMLElement,
        projection,
        runtimeOptions: { fit: false },
      }).fit,
    ).toBe(false);
  });

  it("忽略无效 viewData，避免 simple-mind-map 读取 viewData.state 时崩溃", () => {
    const projection = createFallbackMindmapAdapterProjection({


).toBe(true);
    expect(
      buildSimpleMindMapOptions({
        el: {} as HTMLElement,
        projection,
        runtimeOptions: { fit: false },
      }).fit,
    ).toBe(false);
  });

  it("忽略无效 viewData，避免 simple-mind-map 读取 viewData.state 时崩溃", () => {
    const projection = createFallbackMindmapAdapterProjection({
      mindmapId: "mind_1",
      root: { data: { text: "KMIND" }, children: [] },
      kernelRevision: 7,
    });
    const options = buildSimpleMindMapOptions({
      el: {} as HTMLElement,
      projection: { ...projection, view: {} },
    });

    expect(options.viewData).toBeNull();
  });

  it("安全命令包装只允许受控 runtime command", () => {
    const calls: unknown[][] = [];
    const exec = createSafeMindmapCommandExecutor({
      execCommand: (...args) => calls.push(args),
    });

    expect(exec("INSERT_CHILD_NODE", "node_1")).toMatchObject({ ok: true });
    expect(exec("SAVE_FULL_BLOB", { data: {} })).toEqual({
      ok: false,
      command: "SAVE_FULL_BLOB",
      error: "unsupported_command",
    });
    expect(calls).toEqual([["INSERT_CHILD_NODE", "node_1"]]);
  });
});


import {
  canonicalizeMindmapData,
  createDefaultMindmapThemeConfig,
  DEFAULT_MINDMAP_LAYOUT,
  DEFAULT_MINDMAP_THEME,
  defaultMindmapData,
  type MindMapData,
} from "./mindmap-projection";

export type MindmapAdapterProjection = {
  schema: "mnote.mindmap.simple_mind_map_scene.v1";
  runtime: "simple-mind-map" | string;
  root: unknown;
  layout?: unknown;
  theme?: unknown;
  themeConfig?: unknown;
  view?: unknown;
  config?: unknown;
  compatPayload?: unknown;
  kernelRevision: number;
};

export type SimpleMindMapInstance = {
  execCommand?: (command: string, ...args: unknown[]) => unknown;
  destroy?: () => void;
  getData?: (withConfig?: boolean) => unknown;
  getLayout?: () => unknown;
  getTheme?: () => unknown;
  getThemeConfig?: (prop?: unknown) => unknown;
  on?: (event: string, handler: (...args: unknown[]) => void) => void;
  off?: (event: string, handler: (...args: unknown[]) => void) => void;
  setData?: (data: unknown) => void;
  setLayout?: (layout: unknown, notRender?: boolean) => void;
  setMode?: (mode: "edit" | "readonly") => void;
  setTheme?: (theme: unknown, notRender?: boolean) => void;
  setThemeConfig?: (config: Record<string, unknown>, notRender?: boolean) => void;
  updateData?: (data: unknown) => void;
  updateConfig?: (config?: Record<string, unknown>) => void;
  view?: {
    scale?: number;
    enlarge?: () => void;
    narrow?: () => void;
    getTransformData?: () => unknown;
    setScale?: (scale: number, cx: number, cy: number) => void;
    fit?: () => void;
  };
  renderer?: {
    setRootNodeCenter?: () => void;
    textEdit?: {
      hideEditTextBox?: () => void;
      isShowTextEdit?: () => boolean;
    };
    clearActiveNodeList?: () => void;
    addNodeToActiveList?: (node: unknown, skipBeforeEvent?: boolean) => void;
    emitNodeActiveEvent?: (node?: unknown, activeNodeList?: unknown[]) => void;
    findNodeByUid?: (uid: string) => unknown;
    activeNodeList?: unknown[];
    lastActiveNodeList?: unknown[];
    root?: unknown;
    renderTree?: { _node?: unknown };


textEdit?: {
      hideEditTextBox?: () => void;
      isShowTextEdit?: () => boolean;
    };
    clearActiveNodeList?: () => void;
    addNodeToActiveList?: (node: unknown, skipBeforeEvent?: boolean) => void;
    emitNodeActiveEvent?: (node?: unknown, activeNodeList?: unknown[]) => void;
    findNodeByUid?: (uid: string) => unknown;
    activeNodeList?: unknown[];
    lastActiveNodeList?: unknown[];
    root?: unknown;
    renderTree?: { _node?: unknown };
  };
};

export type SimpleMindMapCtor = new (options: Record<string, unknown>) => SimpleMindMapInstance;

export type SimpleMindMapRuntime = {
  MindMap: SimpleMindMapCtor;
  registeredPlugins: string[];
};

export type SimpleMindMapBridgeEventName =
  | "data_change"
  | "view_data_change"
  | "node_active"
  | "back_forward"
  | "scale"
  | "translate";

export type SimpleMindMapBridgeEvent = {
  type: SimpleMindMapBridgeEventName;
  args: unknown[];
  snapshot?: unknown;
  kernelRevision: number;
};

export type SimpleMindMapBridge = {
  instance: SimpleMindMapInstance;
  execCommand: (command: string, ...args: unknown[]) => SimpleMindMapSafeCommandResult;
  getSnapshot: () => unknown;
  refreshProjection?: (reason?: string) => Promise<void>;
  destroy: () => void;
};

export type SimpleMindMapSafeCommandResult =
  | { ok: true; command: string; result: unknown }
  | { ok: false; command: string; error: "unsupported_command" | "runtime_unavailable" | "command_failed" };

export const SIMPLE_MIND_MAP_REQUIRED_PLUGINS = [
  "Drag",
  "KeyboardNavigation",
  "Export",
  "Select",
  "AssociativeLine",
  "Search",
  "OuterFrame",
] as const;

export const SIMPLE_MIND_MAP_OPTIONAL_PLUGINS = [


export const SIMPLE_MIND_MAP_REQUIRED_PLUGINS = [
  "Drag",
  "KeyboardNavigation",
  "Export",
  "Select",
  "AssociativeLine",
  "Search",
  "OuterFrame",
] as const;

export const SIMPLE_MIND_MAP_OPTIONAL_PLUGINS = [
  // RichText 会把纯文本节点转成 HTML 字符串；Phase 6.1 先保护 kernel 文本语义。
  "Scrollbar",
  "MiniMap",
  "Painter",
  "Formula",
] as const;

export const SIMPLE_MIND_MAP_BRIDGE_EVENTS: SimpleMindMapBridgeEventName[] = [
  "data_change",
  "view_data_change",
  "node_active",
  "back_forward",
  "scale",
  "translate",
];

export const SIMPLE_MIND_MAP_SAFE_COMMANDS = new Set([
  "BACK",
  "FORWARD",
  "INSERT_NODE",
  "INSERT_CHILD_NODE",
  "REMOVE_NODE",
  "DELETE_NODE",
  "ADD_GENERALIZATION",
  "ADD_OUTER_FRAME",
  "SET_NOTATION",
]);

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null && !Array.isArray(value);

const readString = (value: unknown, fallback: string): string => {
  if (typeof value === "string" && value.trim()) return value.trim();
  if (isRecord(value)) {
    const template = value.template;
    if (typeof template === "string" && template.trim()) return template.trim();
  }
  return fallback;
};

const readRecord = (value: unknown): Record<string, unknown> => {
  if (isRecord(value)) return value;
  return {};
};

const readThemeConfig = (value: unknown): Record<string, unknown> => {
  if (!isRecord(value) || Object.keys(value).length === 0) return createDefaultMindmapThemeConfig();
  return value;


}
  return fallback;
};

const readRecord = (value: unknown): Record<string, unknown> => {
  if (isRecord(value)) return value;
  return {};
};

const readThemeConfig = (value: unknown): Record<string, unknown> => {
  if (!isRecord(value) || Object.keys(value).length === 0) return createDefaultMindmapThemeConfig();
  return value;
};

const readViewData = (value: unknown): Record<string, unknown> | null => {
  if (!isRecord(value) || !isRecord(value.state)) return null;
  return {
    ...value,
    state: value.state,
    transform: isRecord(value.transform) ? value.transform : {},
  };
};

export const buildSimpleMindMapOptions = (input: {
  el: HTMLElement;
  projection: MindmapAdapterProjection;
  runtimeOptions?: Record<string, unknown>;
}): Record<string, unknown> => {
  const root = canonicalizeMindmapData(input.projection.root ?? defaultMindmapData);
  const config = readRecord(input.projection.config);
  const runtimeOptions = readRecord(input.runtimeOptions);
  return {
    ...config,
    ...runtimeOptions,
    el: input.el,
    data: root,
    fit:
      typeof runtimeOptions.fit === "boolean"
        ? runtimeOptions.fit
        : typeof config.fit === "boolean"
          ? config.fit
          : true,
    layout: readString(input.projection.layout, DEFAULT_MINDMAP_LAYOUT),
    theme: readString(input.projection.theme, DEFAULT_MINDMAP_THEME),
    themeConfig: readThemeConfig(input.projection.themeConfig),
    viewData: readViewData(input.projection.view),
    initRootNodePosition: ["center", "center"],
  };
};

const pluginImporters = {
  Drag: () => import("simple-mind-map/src/plugins/Drag.js"),
  KeyboardNavigation: () => import("simple-mind-map/src/plugins/KeyboardNavigation.js"),
  Export: () => import("simple-mind-map/src/plugins/Export.js"),
  Select: () => import("simple-mind-map/src/plugins/Select.js"),
  AssociativeLine: () => import("simple-mind-map/src/plugins/AssociativeLine.js"),
  Search: () => import("simple-mind-map/src/plugins/Search.js"),
  OuterFrame: () => import("simple-mind-map/src/plugins/OuterFrame.js"),
  Scrollbar: () => import("simple-mind-map/src/plugins/Scrollbar.js"),
  MiniMap: () => import("simple-mind-map/src/plugins/MiniMap.js"),


};

const pluginImporters = {
  Drag: () => import("simple-mind-map/src/plugins/Drag.js"),
  KeyboardNavigation: () => import("simple-mind-map/src/plugins/KeyboardNavigation.js"),
  Export: () => import("simple-mind-map/src/plugins/Export.js"),
  Select: () => import("simple-mind-map/src/plugins/Select.js"),
  AssociativeLine: () => import("simple-mind-map/src/plugins/AssociativeLine.js"),
  Search: () => import("simple-mind-map/src/plugins/Search.js"),
  OuterFrame: () => import("simple-mind-map/src/plugins/OuterFrame.js"),
  Scrollbar: () => import("simple-mind-map/src/plugins/Scrollbar.js"),
  MiniMap: () => import("simple-mind-map/src/plugins/MiniMap.js"),
  Painter: () => import("simple-mind-map/src/plugins/Painter.js"),
  Formula: () => import("simple-mind-map/src/plugins/Formula.js"),
} satisfies Record<string, () => Promise<{ default: unknown }>>;

export type SimpleMindMapPluginName = keyof typeof pluginImporters;

export const defaultSimpleMindMapPluginNames = (): SimpleMindMapPluginName[] => [
  ...SIMPLE_MIND_MAP_REQUIRED_PLUGINS,
  ...SIMPLE_MIND_MAP_OPTIONAL_PLUGINS,
];

export const loadSimpleMindMapRuntime = async (
  pluginNames: SimpleMindMapPluginName[] = defaultSimpleMindMapPluginNames(),
): Promise<SimpleMindMapRuntime> => {
  if (typeof window === "undefined" || typeof document === "undefined") {
    throw new Error("simple_mind_map_browser_required");
  }

  const [{ default: MindMap }, pluginModules] = await Promise.all([
    import("simple-mind-map"),
    Promise.all(pluginNames.map(async (name) => [name, (await pluginImporters[name]()).default] as const)),
  ]);

  const MindMapCtor = MindMap as unknown as SimpleMindMapCtor & {
    hasPlugin?: (plugin: unknown) => number;
    usePlugin?: (plugin: unknown) => unknown;
  };

  const registeredPlugins: string[] = [];
  pluginModules.forEach(([name, plugin]) => {
    if (!plugin || typeof MindMapCtor.usePlugin !== "function") return;
    const registered =
      typeof MindMapCtor.hasPlugin === "function" ? MindMapCtor.hasPlugin(plugin) !== -1 : false;
    if (!registered) {
      MindMapCtor.usePlugin(plugin);
    }
    registeredPlugins.push(name);
  });

  return {
    MindMap: MindMapCtor,
    registeredPlugins,
  };
};

export const createSafeMindmapCommandExecutor =
  (instance: SimpleMindMapInstance) =>
  (command: string, ...args: unknown[]): SimpleMindMapSafeCommandResult => {


registeredPlugins.push(name);
  });

  return {
    MindMap: MindMapCtor,
    registeredPlugins,
  };
};

export const createSafeMindmapCommandExecutor =
  (instance: SimpleMindMapInstance) =>
  (command: string, ...args: unknown[]): SimpleMindMapSafeCommandResult => {
    if (!SIMPLE_MIND_MAP_SAFE_COMMANDS.has(command)) {
      return { ok: false, command, error: "unsupported_command" };
    }
    if (typeof instance.execCommand !== "function") {
      return { ok: false, command, error: "runtime_unavailable" };
    }
    try {
      return { ok: true, command, result: instance.execCommand(command, ...args) };
    } catch {
      return { ok: false, command, error: "command_failed" };
    }
  };

export const attachSimpleMindMapEventListeners = (input: {
  instance: SimpleMindMapInstance;
  projection: MindmapAdapterProjection;
  onEvent?: (event: SimpleMindMapBridgeEvent) => void;
}): (() => void) => {
  const cleanup: Array<() => void> = [];
  SIMPLE_MIND_MAP_BRIDGE_EVENTS.forEach((eventName) => {
    const handler = (...args: unknown[]) => {
      const snapshot =
        eventName === "data_change"
          ? input.instance.getData?.()
          : eventName === "view_data_change"
          ? input.instance.getData?.(true) ?? input.instance.getData?.()
          : undefined;
      input.onEvent?.({
        type: eventName,
        args,
        snapshot,
        kernelRevision: input.projection.kernelRevision,
      });
    };
    input.instance.on?.(eventName, handler);
    cleanup.push(() => input.instance.off?.(eventName, handler));
  });
  return () => cleanup.splice(0).forEach((dispose) => dispose());
};

export const createSimpleMindMapBridge = async (input: {
  el: HTMLElement;
  projection: MindmapAdapterProjection;
  pluginNames?: SimpleMindMapPluginName[];
  runtimeOptions?: Record<string, unknown>;
  mode?: "edit" | "readonly";
  onEvent?: (event: SimpleMindMapBridgeEvent) => void;
}): Promise<SimpleMindMapBridge> => {


});
  return () => cleanup.splice(0).forEach((dispose) => dispose());
};

export const createSimpleMindMapBridge = async (input: {
  el: HTMLElement;
  projection: MindmapAdapterProjection;
  pluginNames?: SimpleMindMapPluginName[];
  runtimeOptions?: Record<string, unknown>;
  mode?: "edit" | "readonly";
  onEvent?: (event: SimpleMindMapBridgeEvent) => void;
}): Promise<SimpleMindMapBridge> => {
  const runtime = await loadSimpleMindMapRuntime(input.pluginNames);
  const themeConfig = readThemeConfig(input.projection.themeConfig);
  const options = buildSimpleMindMapOptions({
    el: input.el,
    projection: input.projection,
    runtimeOptions: input.runtimeOptions,
  });
  const instance = new runtime.MindMap(options);
  instance.setThemeConfig?.(themeConfig);
  if (input.mode) instance.setMode?.(input.mode);

  const detachEvents = attachSimpleMindMapEventListeners({
    instance,
    projection: input.projection,
    onEvent: input.onEvent,
  });
  const execCommand = createSafeMindmapCommandExecutor(instance);

  return {
    instance,
    execCommand,
    getSnapshot: () => instance.getData?.(true) ?? instance.getData?.(),
    destroy: () => {
      instance.renderer?.textEdit?.hideEditTextBox?.();
      detachEvents();
      instance.destroy?.();
    },
  };
};

export const createFallbackMindmapAdapterProjection = (input: {
  mindmapId: string;
  root?: MindMapData;
  kernelRevision?: number;
}): MindmapAdapterProjection => ({
  schema: "mnote.mindmap.simple_mind_map_scene.v1",
  runtime: "simple-mind-map",
  root: input.root ?? defaultMindmapData,
  layout: DEFAULT_MINDMAP_LAYOUT,
  theme: DEFAULT_MINDMAP_THEME,
  themeConfig: createDefaultMindmapThemeConfig(),
  view: {},
  config: {},
  compatPayload: { source: "frontend-fallback", mindmapId: input.mindmapId },
  kernelRevision: input.kernelRevision ?? 1,
});


import { describe, expect, test } from "vitest";
import type { MediaAsset } from "@/types/media";
import { isRealFileAsset, parseSupabaseStorageObjectUrl } from "./asset";

describe("file-tree/asset", () => {
  test("parseSupabaseStorageObjectUrl", () => {
    expect(
      parseSupabaseStorageObjectUrl(
        "https://xxx.supabase.co/storage/v1/object/public/documents/a/b/c.txt",
      ),
    ).toEqual({ bucket: "documents", path: "a/b/c.txt" });
    expect(
      parseSupabaseStorageObjectUrl(
        "https://xxx.supabase.co/storage/v1/object/sign/documents/a/b/c.txt?token=abc",
      ),
    ).toEqual({ bucket: "documents", path: "a/b/c.txt" });
    expect(parseSupabaseStorageObjectUrl("/documents/123")).toBe(null);
  });

  test("isRealFileAsset", () => {
    const base = { id: "1" } as MediaAsset;
    expect(isRealFileAsset({ ...base, storage_path: "a/b", file_url: null } as any)).toBe(true);
    expect(isRealFileAsset({ ...base, storage_path: null, file_url: "/documents/123" } as any)).toBe(false);
    expect(
      isRealFileAsset({
        ...base,
        storage_path: null,
        file_url: "https://xxx.supabase.co/storage/v1/object/sign/documents/a/b/c.txt?token=abc",
      } as any),
    ).toBe(true);
  });
});


import type { MediaAsset } from "@/types/media";

export function parseSupabaseStorageObjectUrl(
  fileUrl: string,
): { bucket: string; path: string } | null {
  try {
    const url = new URL(fileUrl);
    const segments = url.pathname.split("/").filter(Boolean);
    const objectIdx = segments.findIndex((seg) => seg === "object");
    if (objectIdx === -1 || objectIdx + 2 >= segments.length) return null;
    const mode = segments[objectIdx + 1];
    if (mode !== "public" && mode !== "sign") return null;
    const bucket = segments[objectIdx + 2];
    const p = segments.slice(objectIdx + 3).join("/");
    return p ? { bucket, path: p } : null;
  } catch {
    return null;
  }
}

export function isRealFileAsset(asset: MediaAsset): boolean {
  if (Boolean(asset.storage_path)) return true;
  const url = asset.file_url ?? "";
  if (!url.startsWith("http")) return false;
  return parseSupabaseStorageObjectUrl(url) !== null;
}


import { describe, expect, it } from "vitest";
import {
  clearFileTreeClipboardPayload,
  decodeFileTreeClipboardPayload,
  encodeFileTreeClipboardPayload,
  inferPasteTargetDocId,
  isTextInputTarget,
} from "./clipboard";

describe("file-tree clipboard payload", () => {
  it("可编码/解码", () => {
    const payload = { type: "mnote-file-tree" as const, version: 1 as const, action: "copy" as const, rowIds: ["doc:a", "asset:x"] };
    const text = encodeFileTreeClipboardPayload(payload);
    expect(decodeFileTreeClipboardPayload(text)).toEqual(payload);
    const cutPayload = { ...payload, action: "cut" as const };
    expect(decodeFileTreeClipboardPayload(encodeFileTreeClipboardPayload(cutPayload))).toEqual(cutPayload);
    expect(
      decodeFileTreeClipboardPayload(
        encodeFileTreeClipboardPayload({ ...payload, action: "move" as any }),
      ),
    ).toBeNull();
    expect(decodeFileTreeClipboardPayload("not-a-payload")).toBeNull();
  });

  it("可清空内存剪贴板", async () => {
    await clearFileTreeClipboardPayload();
  });
});

describe("inferPasteTargetDocId", () => {
  it("focused 为 doc/index/asset 三分支覆盖", () => {
    const rowById = new Map<string, any>();
    rowById.set("asset:x", { kind: "asset", rowId: "asset:x", docId: "d1", asset: { id: "x" } });
    expect(inferPasteTargetDocId({ focusedRowId: "doc:d2", rowById, activeDocId: "active" })).toBe("d2");
    expect(inferPasteTargetDocId({ focusedRowId: "index:d3", rowById, activeDocId: "active" })).toBe("d3");
    expect(inferPasteTargetDocId({ focusedRowId: "asset:x", rowById, activeDocId: "active" })).toBe("d1");
  });

  it("无 focused 时回退 activeDocId", () => {
    const rowById = new Map<string, any>();
    expect(inferPasteTargetDocId({ focusedRowId: null, rowById, activeDocId: "active" })).toBe("active");
    expect(inferPasteTargetDocId({ focusedRowId: null, rowById, activeDocId: null })).toBeNull();
  });
});

describe("isTextInputTarget", () => {
  it("input/textarea/contenteditable 不拦截快捷键", () => {
    const input = document.createElement("input");
    const textarea = document.createElement("textarea");
    const div = document.createElement("div");
    div.setAttribute("contenteditable", "true");
    expect(isTextInputTarget(input)).toBe(true);
    expect(isTextInputTarget(textarea)).toBe(true);
    expect(isTextInputTarget(div)).toBe(true);
    expect(isTextInputTarget(document.createElement("button"))).toBe(false);
  });
});


"use client";

import type { FileTreeRow } from "./types";
import { parseFileTreeRowId } from "./types";

export type FileTreeClipboardAction = "copy" | "cut";

export type FileTreeClipboardPayloadV1 = {
  type: "mnote-file-tree";
  version: 1;
  action: FileTreeClipboardAction;
  rowIds: string[];
};

const PREFIX = "mnote-file-tree-clipboard:v1:";

let memoryClipboardText: string | null = null;

function encodeBase64(text: string): string {
  const bytes = new TextEncoder().encode(text);
  let binary = "";
  bytes.forEach((b) => {
    binary += String.fromCharCode(b);
  });
  return btoa(binary);
}

function decodeBase64(base64: string): string {
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i += 1) {
    bytes[i] = binary.charCodeAt(i);
  }
  return new TextDecoder().decode(bytes);
}

export function encodeFileTreeClipboardPayload(payload: FileTreeClipboardPayloadV1): string {
  return `${PREFIX}${encodeBase64(JSON.stringify(payload))}`;
}

export function decodeFileTreeClipboardPayload(text: string): FileTreeClipboardPayloadV1 | null {
  if (!text || !text.startsWith(PREFIX)) return null;
  const base64 = text.slice(PREFIX.length);
  try {
    const raw = decodeBase64(base64);
    const parsed = JSON.parse(raw) as Partial<FileTreeClipboardPayloadV1>;
    if (parsed?.type !== "mnote-file-tree" || parsed.version !== 1) return null;
    if (parsed.action !== "copy" && parsed.action !== "cut") return null;
    if (!Array.isArray(parsed.rowIds) || parsed.rowIds.some((id) => typeof id !== "string")) return null;
    return parsed as FileTreeClipboardPayloadV1;
  } catch {
    return null;
  }
}

export async function writeFileTreeClipboardPayload(payload: FileTreeClipboardPayloadV1): Promise<void> {
  const text = encodeFileTreeClipboardPayload(payload);
  memoryClipboardText = text;
  if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
    try {


if (!Array.isArray(parsed.rowIds) || parsed.rowIds.some((id) => typeof id !== "string")) return null;
    return parsed as FileTreeClipboardPayloadV1;
  } catch {
    return null;
  }
}

export async function writeFileTreeClipboardPayload(payload: FileTreeClipboardPayloadV1): Promise<void> {
  const text = encodeFileTreeClipboardPayload(payload);
  memoryClipboardText = text;
  if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
    try {
      await navigator.clipboard.writeText(text);
    } catch {
      // ignore, fallback to memory
    }
  }
}

export async function readFileTreeClipboardPayload(): Promise<FileTreeClipboardPayloadV1 | null> {
  let text: string | null = memoryClipboardText;
  if (typeof navigator !== "undefined" && navigator.clipboard?.readText) {
    try {
      text = await navigator.clipboard.readText();
    } catch {
      // ignore, fallback to memory
    }
  }
  if (!text) return null;
  return decodeFileTreeClipboardPayload(text);
}

export async function clearFileTreeClipboardPayload(): Promise<void> {
  memoryClipboardText = null;
  if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
    try {
      await navigator.clipboard.writeText("");
    } catch {
      // ignore, memory fallback 已清空
    }
  }
}

export function isTextInputTarget(target: EventTarget | null): boolean {
  const el = target as HTMLElement | null;
  if (!el) return false;
  if (el.isContentEditable) return true;
  const contentEditable = el.getAttribute?.("contenteditable");
  if (contentEditable && contentEditable.toLowerCase() !== "false") {
    return true;
  }
  const tag = el.tagName?.toLowerCase();
  return tag === "input" || tag === "textarea" || el.getAttribute?.("role") === "textbox";
}

export function inferPasteTargetDocId({
  focusedRowId,
  rowById,
  activeDocId,
}: {


if (contentEditable && contentEditable.toLowerCase() !== "false") {
    return true;
  }
  const tag = el.tagName?.toLowerCase();
  return tag === "input" || tag === "textarea" || el.getAttribute?.("role") === "textbox";
}

export function inferPasteTargetDocId({
  focusedRowId,
  rowById,
  activeDocId,
}: {
  focusedRowId: string | null;
  rowById: Map<string, FileTreeRow>;
  activeDocId: string | null;
}): string | null {
  if (focusedRowId) {
    const parsed = parseFileTreeRowId(focusedRowId);
    if (parsed?.kind === "doc") return parsed.docId;
    if (parsed?.kind === "index") return parsed.docId;
    if (parsed?.kind === "asset-folder") {
      const row = rowById.get(focusedRowId);
      return row?.kind === "asset-folder" ? row.docId : null;
    }
    if (parsed?.kind === "asset") {
      const row = rowById.get(focusedRowId);
      return row?.kind === "asset" ? row.docId : null;
    }
  }
  return activeDocId || null;
}


import { describe, expect, it } from "vitest";
import type { FileTreeRow } from "@/lib/file-tree/types";
import { buildParentById } from "./dnd";
import { computeFileTreeDeleteTargets } from "./delete";

function makeDoc(id: string, parent_id: string | null): any {
  return {
    id,
    parent_id,
    title: id,
    access_scope: "private",
    icon: null,
    cover: null,
    is_template: false,
    user_id: "u1",
    workspace_id: "w1",
    created_at: "",
    updated_at: "",
    children: [],
  };
}

describe("computeFileTreeDeleteTargets", () => {
  it("去掉被父级页面覆盖的子页面", () => {
    const docA = makeDoc("A", null);
    const docB = makeDoc("B", "A");
    const rows: FileTreeRow[] = [
      { rowId: "doc:A", kind: "doc", docId: "A", parentDocId: null, node: docA, depth: 0, hasChildren: true, isExpanded: true },
      { rowId: "index:A", kind: "index", docId: "A", parentDocId: "A", node: docA, depth: 1 },
      { rowId: "doc:B", kind: "doc", docId: "B", parentDocId: "A", node: docB, depth: 1, hasChildren: false, isExpanded: false },
      { rowId: "index:B", kind: "index", docId: "B", parentDocId: "B", node: docB, depth: 2 },
    ];
    const parentById = buildParentById([
      { id: "A", parentId: null },
      { id: "B", parentId: "A" },
    ]);

    const selectedRowIds = new Set(["doc:A", "doc:B"]);
    const result = computeFileTreeDeleteTargets({ visibleRows: rows, selectedRowIds, parentById });
    expect(result.docIds).toEqual(["A"]);
  });

  it("选中 index 行等价于选中页面本身（去重）", () => {
    const docA = makeDoc("A", null);
    const rows: FileTreeRow[] = [
      { rowId: "doc:A", kind: "doc", docId: "A", parentDocId: null, node: docA, depth: 0, hasChildren: false, isExpanded: false },
      { rowId: "index:A", kind: "index", docId: "A", parentDocId: "A", node: docA, depth: 1 },
    ];
    const parentById = buildParentById([{ id: "A", parentId: null }]);
    const selectedRowIds = new Set(["doc:A", "index:A"]);
    const result = computeFileTreeDeleteTargets({ visibleRows: rows, selectedRowIds, parentById });
    expect(result.docIds).toEqual(["A"]);
  });

  it("如果页面被删除，则跳过同页面下的附件删除（避免重复/无效操作）", () => {
    const docA = makeDoc("A", null);
    const rows: FileTreeRow[] = [
      { rowId: "doc:A", kind: "doc", docId: "A", parentDocId: null, node: docA, depth: 0, hasChildren: true, isExpanded: true },
      { rowId: "index:A", kind: "index", docId: "A", parentDocId: "A", node: docA, depth: 1 },
      {


const parentById = buildParentById([{ id: "A", parentId: null }]);
    const selectedRowIds = new Set(["doc:A", "index:A"]);
    const result = computeFileTreeDeleteTargets({ visibleRows: rows, selectedRowIds, parentById });
    expect(result.docIds).toEqual(["A"]);
  });

  it("如果页面被删除，则跳过同页面下的附件删除（避免重复/无效操作）", () => {
    const docA = makeDoc("A", null);
    const rows: FileTreeRow[] = [
      { rowId: "doc:A", kind: "doc", docId: "A", parentDocId: null, node: docA, depth: 0, hasChildren: true, isExpanded: true },
      { rowId: "index:A", kind: "index", docId: "A", parentDocId: "A", node: docA, depth: 1 },
      {
        rowId: "asset:1",
        kind: "asset",
        docId: "A",
        parentDocId: "A",
        asset: {
          id: "1",
          workspace_id: "w1",
          document_id: "A",
          asset_type: "file",
          file_url: "https://example.com/1",
          thumbnail_url: null,
          bucket: "b",
          storage_path: "p",
          file_name: "a.txt",
          file_size: 1,
          mime_type: "text/plain",
          ocr_payload: undefined,
          ocr_strategy: null,
          ocr_text: null,
          ocr_status: null,
          signed_url: null,
          created_at: "",
          updated_at: "",
        },
        depth: 2,
      },
    ];
    const parentById = buildParentById([{ id: "A", parentId: null }]);
    const selectedRowIds = new Set(["doc:A", "asset:1"]);
    const result = computeFileTreeDeleteTargets({ visibleRows: rows, selectedRowIds, parentById });
    expect(result.docIds).toEqual(["A"]);
    expect(result.assetIds).toEqual([]);
  });
});


"use client";

import type { FileTreeRow } from "@/lib/file-tree/types";
import { filterTopLevelDocIds } from "./dnd";

export type FileTreeDeleteTargets = {
  docIds: string[];
  assetIds: string[];
};

export function computeFileTreeDeleteTargets(args: {
  visibleRows: FileTreeRow[];
  selectedRowIds: Set<string>;
  parentById: Map<string, string | null>;
}): FileTreeDeleteTargets {
  const { visibleRows, selectedRowIds, parentById } = args;

  const docCandidates: string[] = [];
  const assetCandidates: string[] = [];
  const assetDocIdByAssetId = new Map<string, string>();

  for (const row of visibleRows) {
    if (!selectedRowIds.has(row.rowId)) continue;

    if (row.kind === "doc" || row.kind === "index") {
      docCandidates.push(row.docId);
      continue;
    }

    if (row.kind === "asset" || row.kind === "asset-folder") {
      assetCandidates.push(row.asset.id);
      assetDocIdByAssetId.set(row.asset.id, row.docId);
    }
  }

  const docIds = filterTopLevelDocIds(docCandidates, parentById);
  const docIdSet = new Set(docIds);

  const seenAssets = new Set<string>();
  const assetIds: string[] = [];
  for (const assetId of assetCandidates) {
    if (seenAssets.has(assetId)) continue;
    seenAssets.add(assetId);
    const docId = assetDocIdByAssetId.get(assetId);
    if (docId && docIdSet.has(docId)) continue;
    assetIds.push(assetId);
  }

  return { docIds, assetIds };
}


import { describe, expect, test } from "vitest";
import type { FileTreeRow } from "@/lib/file-tree/types";
import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "./dnd";

describe("file-tree/dnd", () => {
  test("inferDropTargetDocId", () => {
    const docRow: FileTreeRow = {
      kind: "doc",
      rowId: "doc:a",
      docId: "a",
      parentDocId: null,
      depth: 0,
      isExpanded: false,
      hasChildren: false,
      node: {} as any,
    };
    const indexRow: FileTreeRow = { kind: "index", rowId: "index:a", docId: "a", parentDocId: "a", depth: 1, node: {} as any };
    const assetRow: FileTreeRow = { kind: "asset", rowId: "asset:x", docId: "a", parentDocId: "a", depth: 1, asset: {} as any };
    expect(inferDropTargetDocId(docRow)).toBe("a");
    expect(inferDropTargetDocId(indexRow)).toBe("a");
    expect(inferDropTargetDocId(assetRow)).toBe("a");
    expect(inferDropTargetDocId(null)).toBe(null);
  });

  test("filterTopLevelDocIds removes descendants", () => {
    const parentById = buildParentById([
      { id: "a", parentId: null },
      { id: "b", parentId: "a" },
      { id: "c", parentId: "b" },
      { id: "d", parentId: null },
    ]);
    expect(filterTopLevelDocIds(["b", "a", "c", "d"], parentById)).toEqual(["a", "d"]);
    expect(filterTopLevelDocIds(["b", "c"], parentById)).toEqual(["b"]);
  });

  test("isInvalidDocDrop blocks self/descendant", () => {
    const parentById = buildParentById([
      { id: "a", parentId: null },
      { id: "b", parentId: "a" },
      { id: "c", parentId: "b" },
    ]);
    expect(isInvalidDocDrop({ sourceDocIds: ["a"], targetParentId: "a", parentById })).toBe(true);
    expect(isInvalidDocDrop({ sourceDocIds: ["a"], targetParentId: "b", parentById })).toBe(true);
    expect(isInvalidDocDrop({ sourceDocIds: ["b"], targetParentId: "a", parentById })).toBe(false);
  });
});


import type { FileTreeRow } from "@/lib/file-tree/types";

export function inferDropTargetDocId(targetRow: FileTreeRow | null): string | null {
  if (!targetRow) return null;
  return targetRow.docId ?? null;
}

export type ParentEdge = { id: string; parentId: string | null };

export function buildParentById(edges: ParentEdge[]): Map<string, string | null> {
  const map = new Map<string, string | null>();
  edges.forEach((edge) => {
    map.set(edge.id, edge.parentId ?? null);
  });
  return map;
}

export function isAncestorOf(
  ancestorId: string,
  nodeId: string,
  parentById: Map<string, string | null>,
): boolean {
  let current: string | null | undefined = nodeId;
  while (current) {
    const parent = parentById.get(current);
    if (!parent) return false;
    if (parent === ancestorId) return true;
    current = parent;
  }
  return false;
}

export function filterTopLevelDocIds(
  docIds: string[],
  parentById: Map<string, string | null>,
): string[] {
  const unique = Array.from(new Set(docIds));
  const selected = new Set(unique);
  return unique.filter((id) => {
    let current: string | null | undefined = id;
    while (current) {
      const parent = parentById.get(current);
      if (!parent) return true;
      if (selected.has(parent)) return false;
      current = parent;
    }
    return true;
  });
}

export function isInvalidDocDrop(args: {
  sourceDocIds: string[];
  targetParentId: string | null;
  parentById: Map<string, string | null>;
}): boolean {
  const { sourceDocIds, targetParentId, parentById } = args;
  if (!targetParentId) return false;
  const sources = new Set(sourceDocIds);
  if (sources.has(targetParentId)) return true;
  for (const sourceId of sources) {


}

export function isInvalidDocDrop(args: {
  sourceDocIds: string[];
  targetParentId: string | null;
  parentById: Map<string, string | null>;
}): boolean {
  const { sourceDocIds, targetParentId, parentById } = args;
  if (!targetParentId) return false;
  const sources = new Set(sourceDocIds);
  if (sources.has(targetParentId)) return true;
  for (const sourceId of sources) {
    if (isAncestorOf(sourceId, targetParentId, parentById)) return true;
  }
  return false;
}


import { describe, expect, test } from "vitest";
import { makeUniqueFileName, makeUniqueTitle } from "./naming";

describe("file-tree/naming", () => {
  test("makeUniqueTitle", () => {
    const existing = new Set<string>(["无标题", "无标题 副本"]);
    expect(makeUniqueTitle("无标题", existing)).toBe("无标题 副本 2");
    expect(makeUniqueTitle("Hello", existing)).toBe("Hello");
    expect(makeUniqueTitle("Hello", existing)).toBe("Hello 副本");
  });

  test("makeUniqueFileName keeps extension", () => {
    const existing = new Set<string>(["a.txt", "a 副本.txt"]);
    expect(makeUniqueFileName("a.txt", existing)).toBe("a 副本 2.txt");
    expect(makeUniqueFileName("图片.png", existing)).toBe("图片.png");
    expect(makeUniqueFileName("图片.png", existing)).toBe("图片 副本.png");
    expect(makeUniqueFileName("无扩展名", existing)).toBe("无扩展名");
    expect(makeUniqueFileName("无扩展名", existing)).toBe("无扩展名 副本");
  });
});


function splitExtension(fileName: string): { base: string; ext: string } {
  const safe = fileName.trim();
  const lastDot = safe.lastIndexOf(".");
  if (lastDot <= 0 || lastDot === safe.length - 1) {
    return { base: safe, ext: "" };
  }
  return { base: safe.slice(0, lastDot), ext: safe.slice(lastDot) };
}

export function makeUniqueTitle(baseTitle: string, existing: Set<string>): string {
  const base = baseTitle.trim() || "无标题";
  if (!existing.has(base)) {
    existing.add(base);
    return base;
  }
  const first = `${base} 副本`;
  if (!existing.has(first)) {
    existing.add(first);
    return first;
  }
  for (let i = 2; i < 1000; i += 1) {
    const candidate = `${base} 副本 ${i}`;
    if (!existing.has(candidate)) {
      existing.add(candidate);
      return candidate;
    }
  }
  const fallback = `${base} 副本 ${Date.now()}`;
  existing.add(fallback);
  return fallback;
}

export function makeUniqueFileName(fileName: string, existing: Set<string>): string {
  const safe = fileName.trim() || "附件";
  if (!existing.has(safe)) {
    existing.add(safe);
    return safe;
  }
  const { base, ext } = splitExtension(safe);
  const first = `${base} 副本${ext}`;
  if (!existing.has(first)) {
    existing.add(first);
    return first;
  }
  for (let i = 2; i < 1000; i += 1) {
    const candidate = `${base} 副本 ${i}${ext}`;
    if (!existing.has(candidate)) {
      existing.add(candidate);
      return candidate;
    }
  }
  const fallback = `${base} 副本 ${Date.now()}${ext}`;
  existing.add(fallback);
  return fallback;
}


import { beforeEach, describe, expect, it, vi } from "vitest";
import {
  FILE_TREE_SEARCH_MAX_RESULTS,
  buildFileTreeProjectionSearchRequestMeta,
  fetchKernelFileTreeProjection,
} from "./projection-client";

describe("fetchKernelFileTreeProjection", () => {
  beforeEach(() => {
    vi.restoreAllMocks();
  });

  it("通过 3000 同源 file_tree projection endpoint 请求 Rust 搜索 projection", async () => {
    const fetchMock = vi.fn().mockResolvedValue(
      new Response(
        JSON.stringify({
          ok: true,
          result: {
            projectionId: "kernel_projection:file_tree:page_root",
            projection: "file_tree",
            rootNodeId: "page_root",
            items: [{ rowId: "asset:table_1" }],
            edges: [],
            meta: {
              search: {
                indexingVisibility: {
                  schema: "mnote.file_tree.indexing_visibility",
                  schemaVersion: 1,
                  source: "kernel.project_view",
                  status: "visible",
                  requestKey: "page_root:预算",
                  indexedResourceKinds: ["document", "index", "asset"],
                  visibleResourceKinds: ["document", "index", "asset"],
                  metrics: {
                    visibleRows: 1,
                    visibleEdges: 0,
                  },
                },
              },
            },
          },
        }),
        { status: 200 },
      ),
    );
    vi.stubGlobal("fetch", fetchMock);

    const result = await fetchKernelFileTreeProjection({
      workspaceId: "ws_1",
      rootNodeId: "page_root",
      depth: 3,
      query: "  预算  ",
      maxResults: 12,
    });

    expect(fetchMock).toHaveBeenCalledWith(
      "/api/tree/projections/file?workspaceId=ws_1&rootNodeId=page_root&depth=3&query=%E9%A2%84%E7%AE%97&maxResults=12",
      expect.objectContaining({
        method: "GET",
        credentials: "include",


workspaceId: "ws_1",
      rootNodeId: "page_root",
      depth: 3,
      query: "  预算  ",
      maxResults: 12,
    });

    expect(fetchMock).toHaveBeenCalledWith(
      "/api/tree/projections/file?workspaceId=ws_1&rootNodeId=page_root&depth=3&query=%E9%A2%84%E7%AE%97&maxResults=12",
      expect.objectContaining({
        method: "GET",
        credentials: "include",
      }),
    );
    expect(result.items.map((item) => item.rowId)).toEqual(["asset:table_1"]);
    expect(result.meta?.search?.indexingVisibility).toMatchObject({
      schema: "mnote.file_tree.indexing_visibility",
      status: "visible",
      requestKey: "page_root:预算",
    });
  });

  it("固定 file_tree 搜索语义边界：命中数先截断，祖先补全不计入 maxResults", () => {
    expect(FILE_TREE_SEARCH_MAX_RESULTS).toBe(80);
    expect(
      buildFileTreeProjectionSearchRequestMeta({
        workspaceId: "ws_1",
        query: "  rust  ",
        maxResults: 500,
      }),
    ).toEqual({
      query: "rust",
      maxResults: 80,
      maxResultsRule: "matches_only_before_ancestor_completion",
      ancestorCompletion: "include_all_ancestors_after_match_truncation",
      ordering: "kernel_file_tree_preorder",
      emptyStateText: "没有匹配结果",
      asyncVisibility: {
        source: "kernel.project_view",
        requestKey: "ws_1:rust",
      },
      coveredResourceKinds: ["index", "asset", "asset_folder", "mindmap", "book", "pdf"],
    });
  });

  it("搜索请求会把 maxResults 限制在 sidebar 使用的稳定上限内", async () => {
    const fetchMock = vi.fn().mockResolvedValue(
      new Response(
        JSON.stringify({
          ok: true,
          result: {
            projectionId: "kernel_projection:file_tree:root",
            projection: "file_tree",
            rootNodeId: null,
            items: [],
            edges: [],
          },
        }),
        { status: 200 },
      ),


JSON.stringify({
          ok: true,
          result: {
            projectionId: "kernel_projection:file_tree:root",
            projection: "file_tree",
            rootNodeId: null,
            items: [],
            edges: [],
          },
        }),
        { status: 200 },
      ),
    );
    vi.stubGlobal("fetch", fetchMock);

    await fetchKernelFileTreeProjection({
      workspaceId: "ws_1",
      query: "rust",
      maxResults: 500,
    });

    expect(fetchMock).toHaveBeenCalledWith(
      "/api/tree/projections/file?workspaceId=ws_1&query=rust&maxResults=80",
      expect.anything(),
    );
  });

  it("失败时透出服务端错误消息", async () => {
    vi.stubGlobal(
      "fetch",
      vi.fn().mockResolvedValue(
        new Response(JSON.stringify({ error: "获取 projection 失败" }), { status: 502 }),
      ),
    );

    await expect(fetchKernelFileTreeProjection({ workspaceId: "ws_1" })).rejects.toThrow(
      "获取 projection 失败",
    );
  });
});


import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree";

export const FILE_TREE_SEARCH_MAX_RESULTS = 80;
export const FILE_TREE_SEARCH_EMPTY_STATE_TEXT = "没有匹配结果";

export type FileTreeProjectionSearchRequestMeta = {
  query: string | null;
  maxResults: number | null;
  maxResultsRule: "matches_only_before_ancestor_completion";
  ancestorCompletion: "include_all_ancestors_after_match_truncation";
  ordering: "kernel_file_tree_preorder";
  emptyStateText: typeof FILE_TREE_SEARCH_EMPTY_STATE_TEXT;
  asyncVisibility: {
    source: "kernel.project_view";
    requestKey: string | null;
  };
  coveredResourceKinds: ["index", "asset", "asset_folder", "mindmap", "book", "pdf"];
};

export type FetchKernelFileTreeProjectionInput = {
  workspaceId: string;
  rootNodeId?: string | null;
  depth?: number | null;
  query?: string | null;
  maxResults?: number | null;
};

function normalizeQuery(value: string | null | undefined): string | null {
  const query = value?.trim() ?? "";
  return query.length > 0 ? query : null;
}

function normalizeMaxResults(value: number | null | undefined): number | null {
  if (typeof value !== "number" || !Number.isFinite(value)) {
    return null;
  }
  return Math.min(FILE_TREE_SEARCH_MAX_RESULTS, Math.max(1, Math.floor(value)));
}

export function buildFileTreeProjectionSearchRequestMeta(
  input: Pick<FetchKernelFileTreeProjectionInput, "workspaceId" | "query" | "maxResults">,
): FileTreeProjectionSearchRequestMeta {
  const query = normalizeQuery(input.query);
  const maxResults = normalizeMaxResults(input.maxResults);
  return {
    query,
    maxResults,
    maxResultsRule: "matches_only_before_ancestor_completion",
    ancestorCompletion: "include_all_ancestors_after_match_truncation",
    ordering: "kernel_file_tree_preorder",
    emptyStateText: FILE_TREE_SEARCH_EMPTY_STATE_TEXT,
    asyncVisibility: {
      source: "kernel.project_view",
      requestKey: query ? `${input.workspaceId}:${query}` : null,
    },
    coveredResourceKinds: ["index", "asset", "asset_folder", "mindmap", "book", "pdf"],
  };
}

export async function fetchKernelFileTreeProjection(


ancestorCompletion: "include_all_ancestors_after_match_truncation",
    ordering: "kernel_file_tree_preorder",
    emptyStateText: FILE_TREE_SEARCH_EMPTY_STATE_TEXT,
    asyncVisibility: {
      source: "kernel.project_view",
      requestKey: query ? `${input.workspaceId}:${query}` : null,
    },
    coveredResourceKinds: ["index", "asset", "asset_folder", "mindmap", "book", "pdf"],
  };
}

export async function fetchKernelFileTreeProjection(
  input: FetchKernelFileTreeProjectionInput,
): Promise<KernelFileTreeProjection> {
  const params = new URLSearchParams();
  params.set("workspaceId", input.workspaceId);
  const rootNodeId = input.rootNodeId?.trim();
  if (rootNodeId) {
    params.set("rootNodeId", rootNodeId);
  }
  if (typeof input.depth === "number" && Number.isFinite(input.depth)) {
    params.set("depth", String(input.depth));
  }
  const query = normalizeQuery(input.query);
  if (query) {
    params.set("query", query);
  }
  const maxResults = normalizeMaxResults(input.maxResults);
  if (maxResults != null) {
    params.set("maxResults", String(maxResults));
  }

  const response = await fetch(`/api/tree/projections/file?${params.toString()}`, {
    method: "GET",
    credentials: "include",
  });

  if (!response.ok) {
    const payload = await response.json().catch(() => ({}));
    const message =
      typeof payload?.error === "string" ? payload.error : "获取 file_tree projection 失败";
    throw new Error(message);
  }

  const payload = (await response.json()) as { result?: KernelFileTreeProjection };
  if (!payload.result) {
    throw new Error("file_tree projection 响应缺少 result");
  }
  return payload.result;
}


import { afterEach, describe, expect, it, vi } from "vitest";
import {
  copyFileTreeResourceAssets,
  deleteFileTreeResourceAssets,
  preflightFileTreeDelete,
  preflightFileTreeInternalDrop,
  preflightFileTreePaste,
  preflightFileTreeUploadTarget,
  moveFileTreeResourceAssets,
  renameFileTreeResourceAsset,
  restoreFileTreeResourceAssets,
  uploadFileTreeResourceAsset,
} from "./resource-command-client";

describe("file-tree resource command client", () => {
  afterEach(() => {
    vi.restoreAllMocks();
  });

  it("copy/move 应通过统一资源 command client 发送到 media batch route", async () => {
    const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
      ok: true,
      json: async () => ({ items: [{ id: "asset_1" }] }),
    } as Response);

    await copyFileTreeResourceAssets({
      assetIds: ["asset_1", "asset_1", " asset_2 "],
      targetDocumentId: "doc_target",
      targetSubPath: "mindmaps/mind_1",
    });
    await moveFileTreeResourceAssets({
      assetIds: ["asset_3"],
      targetDocumentId: "doc_target_2",
    });

    expect(fetchMock).toHaveBeenCalledTimes(2);
    expect(fetchMock).toHaveBeenNthCalledWith(
      1,
      "/api/media/batch",
      expect.objectContaining({
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          action: "copy",
          assetIds: ["asset_1", "asset_2"],
          targetDocumentId: "doc_target",
          targetSubPath: "mindmaps/mind_1",
        }),
      }),
    );
    expect(fetchMock).toHaveBeenNthCalledWith(
      2,
      "/api/media/batch",
      expect.objectContaining({
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          action: "move",
          assetIds: ["asset_3"],
          targetDocumentId: "doc_target_2",


}),
    );
    expect(fetchMock).toHaveBeenNthCalledWith(
      2,
      "/api/media/batch",
      expect.objectContaining({
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          action: "move",
          assetIds: ["asset_3"],
          targetDocumentId: "doc_target_2",
        }),
      }),
    );
  });

  it("rename/delete/restore 也应复用同一 batch transport 边界", async () => {
    const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
      ok: true,
      json: async () => ({ ok: true }),
    } as Response);

    await renameFileTreeResourceAsset({ assetId: "asset_1", newName: "新文件.pdf" });
    await deleteFileTreeResourceAssets(["asset_1", "asset_2"]);
    await restoreFileTreeResourceAssets(["asset_3"]);

    expect(fetchMock.mock.calls.map((call) => JSON.parse(String(call[1]?.body)))).toEqual([
      { action: "rename", assetIds: ["asset_1"], newName: "新文件.pdf" },
      { action: "delete", assetIds: ["asset_1", "asset_2"] },
      { action: "restore", assetIds: ["asset_3"] },
    ]);
  });

  it("后端返回错误时应抛出稳定 fallback 或服务端消息", async () => {
    vi.spyOn(globalThis, "fetch").mockResolvedValue({
      ok: false,
      json: async () => ({ error: "Rust resource preflight rejected" }),
    } as Response);

    await expect(
      moveFileTreeResourceAssets({
        assetIds: ["asset_1"],
        targetDocumentId: "doc_target",
      }),
    ).rejects.toThrow("Rust resource preflight rejected");
  });

  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",