源码
packages/desktop/src/renderer/ChangesPane.tsx
import {
DiffView,
type FindRequest,
type FindState,
ToolFilePreview,
toolFile,
useSelectionStableValue,
} from "@zpi-dev/ui";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { DiffItem } from "../shared/bridge.ts";
import { PreviewHeader } from "./PreviewHeader.tsx";
import { openFile, paneTask } from "./pane-store.ts";
import { useStore } from "./store.ts";
import { unwrap } from "./unwrap.ts";
import { userErrorMessage } from "./user-error.ts";
const emptyEntries: DiffItem[] = [];
export function ChangesPane({
sessionId,
runId,
path,
toolCallId,
visible,
findRequest,
onFindStateChange,
headerContainer,
workspaceCwd,
}: {
findRequest?: FindRequest;
onFindStateChange?: (state: FindState) => void;
sessionId: string;
runId: string | null;
path?: string;
toolCallId?: string;
visible: boolean;
headerContainer?: HTMLElement | null;
workspaceCwd?: string;
}) {
const incomingOperation = useStore((state) =>
state.views
.get(sessionId)
?.runs.find((run) => run.runId === runId)
?.orderedBlocks.find((block) => block.type === "tool" && block.toolCallId === toolCallId),
);
const root = useRef<HTMLDivElement>(null);
const operation = useSelectionStableValue(
incomingOperation,
JSON.stringify([sessionId, runId, toolCallId ?? null, path ?? null]),
root,
);
const pending = operation?.type === "tool" && ["preparing", "running"].includes(operation.status);
const previewOperation = operation?.type === "tool" && !operation.fileChange;
const previewPath = path || (operation?.type === "tool" ? toolFile(operation)?.path : undefined);
const scope = JSON.stringify([sessionId, runId, toolCallId ?? null, previewPath ?? null]);
const [result, setResult] = useState<{
scope: string;
entries: DiffItem[];
error: string;
loading: boolean;
}>();
const [revision, setRevision] = useState(0);
// 来源切换的首帧也不能露出旧文件;同来源刷新则保留已展示的内容。
const entries = result?.scope === scope ? result.entries : emptyEntries;
const error = result?.scope === scope ? result.error : "";
const loading = result?.scope === scope ? result.loading : visible && !previewOperation;
useEffect(() => {
if (!visible || previewOperation) return;
let active = true;
setResult((current) => ({
scope,
entries: current?.scope === scope ? current.entries : emptyEntries,
error: "",
loading: true,
}));
const changes = toolCallId
? previewPath
? window.zpi
.readPatch(sessionId, runId, `operation:${toolCallId}`)
.then(unwrap)
.then((patch): DiffItem[] => [
{
id: `operation:${toolCallId}`,
path: previewPath,
status: "modified",
area: "工具变更",
patch,
},
])
: Promise.reject(new Error("无法确定此工具变更的文件,请从工具卡片重新打开。"))
: window.zpi
.getChanges(sessionId, runId)
.then(unwrap)
// initialPath 只是默认选择;先限定数据,才能同时隔离文件导航和搜索。
.then((value) => (previewPath ? value.filter((entry) => entry.path === previewPath) : value));
void changes
.then((value) => {
if (active) setResult({ scope, entries: value, error: "", loading: false });
})
.catch((error) => {
if (active)
setResult((current) => ({
scope,
entries: current?.scope === scope ? current.entries : emptyEntries,
error: userErrorMessage(error),
loading: false,
}));
});
return () => {
active = false;
};
}, [sessionId, runId, revision, visible, toolCallId, previewPath, previewOperation, scope]);
useEffect(() => {
if (!visible) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const off = window.zpi.onEvent((event) => {
if (
event.sessionId === sessionId &&
(event.event.type === "settled" || event.event.type === "tool_end")
) {
clearTimeout(timer);
timer = setTimeout(() => setRevision((n) => n + 1), 200);
}
});
return () => {
clearTimeout(timer);
off();
};
}, [sessionId, visible]);
const load = useCallback(
(id: string) => window.zpi.readPatch(sessionId, runId, id).then(unwrap),
[sessionId, runId],
);
const open = useCallback(
(id: string) => {
const entry = entries.find((entry) => entry.id === id);
if (entry) paneTask(openFile(sessionId, entry.path));
},
[sessionId, entries],
);
const renderHeader = (selectedPath: string, viewSource?: () => void) => {
const header = (
<PreviewHeader
sessionId={sessionId}
path={selectedPath}
onViewSource={viewSource}
workspaceCwd={workspaceCwd}
/>
);
return headerContainer ? createPortal(header, headerContainer) : header;
};
return (
<div ref={root} className="changes-pane">
{!pending && error && (
<p role="alert" className="run-error">
{error}
</p>
)}
{previewOperation && operation?.type === "tool" ? (
<div className="pending-file-preview">
{previewPath && renderHeader(previewPath)}
{pending && (
<p className="pane-empty">
{operation.status === "preparing" ? "正在生成文件内容…" : "正在写入文件…"}
</p>
)}
<ToolFilePreview block={operation} />
</div>
) : !entries.length ? (
<>
{previewPath && renderHeader(previewPath, () => paneTask(openFile(sessionId, previewPath)))}
<p className="pane-empty">{loading ? "加载变更…" : "暂无文件变更"}</p>
</>
) : (
<DiffView
key={scope}
entries={entries}
renderHeader={renderHeader}
initialPath={previewPath}
loadPatch={load}
openFile={open}
findRequest={visible ? findRequest : undefined}
onFindStateChange={onFindStateChange}
/>
)}
</div>
);
}