checkpoint before gfm ast parser design
This commit is contained in:
@@ -5,6 +5,11 @@ const mockValidateRequestBody = vi.fn();
|
||||
const mockIsConvexEnabled = vi.fn();
|
||||
const mockGetAuthedConvexClient = vi.fn();
|
||||
const mockStartMnoteCliAgentHostRun = vi.fn();
|
||||
const mockStartCodexJsonRun = vi.fn();
|
||||
const mockCodexMessagesToPrompt = vi.fn();
|
||||
const mockFindWorkspaceRoot = vi.fn();
|
||||
const mockStartHermesRun = vi.fn();
|
||||
const mockStreamHermesRunEvents = vi.fn();
|
||||
const mockRequireAuthContext = vi.fn();
|
||||
const mockGetConvexAuthedHttpClient = vi.fn();
|
||||
|
||||
@@ -51,6 +56,17 @@ vi.mock("@/lib/server/mnote-cli-agent-host", () => ({
|
||||
startMnoteCliAgentHostRun: mockStartMnoteCliAgentHostRun,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/codex/codexExec", () => ({
|
||||
codexMessagesToPrompt: mockCodexMessagesToPrompt,
|
||||
findWorkspaceRoot: mockFindWorkspaceRoot,
|
||||
startCodexJsonRun: mockStartCodexJsonRun,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai-agent/hermes/bridge", () => ({
|
||||
startHermesRun: mockStartHermesRun,
|
||||
streamHermesRunEvents: mockStreamHermesRunEvents,
|
||||
}));
|
||||
|
||||
describe("/api/ai-agent/run route", () => {
|
||||
beforeEach(() => {
|
||||
mockSafeGetJsonBody.mockReset();
|
||||
@@ -58,8 +74,15 @@ describe("/api/ai-agent/run route", () => {
|
||||
mockIsConvexEnabled.mockReset();
|
||||
mockGetAuthedConvexClient.mockReset();
|
||||
mockStartMnoteCliAgentHostRun.mockReset();
|
||||
mockStartCodexJsonRun.mockReset();
|
||||
mockCodexMessagesToPrompt.mockReset();
|
||||
mockFindWorkspaceRoot.mockReset();
|
||||
mockStartHermesRun.mockReset();
|
||||
mockStreamHermesRunEvents.mockReset();
|
||||
mockRequireAuthContext.mockReset();
|
||||
mockGetConvexAuthedHttpClient.mockReset();
|
||||
mockCodexMessagesToPrompt.mockImplementation((messages) => messages.map((m: any) => m.content).join("\n"));
|
||||
mockFindWorkspaceRoot.mockResolvedValue("/mnt/Data1T/mnote");
|
||||
});
|
||||
|
||||
it("文档页在线请求应只进入 mnote-cli host", async () => {
|
||||
@@ -137,8 +160,8 @@ describe("/api/ai-agent/run route", () => {
|
||||
expect(text).toContain("mnote-cli");
|
||||
});
|
||||
|
||||
it.each(["codex", "hermes", "local", "ollama"] as const)(
|
||||
"provider=%s 也必须统一进入 mnote-cli host",
|
||||
it.each(["local", "ollama"] as const)(
|
||||
"provider=%s 继续进入 mnote-cli host",
|
||||
async (provider) => {
|
||||
mockSafeGetJsonBody.mockResolvedValue({
|
||||
stream: true,
|
||||
@@ -196,6 +219,127 @@ describe("/api/ai-agent/run route", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("provider=codex 应进入 Codex host 并返回 codex_session", async () => {
|
||||
mockSafeGetJsonBody.mockResolvedValue({
|
||||
stream: true,
|
||||
scope: "document",
|
||||
messages: [{ role: "user", content: "#chat 继续检查" }],
|
||||
context: {
|
||||
documentId: "doc-1",
|
||||
},
|
||||
options: {
|
||||
ai: {
|
||||
provider: "codex",
|
||||
sessionId: "019dfbb6-9219-7861-a621-f6d77d9462f2",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockValidateRequestBody.mockReturnValue(null);
|
||||
mockIsConvexEnabled.mockReturnValue(true);
|
||||
mockGetAuthedConvexClient.mockResolvedValue({
|
||||
auth: {
|
||||
userId: "user-1",
|
||||
},
|
||||
});
|
||||
mockStartCodexJsonRun.mockImplementation(({ onJsonLine }) => {
|
||||
onJsonLine?.({ type: "thread.started", thread_id: "codex-thread-1" });
|
||||
return {
|
||||
done: Promise.resolve({ ok: true, threadId: "codex-thread-1", text: "Codex 已回复" }),
|
||||
kill: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(new Request("http://127.0.0.1:3000/api/ai-agent/run", { method: "POST" }));
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
|
||||
expect(mockStartCodexJsonRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "019dfbb6-9219-7861-a621-f6d77d9462f2",
|
||||
}),
|
||||
);
|
||||
expect(response.headers.get("x-mnote-ai-execution-owner")).toBe("codex");
|
||||
expect(text).toContain("event: codex_session");
|
||||
expect(text).toContain("codex-thread-1");
|
||||
expect(text).toContain("Codex 已回复");
|
||||
});
|
||||
|
||||
it("provider=hermes 应进入 Hermes API bridge", async () => {
|
||||
mockSafeGetJsonBody.mockResolvedValue({
|
||||
stream: true,
|
||||
scope: "document",
|
||||
messages: [{ role: "user", content: "总结当前页面" }],
|
||||
context: {
|
||||
documentId: "doc-1",
|
||||
},
|
||||
options: {
|
||||
ai: {
|
||||
provider: "hermes",
|
||||
sessionId: "hermes-session-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockValidateRequestBody.mockReturnValue(null);
|
||||
mockIsConvexEnabled.mockReturnValue(true);
|
||||
mockGetAuthedConvexClient.mockResolvedValue({
|
||||
auth: {
|
||||
userId: "user-1",
|
||||
},
|
||||
});
|
||||
mockStartHermesRun.mockResolvedValue({ runId: "hermes-run-1" });
|
||||
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
|
||||
await onEvent({ event: "message.delta", delta: "Hermes " });
|
||||
await onEvent({ event: "run.completed", output: "Hermes 已回复" });
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(new Request("http://127.0.0.1:3000/api/ai-agent/run", { method: "POST" }));
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
|
||||
expect(mockStartHermesRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
session_id: "hermes-session-1",
|
||||
}),
|
||||
);
|
||||
expect(response.headers.get("x-mnote-ai-execution-owner")).toBe("hermes");
|
||||
expect(text).toContain("Hermes 已回复");
|
||||
});
|
||||
|
||||
it("provider=claudecode 未接桥时必须明确报错,不能静默进入 mnote-cli", async () => {
|
||||
mockSafeGetJsonBody.mockResolvedValue({
|
||||
stream: true,
|
||||
scope: "document",
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
context: {
|
||||
documentId: "doc-1",
|
||||
},
|
||||
options: {
|
||||
ai: {
|
||||
provider: "claudecode",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockValidateRequestBody.mockReturnValue(null);
|
||||
mockIsConvexEnabled.mockReturnValue(true);
|
||||
mockGetAuthedConvexClient.mockResolvedValue({
|
||||
auth: {
|
||||
userId: "user-1",
|
||||
},
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(new Request("http://127.0.0.1:3000/api/ai-agent/run", { method: "POST" }));
|
||||
const text = await response.text();
|
||||
|
||||
expect(response.status).toBe(501);
|
||||
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
|
||||
expect(text).toContain("ClaudeCode");
|
||||
});
|
||||
|
||||
it("未登录时不应启动 mnote-cli host", async () => {
|
||||
mockSafeGetJsonBody.mockResolvedValue({
|
||||
stream: true,
|
||||
|
||||
@@ -1,13 +1,130 @@
|
||||
import { errorResponses, safeGetJsonBody, validateRequestBody } from "@/lib/api-utils";
|
||||
import { codexMessagesToPrompt, findWorkspaceRoot, startCodexJsonRun } from "@/lib/ai/codex/codexExec";
|
||||
import { startHermesRun, streamHermesRunEvents } from "@/lib/ai-agent/hermes/bridge";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import {
|
||||
startMnoteCliAgentHostRun,
|
||||
type MnoteCliAgentRunPayload,
|
||||
} from "@/lib/server/mnote-cli-agent-host";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const toSseFrame = (event: string, data: unknown) => {
|
||||
const json = JSON.stringify(data ?? null);
|
||||
return `event: ${event}\ndata: ${json}\n\n`;
|
||||
};
|
||||
|
||||
const lastUserMessage = (payload: MnoteCliAgentRunPayload) => {
|
||||
const found = [...payload.messages].reverse().find((message) => message.role === "user");
|
||||
return String(found?.content ?? "");
|
||||
};
|
||||
|
||||
const codexSandboxForPayload = (payload: MnoteCliAgentRunPayload) =>
|
||||
/^\s*#dev\b/i.test(lastUserMessage(payload)) ? "workspace-write" : "read-only";
|
||||
|
||||
async function startCodexAgentRun(payload: MnoteCliAgentRunPayload): Promise<Response> {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
const send = (event: string, data: unknown) => controller.enqueue(encoder.encode(toSseFrame(event, data)));
|
||||
send("ready", { ok: true, bridgeOwner: "codex" });
|
||||
try {
|
||||
const cwd = await findWorkspaceRoot(process.cwd());
|
||||
const prompt = codexMessagesToPrompt(payload.messages);
|
||||
const run = startCodexJsonRun({
|
||||
cwd,
|
||||
sandbox: codexSandboxForPayload(payload),
|
||||
prompt,
|
||||
model: payload.options?.ai?.model,
|
||||
sessionId: payload.options?.ai?.sessionId,
|
||||
onJsonLine: (line) => {
|
||||
if (line.type === "thread.started" && typeof line.thread_id === "string" && line.thread_id.trim()) {
|
||||
send("codex_session", { sessionId: line.thread_id.trim() });
|
||||
}
|
||||
},
|
||||
});
|
||||
const result = await run.done;
|
||||
if (!result.ok) {
|
||||
send("error", { ok: false, message: result.error || "Codex 执行失败" });
|
||||
return;
|
||||
}
|
||||
const sessionId = result.threadId.trim();
|
||||
if (sessionId) send("codex_session", { sessionId });
|
||||
send("assistant_message", { text: result.text || "(无输出)" });
|
||||
send("completion", { ok: true, text: result.text || "(无输出)", steps: 1 });
|
||||
} catch (error) {
|
||||
send("error", { ok: false, message: error instanceof Error ? error.message : String(error) });
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
"x-mnote-ai-execution-owner": "codex",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function startHermesAgentRun(payload: MnoteCliAgentRunPayload): Promise<Response> {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
const send = (event: string, data: unknown) => controller.enqueue(encoder.encode(toSseFrame(event, data)));
|
||||
send("ready", { ok: true, bridgeOwner: "hermes" });
|
||||
try {
|
||||
const started = await startHermesRun({
|
||||
input: payload.messages.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
})),
|
||||
conversation_history: payload.messages.slice(0, -1).map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
})),
|
||||
session_id: payload.options?.ai?.sessionId,
|
||||
});
|
||||
let assistantText = "";
|
||||
await streamHermesRunEvents(started.runId, (event) => {
|
||||
if (event.event === "message.delta" && typeof event.delta === "string") {
|
||||
assistantText += event.delta;
|
||||
send("assistant_delta", { text: event.delta });
|
||||
}
|
||||
if (event.event === "run.completed") {
|
||||
const output = typeof event.output === "string" && event.output.trim() ? event.output.trim() : assistantText.trim();
|
||||
send("assistant_message", { text: output || "(无输出)" });
|
||||
send("completion", { ok: true, text: output || "(无输出)", steps: 1 });
|
||||
}
|
||||
if (event.event === "run.failed") {
|
||||
send("error", { ok: false, message: event.error || "Hermes 执行失败" });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
send("error", { ok: false, message: error instanceof Error ? error.message : String(error) });
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
"x-mnote-ai-execution-owner": "hermes",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const payload = await safeGetJsonBody<MnoteCliAgentRunPayload>(request);
|
||||
if (!payload) {
|
||||
@@ -32,6 +149,25 @@ export async function POST(request: Request) {
|
||||
return errorResponses.unauthorized();
|
||||
}
|
||||
|
||||
const provider = String(payload.options?.ai?.provider ?? "").trim().toLowerCase();
|
||||
if (provider === "codex") {
|
||||
return startCodexAgentRun(payload);
|
||||
}
|
||||
if (provider === "hermes") {
|
||||
return startHermesAgentRun(payload);
|
||||
}
|
||||
if (provider === "claudecode") {
|
||||
return NextResponse.json(
|
||||
{ error: "ClaudeCode bridge 尚未接入,不能静默降级到 mnote-cli。" },
|
||||
{
|
||||
status: 501,
|
||||
headers: {
|
||||
"x-mnote-ai-execution-owner": "claudecode-unavailable",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return startMnoteCliAgentHostRun({
|
||||
request,
|
||||
userId,
|
||||
|
||||
@@ -33,6 +33,7 @@ type PageRuntimeState = {
|
||||
};
|
||||
|
||||
type FileTreeRuntimeState = {
|
||||
activeRowId: string | null;
|
||||
selection: {
|
||||
selectedRowIds: string[];
|
||||
anchorRowId: string | null;
|
||||
@@ -283,6 +284,7 @@ export function TreeShellRustDomShellHost({
|
||||
const [fileTreeState, setFileTreeState] = useState<FileTreeRuntimeState>(() => {
|
||||
const selection = buildTreeShellDomFiletreeSelection(activeDocumentId);
|
||||
return {
|
||||
activeRowId: activeDocumentId ? `index:${activeDocumentId}` : null,
|
||||
selection,
|
||||
dragRowIds: [],
|
||||
dragEffect: null,
|
||||
@@ -560,6 +562,10 @@ export function TreeShellRustDomShellHost({
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (event.kind === "fileTreeKeyboardCommand") {
|
||||
window.dispatchEvent(new CustomEvent("tree.filetree.keyboard-command", { detail: event }));
|
||||
continue;
|
||||
}
|
||||
if (event.kind === "fileTreeContextMenu") {
|
||||
onFileTreeContextMenu?.({
|
||||
documentId,
|
||||
@@ -640,6 +646,7 @@ export function TreeShellRustDomShellHost({
|
||||
if (mode !== "filetree") return;
|
||||
const selection = buildTreeShellDomFiletreeSelection(activeDocumentId);
|
||||
setFileTreeState({
|
||||
activeRowId: activeDocumentId ? `index:${activeDocumentId}` : null,
|
||||
selection,
|
||||
dragRowIds: [],
|
||||
dragEffect: null,
|
||||
@@ -858,6 +865,45 @@ export function TreeShellRustDomShellHost({
|
||||
[reduceFileTreeAction],
|
||||
);
|
||||
|
||||
const handleFileTreeKeyDown = useCallback(
|
||||
async (item: TreeShellDomProjectionItem, event: ReactKeyboardEvent<HTMLElement>) => {
|
||||
const actionByKey: Record<string, Record<string, unknown> | undefined> = {
|
||||
ArrowDown: { kind: "focusNext" },
|
||||
ArrowUp: { kind: "focusPrevious" },
|
||||
Home: { kind: "focusFirst" },
|
||||
End: { kind: "focusLast" },
|
||||
Enter: { kind: "openFocused" },
|
||||
F2: { kind: "beginRenameFocused" },
|
||||
Delete: { kind: "deleteSelection" },
|
||||
Backspace: { kind: "deleteSelection" },
|
||||
Escape: { kind: "escape" },
|
||||
};
|
||||
const shortcut = event.ctrlKey || event.metaKey;
|
||||
const action = shortcut && event.key.toLowerCase() === "c"
|
||||
? { kind: "copySelection" }
|
||||
: shortcut && event.key.toLowerCase() === "x"
|
||||
? { kind: "cutSelection" }
|
||||
: shortcut && event.key.toLowerCase() === "v"
|
||||
? { kind: "pasteIntoFocused" }
|
||||
: actionByKey[event.key];
|
||||
if (!action) return;
|
||||
event.preventDefault();
|
||||
const rowId = toRowId(item);
|
||||
const { result } = await reduceFileTreeAction(action, {
|
||||
...fileTreeState,
|
||||
selection: {
|
||||
...fileTreeState.selection,
|
||||
focusedRowId: fileTreeState.selection.focusedRowId ?? rowId,
|
||||
},
|
||||
});
|
||||
applyFileTreeHostEvents(result?.hostEvents, {
|
||||
rowId,
|
||||
rowKind: toShellRowKind(item),
|
||||
});
|
||||
},
|
||||
[applyFileTreeHostEvents, fileTreeState, reduceFileTreeAction],
|
||||
);
|
||||
|
||||
const handleFileTreeDrop = useCallback(
|
||||
async (item: TreeShellDomProjectionItem, event: DragEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -998,6 +1044,7 @@ export function TreeShellRustDomShellHost({
|
||||
const documentId = toDocumentId(item);
|
||||
const assetId = toAssetId(item);
|
||||
const selected = fileTreeState.selection.selectedRowIds.includes(rowId);
|
||||
const active = fileTreeState.activeRowId === rowId || activeDocumentId === documentId;
|
||||
return (
|
||||
<div
|
||||
key={rowId}
|
||||
@@ -1013,7 +1060,7 @@ export function TreeShellRustDomShellHost({
|
||||
data-asset-id={assetId ?? ""}
|
||||
data-shell-mode="filetree"
|
||||
data-selected={selected}
|
||||
data-active={activeDocumentId === documentId}
|
||||
data-active={active}
|
||||
className={cn(
|
||||
"tree-row group flex min-h-[27px] w-full cursor-pointer items-center gap-2 rounded-[3px] px-3 py-[2px] text-[14px] font-medium text-wolai-text-primary transition-colors hover:bg-wolai-bg-hover",
|
||||
selected && "bg-wolai-bg-active text-[#2563eb]",
|
||||
@@ -1023,6 +1070,7 @@ export function TreeShellRustDomShellHost({
|
||||
draggable
|
||||
onClick={(event) => void handleFileTreeSelect(item, event)}
|
||||
onDoubleClick={() => void handleFileTreeOpen(item)}
|
||||
onKeyDown={(event) => void handleFileTreeKeyDown(item, event)}
|
||||
onContextMenu={(event) => void handleFileTreeContextMenu(item, event)}
|
||||
onDragStart={(event) => {
|
||||
const rowIds = fileTreeState.selection.selectedRowIds.includes(rowId) ? fileTreeState.selection.selectedRowIds : [rowId];
|
||||
@@ -1103,7 +1151,15 @@ export function TreeShellRustDomShellHost({
|
||||
{renderPageRows("")}
|
||||
</div>
|
||||
) : mode === "filetree" ? (
|
||||
<div className="tree-root" role="tree" data-rust-filetree-renderer="dom_v1">
|
||||
<div
|
||||
className="tree-root"
|
||||
role="tree"
|
||||
data-rust-filetree-renderer="dom_v1"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
void reduceFileTreeAction({ kind: "clearSelection" });
|
||||
}}
|
||||
>
|
||||
{renderFileTreeRows()}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -296,7 +296,7 @@ describe("tree-shell-iframe-host", () => {
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"runtimeApi":{"requestContract":"TreeShellRuntimeRequest","resultContract":"TreeShellRuntimeResult"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"reduceEndpoint":"/api/tree/runtime/reduce"');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"domPatchKinds":["pageState","fileTreeState","pickerState"]');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"hostEventKinds":["pageOpen","pageContextMenu","fileTreeOpen","fileTreeContextMenu","fileTreeInternalDrop","fileTreeExternalDrop","pickerPickRoot","pickerPickDocument"]');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"hostEventKinds":["pageOpen","pageContextMenu","fileTreeOpen","fileTreeContextMenu","fileTreeInternalDrop","fileTreeExternalDrop","fileTreeKeyboardCommand","pickerPickRoot","pickerPickDocument"]');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"commandEventKinds":["createNode","renameNode","moveSubtree","copyResource","moveResource","uploadResource"]');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain('"eventKinds":["focus","keyboard","expandCollapse","selection","contextMenu","dragDrop","pick"]');
|
||||
expect(iframe?.getAttribute("srcdoc")).toContain("applyPageKeyboardAction");
|
||||
|
||||
@@ -257,6 +257,7 @@ const TREE_SHELL_RUNTIME_ARTIFACT = {
|
||||
"fileTreeContextMenu",
|
||||
"fileTreeInternalDrop",
|
||||
"fileTreeExternalDrop",
|
||||
"fileTreeKeyboardCommand",
|
||||
"pickerPickRoot",
|
||||
"pickerPickDocument",
|
||||
],
|
||||
|
||||
@@ -26,6 +26,7 @@ function reduceTreeRuntimeForTest(request: RuntimeRequest) {
|
||||
const state = request.state ?? {};
|
||||
const action = request.action ?? {};
|
||||
const env = request.environment ?? {};
|
||||
const selection = (state.selection ?? {}) as Record<string, unknown>;
|
||||
const rows = Array.isArray(env.rows) ? env.rows as Array<Record<string, unknown>> : [];
|
||||
const rowId = typeof action.targetRowId === "string" ? action.targetRowId : null;
|
||||
const targetRow = rows.find((row) => row.rowId === rowId) ?? {};
|
||||
@@ -49,12 +50,28 @@ function reduceTreeRuntimeForTest(request: RuntimeRequest) {
|
||||
targetRowId: rowId,
|
||||
target,
|
||||
fileCount: action.fileCount ?? 0,
|
||||
}]
|
||||
: [];
|
||||
}]
|
||||
: [];
|
||||
const selectedRowId = typeof action.rowId === "string" ? action.rowId : null;
|
||||
const nextSelection =
|
||||
action.kind === "selectRow" && selectedRowId
|
||||
? { selectedRowIds: [selectedRowId], anchorRowId: selectedRowId, focusedRowId: selectedRowId }
|
||||
: action.kind === "clearSelection"
|
||||
? { selectedRowIds: [], anchorRowId: null, focusedRowId: null }
|
||||
: selection;
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
mode: "fileTree",
|
||||
state: { mode: "fileTree", state },
|
||||
state: {
|
||||
mode: "fileTree",
|
||||
state: {
|
||||
activeRowId: (state as Record<string, unknown>).activeRowId ?? null,
|
||||
selection: nextSelection,
|
||||
dragRowIds: (state as Record<string, unknown>).dragRowIds ?? [],
|
||||
dragEffect: (state as Record<string, unknown>).dragEffect ?? null,
|
||||
dropTargetRowId: (state as Record<string, unknown>).dropTargetRowId ?? null,
|
||||
},
|
||||
},
|
||||
domPatches: [{ kind: "fileTreeState" }],
|
||||
hostEvents,
|
||||
commandEvents: [],
|
||||
@@ -414,6 +431,69 @@ describe("tree-shell-surface", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("file tree surface 应支持空白区清空选择", async () => {
|
||||
const runtimeReducerMock = mockTreeRuntimeReducer();
|
||||
const targetProjectionItem: KernelFileTreeProjectionItem = {
|
||||
rowId: "doc:doc_target",
|
||||
nodeId: "doc_target",
|
||||
parentNodeId: null,
|
||||
projectionKind: "file_tree",
|
||||
title: "目标页面",
|
||||
depth: 0,
|
||||
position: 0,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
nodeType: "page",
|
||||
rowKind: "document",
|
||||
iconHint: "page",
|
||||
capabilities: ["open", "select", "context-menu"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_target",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SidebarTreeSurface
|
||||
mode="filetree"
|
||||
rendererFamily="rust_family"
|
||||
workspaceId="ws_1"
|
||||
treeShellEnabled
|
||||
rows={[]}
|
||||
treeShellItems={[targetProjectionItem]}
|
||||
activeId=""
|
||||
onRowClick={() => undefined}
|
||||
onRowDoubleClick={() => undefined}
|
||||
onRowContextMenu={() => undefined}
|
||||
onToggleExpand={() => undefined}
|
||||
onCreateChild={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const targetRow = container.querySelector('[data-testid="filetree-doc-row"][data-row-id="doc:doc_target"]') as HTMLElement | null;
|
||||
const treeRoot = container.querySelector('[data-rust-filetree-renderer="dom_v1"]') as HTMLElement | null;
|
||||
expect(targetRow).not.toBeNull();
|
||||
expect(treeRoot).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
targetRow?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await waitForRuntimeReducer(runtimeReducerMock);
|
||||
expect(targetRow?.getAttribute("data-selected")).toBe("true");
|
||||
|
||||
await act(async () => {
|
||||
treeRoot?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(targetRow?.getAttribute("data-selected")).toBe("false");
|
||||
});
|
||||
|
||||
it("picker surface 在 rust_family 下也应挂到默认 Rust/WASM DOM shell host", async () => {
|
||||
const onPick = vi.fn();
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export function buildPageAggregateFromDocumentPayloads(input: {
|
||||
const pageOptions: PageOptionsState = {
|
||||
wideLayout: input.meta.wide_layout ?? false,
|
||||
smallText: input.meta.use_small_text ?? false,
|
||||
showHeadingNumbers: input.meta.show_heading_numbers ?? true,
|
||||
showHeadingNumbers: input.meta.show_heading_numbers ?? false,
|
||||
showToc: input.meta.show_toc ?? false,
|
||||
showStructure: input.meta.show_structure ?? false,
|
||||
protectEditing: input.meta.protect_editing ?? false,
|
||||
|
||||
@@ -116,6 +116,15 @@ describe("buildVisibleRows", () => {
|
||||
documentId: "page_root",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
extra: {
|
||||
source: {
|
||||
sourceKind: "convex_workspace",
|
||||
sourceUri: "convex://workspace/ws_1/documents/page_root",
|
||||
relativePath: "documents/page_root",
|
||||
storageIdentity: "page_root",
|
||||
operationProfile: "convex_workspace",
|
||||
},
|
||||
},
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
@@ -138,6 +147,15 @@ describe("buildVisibleRows", () => {
|
||||
documentId: "page_root",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "index",
|
||||
extra: {
|
||||
source: {
|
||||
sourceKind: "convex_workspace",
|
||||
sourceUri: "convex://workspace/ws_1/documents/page_root/index",
|
||||
relativePath: "documents/page_root/index",
|
||||
storageIdentity: "page_root:index",
|
||||
operationProfile: "convex_workspace",
|
||||
},
|
||||
},
|
||||
},
|
||||
iconHint: "index",
|
||||
},
|
||||
@@ -162,6 +180,15 @@ describe("buildVisibleRows", () => {
|
||||
workspaceId: "ws_1",
|
||||
assetKind: "mindmap",
|
||||
iconHint: "mindmap",
|
||||
extra: {
|
||||
source: {
|
||||
sourceKind: "local_folder",
|
||||
sourceUri: "file:///workspace/ws_1/page_root/mindmap.json",
|
||||
relativePath: "page_root/mindmap.json",
|
||||
storageIdentity: "mind_1",
|
||||
operationProfile: "local_readonly",
|
||||
},
|
||||
},
|
||||
},
|
||||
iconHint: "mindmap",
|
||||
},
|
||||
@@ -186,6 +213,15 @@ describe("buildVisibleRows", () => {
|
||||
workspaceId: "ws_1",
|
||||
assetKind: "image",
|
||||
iconHint: "image",
|
||||
extra: {
|
||||
source: {
|
||||
sourceKind: "local_folder",
|
||||
sourceUri: "file:///workspace/ws_1/page_root/assets/node.png",
|
||||
relativePath: "page_root/assets/node.png",
|
||||
storageIdentity: "asset_child_1",
|
||||
operationProfile: "local_readonly",
|
||||
},
|
||||
},
|
||||
},
|
||||
iconHint: "image",
|
||||
},
|
||||
@@ -210,6 +246,15 @@ describe("buildVisibleRows", () => {
|
||||
workspaceId: "ws_1",
|
||||
assetKind: "table",
|
||||
iconHint: "table",
|
||||
extra: {
|
||||
source: {
|
||||
sourceKind: "convex_workspace",
|
||||
sourceUri: "convex://workspace/ws_1/assets/table_1",
|
||||
relativePath: "assets/table_1",
|
||||
storageIdentity: "table_1",
|
||||
operationProfile: "convex_workspace",
|
||||
},
|
||||
},
|
||||
},
|
||||
iconHint: "table",
|
||||
},
|
||||
|
||||
@@ -32,6 +32,14 @@ describe("tree-projection contract", () => {
|
||||
resourceKind: "document",
|
||||
documentId: "root",
|
||||
iconHint: "page",
|
||||
extra: {
|
||||
source: {
|
||||
sourceKind: "convex_workspace",
|
||||
sourceUri: "convex://workspace/ws_1/documents/root",
|
||||
storageIdentity: "root",
|
||||
operationProfile: "convex_workspace",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(items[0]?.capabilities).toContain("open");
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import type {
|
||||
TreeProjectionCapability,
|
||||
TreeProjectionItemBase,
|
||||
TreeProjectionSourceMetadata,
|
||||
} from "@/lib/tree-protocol";
|
||||
|
||||
export type PageTreeProjectionItem = {
|
||||
@@ -35,6 +36,16 @@ function buildPageCapabilities(childCount: number): TreeProjectionCapability[] {
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
function buildPageSourceMetadata(node: SidebarTreeNode): TreeProjectionSourceMetadata {
|
||||
return {
|
||||
sourceKind: "convex_workspace",
|
||||
sourceUri: `convex://workspace/${node.workspace_id}/documents/${node.id}`,
|
||||
relativePath: `documents/${node.id}`,
|
||||
storageIdentity: node.id,
|
||||
operationProfile: "convex_workspace",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPageTreeProjectionItems(
|
||||
nodes: SidebarTreeNode[],
|
||||
): PageTreeProjectionItem[] {
|
||||
@@ -71,6 +82,9 @@ export function buildPageTreeProjectionItems(
|
||||
documentId: node.id,
|
||||
workspaceId: node.workspace_id,
|
||||
iconHint: "page",
|
||||
extra: {
|
||||
source: buildPageSourceMetadata(node),
|
||||
},
|
||||
},
|
||||
iconHint: "page",
|
||||
node: {
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TreeProjectionItemBase } from "@/lib/tree-protocol";
|
||||
import type {
|
||||
TreeProjectionItemBase,
|
||||
WorkspaceSource,
|
||||
} from "@/lib/tree-protocol";
|
||||
|
||||
describe("tree-protocol", () => {
|
||||
it("冻结 WorkspaceSource 合同字段", () => {
|
||||
const source: WorkspaceSource = {
|
||||
sourceKind: "convex_workspace",
|
||||
rootUri: "convex://workspace/ws_1",
|
||||
workspaceId: "ws_1",
|
||||
capabilities: [
|
||||
"load-snapshot",
|
||||
"watch",
|
||||
"preflight-command",
|
||||
"execute-command",
|
||||
"resolve-page-aggregate",
|
||||
],
|
||||
};
|
||||
|
||||
expect(source.sourceKind).toBe("convex_workspace");
|
||||
expect(source.rootUri).toContain("workspace/ws_1");
|
||||
expect(source.capabilities).toContain("load-snapshot");
|
||||
});
|
||||
|
||||
it("冻结共享 tree projection 字段与 file_tree 扩展语义", () => {
|
||||
const item: TreeProjectionItemBase = {
|
||||
nodeId: "doc_1",
|
||||
@@ -20,12 +42,28 @@ describe("tree-protocol", () => {
|
||||
documentId: "doc_1",
|
||||
assetKind: "file",
|
||||
iconHint: "page",
|
||||
extra: {
|
||||
source: {
|
||||
sourceKind: "convex_workspace",
|
||||
sourceUri: "convex://workspace/ws_1",
|
||||
relativePath: "docs/doc_1.md",
|
||||
storageIdentity: "doc_1",
|
||||
operationProfile: "convex_workspace",
|
||||
},
|
||||
},
|
||||
},
|
||||
iconHint: "page",
|
||||
};
|
||||
|
||||
expect(item.resourceMeta.resourceKind).toBe("document");
|
||||
expect(item.resourceMeta.assetKind).toBe("file");
|
||||
expect(item.resourceMeta.extra?.source).toMatchObject({
|
||||
sourceKind: "convex_workspace",
|
||||
sourceUri: "convex://workspace/ws_1",
|
||||
relativePath: "docs/doc_1.md",
|
||||
storageIdentity: "doc_1",
|
||||
operationProfile: "convex_workspace",
|
||||
});
|
||||
expect(item.capabilities).toContain("expand");
|
||||
expect(item.expandable).toBe(true);
|
||||
expect(item.iconHint).toBe("page");
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
export type TreeProjectionKind = "sidebar_tree" | "page_tree" | "file_tree";
|
||||
|
||||
export type WorkspaceSourceKind = "local_folder" | "convex_workspace";
|
||||
|
||||
export type WorkspaceSourceCapability =
|
||||
| "load-snapshot"
|
||||
| "watch"
|
||||
| "preflight-command"
|
||||
| "execute-command"
|
||||
| "resolve-page-aggregate";
|
||||
|
||||
export type WorkspaceSource = {
|
||||
sourceKind: WorkspaceSourceKind;
|
||||
rootUri: string;
|
||||
workspaceId: string;
|
||||
capabilities: WorkspaceSourceCapability[];
|
||||
};
|
||||
|
||||
export type TreeProjectionNodeType =
|
||||
| "workspace"
|
||||
| "folder"
|
||||
@@ -49,6 +65,22 @@ export type TreeProjectionAssetKind =
|
||||
| "audio"
|
||||
| "unknown";
|
||||
|
||||
export type TreeProjectionSourceOperationProfile =
|
||||
| "local_readonly"
|
||||
| "convex_workspace";
|
||||
|
||||
export type TreeProjectionSourceMetadata = {
|
||||
sourceKind: WorkspaceSourceKind;
|
||||
sourceUri: string;
|
||||
relativePath?: string | null;
|
||||
storageIdentity?: string | null;
|
||||
operationProfile?: TreeProjectionSourceOperationProfile | null;
|
||||
};
|
||||
|
||||
export type TreeProjectionResourceExtra = {
|
||||
source?: TreeProjectionSourceMetadata | null;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
export type TreeProjectionResourceMeta = {
|
||||
resourceKind: TreeProjectionResourceKind;
|
||||
documentId?: string | null;
|
||||
@@ -56,7 +88,7 @@ export type TreeProjectionResourceMeta = {
|
||||
workspaceId?: string | null;
|
||||
assetKind?: TreeProjectionAssetKind | null;
|
||||
iconHint?: string | null;
|
||||
extra?: Record<string, unknown>;
|
||||
extra?: TreeProjectionResourceExtra;
|
||||
};
|
||||
|
||||
export type TreeProjectionItemBase = {
|
||||
|
||||
Reference in New Issue
Block a user