mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 02:46:29 +00:00
feat: lazy-load diff RPCs for mobile performance
Opening a workspace with a large diff blocked the JS thread because the full structured diff (hunks, lines, syntax tokens) was sent over the wire and parsed via JSON.parse + Zod safeParse on the main thread. Add namespaced checkout RPCs that split diff delivery into two phases: 1. **Metadata-only snapshots** (`checkout/subscribe_diff` → `checkout/diff_snapshot`) — lightweight file list with status, path, insertions/deletions, and a fingerprint. Sent on every file-system change. No hunks, no tokens. 2. **On-demand hunk fetching** (`checkout/get_file_hunks` → `checkout/file_hunks_response`) — full structured diff for a single file, fetched when the user expands it. Served from an in-memory cache with a single-file git fallback. Feature detection via `server_info.features.lazyDiffRpcs`. New clients fall back to the V1 full-diff path when talking to older daemons. Old clients are completely unaffected — all new schema fields are optional and new message types are additive. Per-file fingerprints (hash of hunks excluding tokens) drive React Query cache invalidation so expanded files auto-refresh only when their actual content changes.
This commit is contained in:
@@ -516,6 +516,70 @@ describe("DaemonClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("subscribes to lazy checkout diff updates via RPC handshake", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.subscribeCheckoutDiffLazy(
|
||||
"/tmp/project",
|
||||
{ mode: "uncommitted" },
|
||||
{ subscriptionId: "checkout-lazy-sub-1" },
|
||||
);
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "checkout/subscribe_diff";
|
||||
subscriptionId: string;
|
||||
cwd: string;
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string };
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("checkout/subscribe_diff");
|
||||
expect(request.message.subscriptionId).toBe("checkout-lazy-sub-1");
|
||||
expect(request.message.cwd).toBe("/tmp/project");
|
||||
expect(request.message.compare).toEqual({ mode: "uncommitted", ignoreWhitespace: false });
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "checkout/diff_snapshot",
|
||||
payload: {
|
||||
subscriptionId: "checkout-lazy-sub-1",
|
||||
cwd: "/tmp/project",
|
||||
files: [],
|
||||
error: null,
|
||||
requestId: request.message.requestId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
subscriptionId: "checkout-lazy-sub-1",
|
||||
cwd: "/tmp/project",
|
||||
files: [],
|
||||
error: null,
|
||||
requestId: request.message.requestId,
|
||||
});
|
||||
});
|
||||
|
||||
test("getCheckoutDiff uses one-shot subscription protocol", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
@@ -589,6 +653,105 @@ describe("DaemonClient", () => {
|
||||
expect(unsubscribeRequest.message.subscriptionId).toBe(subscribeRequest.message.subscriptionId);
|
||||
});
|
||||
|
||||
test("getCheckoutFileHunks requests hunks via RPC", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.getCheckoutFileHunks(
|
||||
"/tmp/project",
|
||||
{ mode: "base", baseRef: "main" },
|
||||
"src/app.ts",
|
||||
);
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "checkout/get_file_hunks";
|
||||
cwd: string;
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
||||
path: string;
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("checkout/get_file_hunks");
|
||||
expect(request.message.cwd).toBe("/tmp/project");
|
||||
expect(request.message.compare).toEqual({
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
ignoreWhitespace: false,
|
||||
});
|
||||
expect(request.message.path).toBe("src/app.ts");
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "checkout/file_hunks_response",
|
||||
payload: {
|
||||
requestId: request.message.requestId,
|
||||
path: "src/app.ts",
|
||||
hunks: [],
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
requestId: request.message.requestId,
|
||||
path: "src/app.ts",
|
||||
hunks: [],
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("unsubscribeCheckoutDiffLazy sends unsubscribe request", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
client.unsubscribeCheckoutDiffLazy("checkout-lazy-sub-1");
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "checkout/unsubscribe_diff";
|
||||
subscriptionId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message).toEqual({
|
||||
type: "checkout/unsubscribe_diff",
|
||||
subscriptionId: "checkout-lazy-sub-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("requests branch suggestions via RPC", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
@@ -886,6 +1049,60 @@ describe("DaemonClient", () => {
|
||||
expect(request.message.requestId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("resubscribes lazy checkout diff streams after reconnect", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const internal = client as unknown as {
|
||||
checkoutDiffLazySubscriptions: Map<
|
||||
string,
|
||||
{
|
||||
cwd: string;
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
||||
}
|
||||
>;
|
||||
};
|
||||
internal.checkoutDiffLazySubscriptions.set("checkout-lazy-sub-1", {
|
||||
cwd: "/tmp/project",
|
||||
compare: { mode: "base", baseRef: "main", ignoreWhitespace: false },
|
||||
});
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: "session";
|
||||
message: {
|
||||
type: "checkout/subscribe_diff";
|
||||
subscriptionId: string;
|
||||
cwd: string;
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
||||
requestId: string;
|
||||
};
|
||||
};
|
||||
expect(request.message.type).toBe("checkout/subscribe_diff");
|
||||
expect(request.message.subscriptionId).toBe("checkout-lazy-sub-1");
|
||||
expect(request.message.cwd).toBe("/tmp/project");
|
||||
expect(request.message.compare).toEqual({
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
ignoreWhitespace: false,
|
||||
});
|
||||
expect(typeof request.message.requestId).toBe("string");
|
||||
expect(request.message.requestId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("fetches agents via RPC with filters, sort, and pagination", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -216,6 +216,14 @@ type SubscribeCheckoutDiffPayload = Extract<
|
||||
{ type: "subscribe_checkout_diff_response" }
|
||||
>["payload"];
|
||||
type CheckoutDiffPayload = Omit<SubscribeCheckoutDiffPayload, "subscriptionId">;
|
||||
type CheckoutDiffMetadataSnapshotPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "checkout/diff_snapshot" }
|
||||
>["payload"];
|
||||
type CheckoutFileHunksPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "checkout/file_hunks_response" }
|
||||
>["payload"];
|
||||
type CheckoutCommitPayload = CheckoutCommitResponse["payload"];
|
||||
type CheckoutMergePayload = CheckoutMergeResponse["payload"];
|
||||
type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
|
||||
@@ -500,8 +508,13 @@ type SetDaemonConfigResponse = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "set_daemon_config_response" }
|
||||
>;
|
||||
type CheckoutDiffSnapshotResponse = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "checkout/diff_snapshot" }
|
||||
>;
|
||||
type CorrelatedResponseMessage =
|
||||
| Extract<SessionOutboundMessage, { payload: { requestId: string } }>
|
||||
| CheckoutDiffSnapshotResponse
|
||||
| GetDaemonConfigResponse
|
||||
| SetDaemonConfigResponse;
|
||||
type CorrelatedResponseType = CorrelatedResponseMessage["type"];
|
||||
@@ -616,6 +629,13 @@ export class DaemonClient {
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
||||
}
|
||||
>();
|
||||
private checkoutDiffLazySubscriptions = new Map<
|
||||
string,
|
||||
{
|
||||
cwd: string;
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
||||
}
|
||||
>();
|
||||
private terminalDirectorySubscriptions = new Set<string>();
|
||||
private terminalSlots = new Map<string, number>();
|
||||
private slotTerminals = new Map<number, string>();
|
||||
@@ -1408,6 +1428,22 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
private resubscribeCheckoutDiffLazySubscriptions(): void {
|
||||
if (this.checkoutDiffLazySubscriptions.size === 0) {
|
||||
return;
|
||||
}
|
||||
for (const [subscriptionId, subscription] of this.checkoutDiffLazySubscriptions) {
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "checkout/subscribe_diff",
|
||||
subscriptionId,
|
||||
cwd: subscription.cwd,
|
||||
compare: subscription.compare,
|
||||
requestId: this.createRequestId(),
|
||||
});
|
||||
this.sendSessionMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
private resubscribeTerminalDirectorySubscriptions(): void {
|
||||
if (this.terminalDirectorySubscriptions.size === 0) {
|
||||
return;
|
||||
@@ -2270,6 +2306,105 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getCheckoutDiffMetadata(
|
||||
cwd: string,
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||
requestId?: string,
|
||||
): Promise<Omit<CheckoutDiffMetadataSnapshotPayload, "subscriptionId">> {
|
||||
const oneShotSubscriptionId = `oneshot-checkout-diff-lazy:${crypto.randomUUID()}`;
|
||||
try {
|
||||
const payload = await this.subscribeCheckoutDiffLazy(cwd, compare, {
|
||||
subscriptionId: oneShotSubscriptionId,
|
||||
requestId,
|
||||
});
|
||||
return {
|
||||
cwd: payload.cwd,
|
||||
files: payload.files,
|
||||
error: payload.error,
|
||||
requestId: payload.requestId,
|
||||
};
|
||||
} finally {
|
||||
try {
|
||||
this.unsubscribeCheckoutDiffLazy(oneShotSubscriptionId);
|
||||
} catch {
|
||||
// Ignore disconnect races during one-shot cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async subscribeCheckoutDiffLazy(
|
||||
cwd: string,
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||
options?: { subscriptionId?: string; requestId?: string },
|
||||
): Promise<CheckoutDiffMetadataSnapshotPayload> {
|
||||
const subscriptionId = options?.subscriptionId ?? crypto.randomUUID();
|
||||
const normalizedCompare = this.normalizeCheckoutDiffCompare(compare);
|
||||
const previousSubscription = this.checkoutDiffLazySubscriptions.get(subscriptionId) ?? null;
|
||||
this.checkoutDiffLazySubscriptions.set(subscriptionId, {
|
||||
cwd,
|
||||
compare: normalizedCompare,
|
||||
});
|
||||
|
||||
const resolvedRequestId = this.createRequestId(options?.requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "checkout/subscribe_diff",
|
||||
subscriptionId,
|
||||
cwd,
|
||||
compare: normalizedCompare,
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
|
||||
try {
|
||||
return await this.sendCorrelatedRequest({
|
||||
requestId: resolvedRequestId,
|
||||
message,
|
||||
responseType: "checkout/diff_snapshot",
|
||||
timeout: 60000,
|
||||
options: { skipQueue: true },
|
||||
selectPayload: (payload) => {
|
||||
if (payload.subscriptionId !== subscriptionId) {
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (previousSubscription) {
|
||||
this.checkoutDiffLazySubscriptions.set(subscriptionId, previousSubscription);
|
||||
} else {
|
||||
this.checkoutDiffLazySubscriptions.delete(subscriptionId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
unsubscribeCheckoutDiffLazy(subscriptionId: string): void {
|
||||
this.checkoutDiffLazySubscriptions.delete(subscriptionId);
|
||||
this.sendSessionMessage({
|
||||
type: "checkout/unsubscribe_diff",
|
||||
subscriptionId,
|
||||
});
|
||||
}
|
||||
|
||||
async getCheckoutFileHunks(
|
||||
cwd: string,
|
||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
|
||||
path: string,
|
||||
requestId?: string,
|
||||
): Promise<CheckoutFileHunksPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "checkout/get_file_hunks",
|
||||
cwd,
|
||||
compare: this.normalizeCheckoutDiffCompare(compare),
|
||||
path,
|
||||
},
|
||||
responseType: "checkout/file_hunks_response",
|
||||
timeout: 60000,
|
||||
});
|
||||
}
|
||||
|
||||
async checkoutCommit(
|
||||
cwd: string,
|
||||
input: { message?: string; addAll?: boolean },
|
||||
@@ -3723,6 +3858,7 @@ export class DaemonClient {
|
||||
this.reconnectAttempt = 0;
|
||||
this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" });
|
||||
this.resubscribeCheckoutDiffSubscriptions();
|
||||
this.resubscribeCheckoutDiffLazySubscriptions();
|
||||
this.resubscribeTerminalDirectorySubscriptions();
|
||||
this.flushPendingSendQueue();
|
||||
this.resolveConnect();
|
||||
|
||||
@@ -81,6 +81,53 @@ vi.mock("./checkout-git-utils.js", () => ({
|
||||
|
||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
|
||||
function createStructuredFile(options?: {
|
||||
path?: string;
|
||||
addContent?: string;
|
||||
tokenStyle?: string;
|
||||
}): {
|
||||
path: string;
|
||||
isNew: boolean;
|
||||
isDeleted: boolean;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
hunks: Array<{
|
||||
oldStart: number;
|
||||
oldCount: number;
|
||||
newStart: number;
|
||||
newCount: number;
|
||||
lines: Array<{ type: "header" | "context" | "add"; content: string; tokens?: unknown[] }>;
|
||||
}>;
|
||||
status: "ok";
|
||||
} {
|
||||
const addContent = options?.addContent ?? "const value = 2;";
|
||||
return {
|
||||
path: options?.path ?? "src/example.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 2,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -1,1 +1,2 @@" },
|
||||
{ type: "context", content: "const value = 1;" },
|
||||
{
|
||||
type: "add",
|
||||
content: addContent,
|
||||
tokens: [{ text: "const", style: options?.tokenStyle ?? "keyword" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
status: "ok",
|
||||
};
|
||||
}
|
||||
|
||||
describe("CheckoutDiffManager Linux watchers", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
@@ -134,4 +181,102 @@ describe("CheckoutDiffManager Linux watchers", () => {
|
||||
subscription.unsubscribe();
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test("subscribeLazy returns metadata snapshots and caches file hunks", async () => {
|
||||
getCheckoutDiffMock.mockResolvedValueOnce({
|
||||
diff: "",
|
||||
structured: [createStructuredFile()],
|
||||
});
|
||||
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
warn: vi.fn(),
|
||||
};
|
||||
const manager = new CheckoutDiffManager({
|
||||
logger: logger as any,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
});
|
||||
|
||||
const listener = vi.fn();
|
||||
const subscription = await manager.subscribeLazy(
|
||||
{
|
||||
cwd: path.join("/tmp/repo", "packages", "server"),
|
||||
compare: { mode: "uncommitted" },
|
||||
},
|
||||
listener,
|
||||
);
|
||||
|
||||
expect(subscription.initial.error).toBeNull();
|
||||
expect(subscription.initial.files).toEqual([
|
||||
expect.objectContaining({
|
||||
path: "src/example.ts",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
fingerprint: expect.any(String),
|
||||
}),
|
||||
]);
|
||||
expect((subscription.initial.files[0] as any).hunks).toBeUndefined();
|
||||
expect(
|
||||
manager.getFileHunks(
|
||||
path.join("/tmp/repo", "packages", "server"),
|
||||
{ mode: "uncommitted" },
|
||||
"src/example.ts",
|
||||
),
|
||||
).toEqual(createStructuredFile().hunks);
|
||||
|
||||
subscription.unsubscribe();
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test("lazy metadata fingerprint ignores token-only changes but emits on hunk changes", async () => {
|
||||
getCheckoutDiffMock.mockResolvedValueOnce({
|
||||
diff: "",
|
||||
structured: [createStructuredFile({ tokenStyle: "keyword" })],
|
||||
});
|
||||
getCheckoutDiffMock.mockResolvedValueOnce({
|
||||
diff: "",
|
||||
structured: [createStructuredFile({ tokenStyle: "variableName" })],
|
||||
});
|
||||
getCheckoutDiffMock.mockResolvedValueOnce({
|
||||
diff: "",
|
||||
structured: [createStructuredFile({ addContent: "const value = 3;" })],
|
||||
});
|
||||
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
warn: vi.fn(),
|
||||
};
|
||||
const manager = new CheckoutDiffManager({
|
||||
logger: logger as any,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
});
|
||||
|
||||
const listener = vi.fn();
|
||||
await manager.subscribeLazy(
|
||||
{
|
||||
cwd: path.join("/tmp/repo", "packages", "server"),
|
||||
compare: { mode: "uncommitted" },
|
||||
},
|
||||
listener,
|
||||
);
|
||||
|
||||
const target = Array.from((manager as any).targets.values())[0];
|
||||
await (manager as any).refreshTarget(target);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
await (manager as any).refreshTarget(target);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
files: [
|
||||
expect.objectContaining({
|
||||
path: "src/example.ts",
|
||||
fingerprint: expect.any(String),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { watch, type FSWatcher } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type pino from "pino";
|
||||
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
|
||||
import type { DiffFileMetadata } from "./checkout/rpc-schemas.js";
|
||||
import { getCheckoutDiff } from "../utils/checkout-git.js";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
@@ -18,6 +20,16 @@ export type CheckoutDiffSnapshotPayload = Omit<
|
||||
"subscriptionId"
|
||||
>;
|
||||
|
||||
type ParsedDiffFile = CheckoutDiffSnapshotPayload["files"][number];
|
||||
|
||||
export type { DiffFileMetadata };
|
||||
|
||||
export type CheckoutLazyDiffSnapshotPayload = {
|
||||
cwd: string;
|
||||
files: DiffFileMetadata[];
|
||||
error: CheckoutDiffSnapshotPayload["error"];
|
||||
};
|
||||
|
||||
export type CheckoutDiffMetrics = {
|
||||
checkoutDiffTargetCount: number;
|
||||
checkoutDiffSubscriptionCount: number;
|
||||
@@ -31,13 +43,17 @@ type CheckoutDiffWatchTarget = {
|
||||
diffCwd: string;
|
||||
compare: CheckoutDiffCompareInput;
|
||||
listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>;
|
||||
lazyListeners: Set<(snapshot: CheckoutLazyDiffSnapshotPayload) => void>;
|
||||
watchers: FSWatcher[];
|
||||
fallbackRefreshInterval: NodeJS.Timeout | null;
|
||||
debounceTimer: NodeJS.Timeout | null;
|
||||
refreshPromise: Promise<void> | null;
|
||||
refreshQueued: boolean;
|
||||
latestPayload: CheckoutDiffSnapshotPayload | null;
|
||||
latestLazyPayload: CheckoutLazyDiffSnapshotPayload | null;
|
||||
latestFingerprint: string | null;
|
||||
latestFilesByPath: Map<string, ParsedDiffFile>;
|
||||
latestMetadataFingerprints: Map<string, string>;
|
||||
watchedPaths: Set<string>;
|
||||
repoWatchPath: string | null;
|
||||
linuxTreeRefreshPromise: Promise<void> | null;
|
||||
@@ -72,6 +88,7 @@ export class CheckoutDiffManager {
|
||||
diffCwd: target.diffCwd,
|
||||
}));
|
||||
target.latestPayload = initial;
|
||||
target.latestFilesByPath = new Map(initial.files.map((file) => [file.path, file]));
|
||||
target.latestFingerprint = JSON.stringify(initial);
|
||||
return {
|
||||
initial,
|
||||
@@ -81,6 +98,36 @@ export class CheckoutDiffManager {
|
||||
};
|
||||
}
|
||||
|
||||
async subscribeLazy(
|
||||
params: {
|
||||
cwd: string;
|
||||
compare: CheckoutDiffCompareInput;
|
||||
},
|
||||
listener: (snapshot: CheckoutLazyDiffSnapshotPayload) => void,
|
||||
): Promise<{ initial: CheckoutLazyDiffSnapshotPayload; unsubscribe: () => void }> {
|
||||
const cwd = params.cwd;
|
||||
const compare = this.normalizeCompare(params.compare);
|
||||
const target = await this.ensureTarget(cwd, compare);
|
||||
target.lazyListeners.add(listener);
|
||||
|
||||
const baseSnapshot =
|
||||
target.latestPayload ??
|
||||
(await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, {
|
||||
diffCwd: target.diffCwd,
|
||||
}));
|
||||
target.latestPayload = baseSnapshot;
|
||||
target.latestFilesByPath = new Map(baseSnapshot.files.map((file) => [file.path, file]));
|
||||
const initial = target.latestLazyPayload ?? this.buildLazySnapshot(baseSnapshot);
|
||||
target.latestLazyPayload = initial;
|
||||
target.latestMetadataFingerprints = this.buildMetadataFingerprintMap(initial.files);
|
||||
return {
|
||||
initial,
|
||||
unsubscribe: () => {
|
||||
this.removeLazyListener(target.key, listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
scheduleRefreshForCwd(cwd: string): void {
|
||||
const resolvedCwd = expandTilde(cwd);
|
||||
for (const target of this.targets.values()) {
|
||||
@@ -98,6 +145,7 @@ export class CheckoutDiffManager {
|
||||
|
||||
for (const target of this.targets.values()) {
|
||||
checkoutDiffSubscriptionCount += target.listeners.size;
|
||||
checkoutDiffSubscriptionCount += target.lazyListeners.size;
|
||||
checkoutDiffWatcherCount += target.watchers.length;
|
||||
if (target.fallbackRefreshInterval) {
|
||||
checkoutDiffFallbackRefreshTargetCount += 1;
|
||||
@@ -154,6 +202,9 @@ export class CheckoutDiffManager {
|
||||
target.watchers = [];
|
||||
target.watchedPaths.clear();
|
||||
target.listeners.clear();
|
||||
target.lazyListeners.clear();
|
||||
target.latestFilesByPath.clear();
|
||||
target.latestMetadataFingerprints.clear();
|
||||
}
|
||||
|
||||
private removeListener(
|
||||
@@ -165,7 +216,23 @@ export class CheckoutDiffManager {
|
||||
return;
|
||||
}
|
||||
target.listeners.delete(listener);
|
||||
if (target.listeners.size > 0) {
|
||||
if (target.listeners.size > 0 || target.lazyListeners.size > 0) {
|
||||
return;
|
||||
}
|
||||
this.closeTarget(target);
|
||||
this.targets.delete(targetKey);
|
||||
}
|
||||
|
||||
private removeLazyListener(
|
||||
targetKey: string,
|
||||
listener: (snapshot: CheckoutLazyDiffSnapshotPayload) => void,
|
||||
): void {
|
||||
const target = this.targets.get(targetKey);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.lazyListeners.delete(listener);
|
||||
if (target.listeners.size > 0 || target.lazyListeners.size > 0) {
|
||||
return;
|
||||
}
|
||||
this.closeTarget(target);
|
||||
@@ -188,6 +255,15 @@ export class CheckoutDiffManager {
|
||||
}
|
||||
}
|
||||
|
||||
getFileHunks(
|
||||
cwd: string,
|
||||
compare: CheckoutDiffCompareInput,
|
||||
path: string,
|
||||
): ParsedDiffFile["hunks"] | null {
|
||||
const target = this.targets.get(this.buildTargetKey(cwd, this.normalizeCompare(compare)));
|
||||
return target?.latestFilesByPath.get(path)?.hunks ?? null;
|
||||
}
|
||||
|
||||
private scheduleTargetRefresh(target: CheckoutDiffWatchTarget): void {
|
||||
if (target.debounceTimer) {
|
||||
clearTimeout(target.debounceTimer);
|
||||
@@ -198,6 +274,81 @@ export class CheckoutDiffManager {
|
||||
}, CHECKOUT_DIFF_WATCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private buildLazySnapshot(
|
||||
snapshot: CheckoutDiffSnapshotPayload,
|
||||
): CheckoutLazyDiffSnapshotPayload {
|
||||
return {
|
||||
cwd: snapshot.cwd,
|
||||
files: this.deriveMetadata(snapshot.files),
|
||||
error: snapshot.error,
|
||||
};
|
||||
}
|
||||
|
||||
private deriveMetadata(files: ParsedDiffFile[]): DiffFileMetadata[] {
|
||||
return files.map((file) => ({
|
||||
path: file.path,
|
||||
isNew: file.isNew,
|
||||
isDeleted: file.isDeleted,
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
...(file.status ? { status: file.status } : {}),
|
||||
fingerprint: this.buildFileFingerprint(file),
|
||||
}));
|
||||
}
|
||||
|
||||
private buildFileFingerprint(file: ParsedDiffFile): string {
|
||||
const serialized = JSON.stringify({
|
||||
hunks: file.hunks.map((hunk) => ({
|
||||
oldStart: hunk.oldStart,
|
||||
oldCount: hunk.oldCount,
|
||||
newStart: hunk.newStart,
|
||||
newCount: hunk.newCount,
|
||||
lines: hunk.lines.map((line) => ({
|
||||
type: line.type,
|
||||
content: line.content,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
return createHash("sha256").update(serialized).digest("hex");
|
||||
}
|
||||
|
||||
private buildMetadataFingerprintMap(files: DiffFileMetadata[]): Map<string, string> {
|
||||
return new Map(
|
||||
files.map((file) => [
|
||||
file.path,
|
||||
createHash("sha256").update(JSON.stringify(file)).digest("hex"),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
private metadataSnapshotChanged(
|
||||
target: CheckoutDiffWatchTarget,
|
||||
snapshot: CheckoutLazyDiffSnapshotPayload,
|
||||
): boolean {
|
||||
if (!target.latestLazyPayload) {
|
||||
return true;
|
||||
}
|
||||
if (Boolean(target.latestLazyPayload.error) !== Boolean(snapshot.error)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
target.latestLazyPayload.error?.code !== snapshot.error?.code ||
|
||||
target.latestLazyPayload.error?.message !== snapshot.error?.message
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const nextFingerprints = this.buildMetadataFingerprintMap(snapshot.files);
|
||||
if (nextFingerprints.size !== target.latestMetadataFingerprints.size) {
|
||||
return true;
|
||||
}
|
||||
for (const [path, fingerprint] of nextFingerprints) {
|
||||
if (target.latestMetadataFingerprints.get(path) !== fingerprint) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async computeCheckoutDiffSnapshot(
|
||||
cwd: string,
|
||||
compare: CheckoutDiffCompareInput,
|
||||
@@ -247,6 +398,7 @@ export class CheckoutDiffManager {
|
||||
diffCwd: target.diffCwd,
|
||||
});
|
||||
target.latestPayload = snapshot;
|
||||
target.latestFilesByPath = new Map(snapshot.files.map((file) => [file.path, file]));
|
||||
const fingerprint = JSON.stringify(snapshot);
|
||||
if (fingerprint !== target.latestFingerprint) {
|
||||
target.latestFingerprint = fingerprint;
|
||||
@@ -254,6 +406,17 @@ export class CheckoutDiffManager {
|
||||
listener(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
const lazySnapshot = this.buildLazySnapshot(snapshot);
|
||||
if (this.metadataSnapshotChanged(target, lazySnapshot)) {
|
||||
target.latestLazyPayload = lazySnapshot;
|
||||
target.latestMetadataFingerprints = this.buildMetadataFingerprintMap(lazySnapshot.files);
|
||||
for (const listener of target.lazyListeners) {
|
||||
listener(lazySnapshot);
|
||||
}
|
||||
} else {
|
||||
target.latestLazyPayload = lazySnapshot;
|
||||
}
|
||||
} while (target.refreshQueued);
|
||||
})();
|
||||
|
||||
@@ -281,13 +444,17 @@ export class CheckoutDiffManager {
|
||||
diffCwd: watchRoot ?? cwd,
|
||||
compare,
|
||||
listeners: new Set(),
|
||||
lazyListeners: new Set(),
|
||||
watchers: [],
|
||||
fallbackRefreshInterval: null,
|
||||
debounceTimer: null,
|
||||
refreshPromise: null,
|
||||
refreshQueued: false,
|
||||
latestPayload: null,
|
||||
latestLazyPayload: null,
|
||||
latestFingerprint: null,
|
||||
latestFilesByPath: new Map(),
|
||||
latestMetadataFingerprints: new Map(),
|
||||
watchedPaths: new Set<string>(),
|
||||
repoWatchPath: null,
|
||||
linuxTreeRefreshPromise: null,
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
ServerInfoStatusPayloadSchema,
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
} from "../../shared/messages.js";
|
||||
import { DiffFileMetadataSchema } from "./rpc-schemas.js";
|
||||
|
||||
describe("checkout lazy RPC schemas", () => {
|
||||
test("parses checkout/subscribe_diff inbound messages", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "checkout/subscribe_diff",
|
||||
requestId: "req-1",
|
||||
subscriptionId: "sub-1",
|
||||
cwd: "/tmp/repo",
|
||||
compare: { mode: "uncommitted" },
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("checkout/subscribe_diff");
|
||||
});
|
||||
|
||||
test("parses checkout/unsubscribe_diff inbound messages", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "checkout/unsubscribe_diff",
|
||||
subscriptionId: "sub-1",
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("checkout/unsubscribe_diff");
|
||||
});
|
||||
|
||||
test("parses checkout/get_file_hunks inbound messages", () => {
|
||||
const parsed = SessionInboundMessageSchema.parse({
|
||||
type: "checkout/get_file_hunks",
|
||||
requestId: "req-2",
|
||||
cwd: "/tmp/repo",
|
||||
path: "src/index.ts",
|
||||
compare: { mode: "base", baseRef: "main", ignoreWhitespace: true },
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("checkout/get_file_hunks");
|
||||
});
|
||||
|
||||
test("parses checkout/diff_snapshot outbound messages", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "checkout/diff_snapshot",
|
||||
payload: {
|
||||
subscriptionId: "sub-1",
|
||||
requestId: "req-1",
|
||||
cwd: "/tmp/repo",
|
||||
files: [
|
||||
{
|
||||
path: "src/index.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
status: "ok",
|
||||
fingerprint: "fp-1",
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("checkout/diff_snapshot");
|
||||
});
|
||||
|
||||
test("parses checkout/file_hunks_response outbound messages", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "checkout/file_hunks_response",
|
||||
payload: {
|
||||
requestId: "req-3",
|
||||
path: "src/index.ts",
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 2,
|
||||
lines: [
|
||||
{ type: "header", content: "@@ -1,1 +1,2 @@" },
|
||||
{ type: "context", content: "const a = 1;" },
|
||||
{ type: "add", content: "const b = 2;" },
|
||||
],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("checkout/file_hunks_response");
|
||||
});
|
||||
|
||||
test("requires fingerprint on diff metadata", () => {
|
||||
const result = DiffFileMetadataSchema.safeParse({
|
||||
path: "src/index.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 3,
|
||||
deletions: 1,
|
||||
status: "ok",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts lazyDiffRpcs in server info features", () => {
|
||||
const parsed = ServerInfoStatusPayloadSchema.parse({
|
||||
status: "server_info",
|
||||
serverId: "server-1",
|
||||
hostname: null,
|
||||
version: null,
|
||||
capabilities: {},
|
||||
features: {
|
||||
providersSnapshot: true,
|
||||
lazyDiffRpcs: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.features?.lazyDiffRpcs).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CheckoutDiffCompareSchema,
|
||||
CheckoutErrorSchema,
|
||||
DiffHunkSchema,
|
||||
} from "../../shared/messages.js";
|
||||
|
||||
export const DiffFileMetadataSchema = z.object({
|
||||
path: z.string(),
|
||||
isNew: z.boolean(),
|
||||
isDeleted: z.boolean(),
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
status: z.enum(["ok", "too_large", "binary"]).optional(),
|
||||
fingerprint: z.string(),
|
||||
});
|
||||
|
||||
export type DiffFileMetadata = z.infer<typeof DiffFileMetadataSchema>;
|
||||
|
||||
export const CheckoutSubscribeDiffRequestSchema = z.object({
|
||||
type: z.literal("checkout/subscribe_diff"),
|
||||
requestId: z.string(),
|
||||
subscriptionId: z.string(),
|
||||
cwd: z.string(),
|
||||
compare: z.lazy(() => CheckoutDiffCompareSchema),
|
||||
});
|
||||
|
||||
export const CheckoutUnsubscribeDiffRequestSchema = z.object({
|
||||
type: z.literal("checkout/unsubscribe_diff"),
|
||||
subscriptionId: z.string(),
|
||||
});
|
||||
|
||||
export const CheckoutGetFileHunksRequestSchema = z.object({
|
||||
type: z.literal("checkout/get_file_hunks"),
|
||||
requestId: z.string(),
|
||||
cwd: z.string(),
|
||||
path: z.string(),
|
||||
compare: z.lazy(() => CheckoutDiffCompareSchema),
|
||||
});
|
||||
|
||||
export const CheckoutDiffSnapshotSchema = z.object({
|
||||
type: z.literal("checkout/diff_snapshot"),
|
||||
payload: z.object({
|
||||
subscriptionId: z.string(),
|
||||
requestId: z.string().optional(),
|
||||
cwd: z.string(),
|
||||
files: z.array(DiffFileMetadataSchema),
|
||||
error: z.lazy(() => CheckoutErrorSchema).nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const CheckoutFileHunksResponseSchema = z.object({
|
||||
type: z.literal("checkout/file_hunks_response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
path: z.string(),
|
||||
hunks: z.array(z.lazy(() => DiffHunkSchema)),
|
||||
error: z.lazy(() => CheckoutErrorSchema).nullable(),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
import { homedir } from "node:os";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const { getCheckoutDiffFileMock } = vi.hoisted(() => ({
|
||||
getCheckoutDiffFileMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/checkout-git.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../utils/checkout-git.js")>(
|
||||
"../utils/checkout-git.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getCheckoutDiffFile: getCheckoutDiffFileMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { Session } from "./session.js";
|
||||
|
||||
function createSessionForCheckoutDiffTests(options?: {
|
||||
checkoutDiffManager?: {
|
||||
subscribe: ReturnType<typeof vi.fn>;
|
||||
subscribeLazy: ReturnType<typeof vi.fn>;
|
||||
getFileHunks: ReturnType<typeof vi.fn>;
|
||||
scheduleRefreshForCwd: ReturnType<typeof vi.fn>;
|
||||
getMetrics: ReturnType<typeof vi.fn>;
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}) {
|
||||
const emitted: any[] = [];
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const checkoutDiffManager =
|
||||
options?.checkoutDiffManager ??
|
||||
({
|
||||
subscribe: vi.fn(async () => ({
|
||||
initial: { cwd: "/tmp/repo", files: [], error: null },
|
||||
unsubscribe: vi.fn(),
|
||||
})),
|
||||
subscribeLazy: vi.fn(async () => ({
|
||||
initial: { cwd: "/tmp/repo", files: [], error: null },
|
||||
unsubscribe: vi.fn(),
|
||||
})),
|
||||
getFileHunks: vi.fn(() => null),
|
||||
scheduleRefreshForCwd: vi.fn(),
|
||||
getMetrics: vi.fn(() => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
})),
|
||||
dispose: vi.fn(),
|
||||
} as const);
|
||||
|
||||
const session = new Session({
|
||||
clientId: "test-client",
|
||||
appVersion: null,
|
||||
onMessage: vi.fn(),
|
||||
logger: logger as any,
|
||||
downloadTokenStore: {} as any,
|
||||
pushTokenStore: {} as any,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
agentManager: {
|
||||
subscribe: () => () => {},
|
||||
listAgents: () => [],
|
||||
getAgent: () => null,
|
||||
archiveAgent: async () => ({ archivedAt: new Date().toISOString() }),
|
||||
clearAgentAttention: async () => {},
|
||||
notifyAgentState: () => {},
|
||||
} as any,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
} as any,
|
||||
projectRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
workspaceRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
checkoutDiffManager: checkoutDiffManager as any,
|
||||
workspaceGitService: {
|
||||
subscribe: async () => ({
|
||||
initial: {
|
||||
cwd: "/tmp/repo",
|
||||
git: {
|
||||
isGit: false,
|
||||
repoRoot: null,
|
||||
mainRepoRoot: null,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
isDirty: null,
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
diffStat: null,
|
||||
},
|
||||
github: {
|
||||
featuresEnabled: false,
|
||||
pullRequest: null,
|
||||
error: null,
|
||||
refreshedAt: null,
|
||||
},
|
||||
},
|
||||
unsubscribe: vi.fn(),
|
||||
}),
|
||||
peekSnapshot: () => null,
|
||||
getSnapshot: async () => null,
|
||||
refresh: async () => {},
|
||||
dispose: () => {},
|
||||
} as any,
|
||||
mcpBaseUrl: null,
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: null,
|
||||
}) as any;
|
||||
|
||||
session.emit = (message: any) => emitted.push(message);
|
||||
|
||||
return { session, emitted, checkoutDiffManager };
|
||||
}
|
||||
|
||||
describe("Session checkout lazy diff handlers", () => {
|
||||
beforeEach(() => {
|
||||
getCheckoutDiffFileMock.mockReset();
|
||||
});
|
||||
|
||||
test("checkout/subscribe_diff emits initial metadata snapshot and replaces existing subscription", async () => {
|
||||
const firstUnsubscribe = vi.fn();
|
||||
const secondUnsubscribe = vi.fn();
|
||||
const subscribeLazy = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
initial: {
|
||||
cwd: "/tmp/repo",
|
||||
files: [
|
||||
{
|
||||
path: "src/example.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
fingerprint: "fp-1",
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
unsubscribe: firstUnsubscribe,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
initial: { cwd: "/tmp/repo", files: [], error: null },
|
||||
unsubscribe: secondUnsubscribe,
|
||||
});
|
||||
|
||||
const { session, emitted } = createSessionForCheckoutDiffTests({
|
||||
checkoutDiffManager: {
|
||||
subscribe: vi.fn(),
|
||||
subscribeLazy,
|
||||
getFileHunks: vi.fn(),
|
||||
scheduleRefreshForCwd: vi.fn(),
|
||||
getMetrics: vi.fn(() => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
})),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "checkout/subscribe_diff",
|
||||
requestId: "req-1",
|
||||
subscriptionId: "sub-1",
|
||||
cwd: "~/repo",
|
||||
compare: { mode: "uncommitted" },
|
||||
});
|
||||
await session.handleMessage({
|
||||
type: "checkout/subscribe_diff",
|
||||
requestId: "req-2",
|
||||
subscriptionId: "sub-1",
|
||||
cwd: "~/repo",
|
||||
compare: { mode: "uncommitted" },
|
||||
});
|
||||
|
||||
expect(subscribeLazy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ cwd: `${homedir()}/repo`, compare: { mode: "uncommitted" } },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(firstUnsubscribe).toHaveBeenCalledTimes(1);
|
||||
expect(emitted[0]).toMatchObject({
|
||||
type: "checkout/diff_snapshot",
|
||||
payload: {
|
||||
subscriptionId: "sub-1",
|
||||
requestId: "req-1",
|
||||
cwd: "/tmp/repo",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("checkout/get_file_hunks serves cached hunks before falling back to git", async () => {
|
||||
const cachedHunks = [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 2,
|
||||
lines: [{ type: "header", content: "@@ -1,1 +1,2 @@" }],
|
||||
},
|
||||
];
|
||||
const getFileHunks = vi.fn(() => cachedHunks);
|
||||
const { session, emitted } = createSessionForCheckoutDiffTests({
|
||||
checkoutDiffManager: {
|
||||
subscribe: vi.fn(),
|
||||
subscribeLazy: vi.fn(),
|
||||
getFileHunks,
|
||||
scheduleRefreshForCwd: vi.fn(),
|
||||
getMetrics: vi.fn(() => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
})),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "checkout/get_file_hunks",
|
||||
requestId: "req-hunks",
|
||||
cwd: "/tmp/repo",
|
||||
path: "src/example.ts",
|
||||
compare: { mode: "uncommitted" },
|
||||
});
|
||||
|
||||
expect(getFileHunks).toHaveBeenCalledWith(
|
||||
"/tmp/repo",
|
||||
{ mode: "uncommitted" },
|
||||
"src/example.ts",
|
||||
);
|
||||
expect(getCheckoutDiffFileMock).not.toHaveBeenCalled();
|
||||
expect(emitted[0]).toMatchObject({
|
||||
type: "checkout/file_hunks_response",
|
||||
payload: {
|
||||
requestId: "req-hunks",
|
||||
path: "src/example.ts",
|
||||
hunks: cachedHunks,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("checkout/get_file_hunks falls back to single-file diff and unsubscribe cleans up", async () => {
|
||||
const unsubscribe = vi.fn();
|
||||
getCheckoutDiffFileMock.mockResolvedValueOnce({
|
||||
path: "src/example.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 2,
|
||||
lines: [{ type: "header", content: "@@ -1,1 +1,2 @@" }],
|
||||
},
|
||||
],
|
||||
status: "ok",
|
||||
});
|
||||
|
||||
const { session, emitted } = createSessionForCheckoutDiffTests({
|
||||
checkoutDiffManager: {
|
||||
subscribe: vi.fn(),
|
||||
subscribeLazy: vi.fn(async () => ({
|
||||
initial: { cwd: "/tmp/repo", files: [], error: null },
|
||||
unsubscribe,
|
||||
})),
|
||||
getFileHunks: vi.fn(() => null),
|
||||
scheduleRefreshForCwd: vi.fn(),
|
||||
getMetrics: vi.fn(() => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
})),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "checkout/subscribe_diff",
|
||||
requestId: "req-sub",
|
||||
subscriptionId: "sub-1",
|
||||
cwd: "/tmp/repo",
|
||||
compare: { mode: "base", baseRef: "main" },
|
||||
});
|
||||
await session.handleMessage({
|
||||
type: "checkout/get_file_hunks",
|
||||
requestId: "req-hunks",
|
||||
cwd: "/tmp/repo",
|
||||
path: "src/example.ts",
|
||||
compare: { mode: "base", baseRef: "main" },
|
||||
});
|
||||
await session.handleMessage({
|
||||
type: "checkout/unsubscribe_diff",
|
||||
subscriptionId: "sub-1",
|
||||
});
|
||||
|
||||
expect(getCheckoutDiffFileMock).toHaveBeenCalledWith(
|
||||
"/tmp/repo",
|
||||
{
|
||||
mode: "base",
|
||||
baseRef: "main",
|
||||
ignoreWhitespace: undefined,
|
||||
includeStructured: true,
|
||||
},
|
||||
"src/example.ts",
|
||||
{ paseoHome: "/tmp/paseo-test" },
|
||||
);
|
||||
expect(emitted[1]).toMatchObject({
|
||||
type: "checkout/file_hunks_response",
|
||||
payload: {
|
||||
requestId: "req-hunks",
|
||||
path: "src/example.ts",
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,9 @@ import {
|
||||
type CaptureTerminalRequest,
|
||||
type SubscribeCheckoutDiffRequest,
|
||||
type UnsubscribeCheckoutDiffRequest,
|
||||
type CheckoutSubscribeDiffRequest,
|
||||
type CheckoutUnsubscribeDiffRequest,
|
||||
type CheckoutGetFileHunksRequest,
|
||||
type DirectorySuggestionsRequest,
|
||||
type EditorTargetDescriptorPayload,
|
||||
type EditorTargetId,
|
||||
@@ -149,6 +152,7 @@ import { type WorktreeConfig } from "../utils/worktree.js";
|
||||
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
|
||||
import {
|
||||
getCheckoutDiff,
|
||||
getCheckoutDiffFile,
|
||||
getCheckoutStatus,
|
||||
listBranchSuggestions,
|
||||
commitChanges,
|
||||
@@ -622,6 +626,7 @@ export class Session {
|
||||
private inflightRequests = 0;
|
||||
private peakInflightRequests = 0;
|
||||
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
|
||||
private readonly checkoutDiffLazySubscriptions = new Map<string, () => void>();
|
||||
private readonly workspaceGitSubscriptions = new Map<string, () => void>();
|
||||
private readonly registerVoiceSpeakHandler?: (
|
||||
agentId: string,
|
||||
@@ -1783,6 +1788,18 @@ export class Session {
|
||||
this.handleUnsubscribeCheckoutDiffRequest(msg);
|
||||
break;
|
||||
|
||||
case "checkout/subscribe_diff":
|
||||
await this.handleCheckoutSubscribeDiffRequest(msg);
|
||||
break;
|
||||
|
||||
case "checkout/get_file_hunks":
|
||||
await this.handleCheckoutGetFileHunksRequest(msg);
|
||||
break;
|
||||
|
||||
case "checkout/unsubscribe_diff":
|
||||
this.handleCheckoutUnsubscribeDiffRequest(msg);
|
||||
break;
|
||||
|
||||
case "checkout_switch_branch_request":
|
||||
await this.handleCheckoutSwitchBranchRequest(msg);
|
||||
break;
|
||||
@@ -4277,6 +4294,89 @@ export class Session {
|
||||
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
|
||||
}
|
||||
|
||||
private async handleCheckoutSubscribeDiffRequest(
|
||||
msg: CheckoutSubscribeDiffRequest,
|
||||
): Promise<void> {
|
||||
const cwd = expandTilde(msg.cwd);
|
||||
this.checkoutDiffLazySubscriptions.get(msg.subscriptionId)?.();
|
||||
this.checkoutDiffLazySubscriptions.delete(msg.subscriptionId);
|
||||
|
||||
const subscription = await this.checkoutDiffManager.subscribeLazy(
|
||||
{ cwd, compare: msg.compare },
|
||||
(snapshot) => {
|
||||
this.emit({
|
||||
type: "checkout/diff_snapshot",
|
||||
payload: {
|
||||
subscriptionId: msg.subscriptionId,
|
||||
...snapshot,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
this.checkoutDiffLazySubscriptions.set(msg.subscriptionId, subscription.unsubscribe);
|
||||
|
||||
this.emit({
|
||||
type: "checkout/diff_snapshot",
|
||||
payload: {
|
||||
subscriptionId: msg.subscriptionId,
|
||||
...subscription.initial,
|
||||
requestId: msg.requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async handleCheckoutGetFileHunksRequest(msg: CheckoutGetFileHunksRequest): Promise<void> {
|
||||
const cwd = expandTilde(msg.cwd);
|
||||
let hunks = this.checkoutDiffManager.getFileHunks(cwd, msg.compare, msg.path);
|
||||
let error: {
|
||||
code: "UNKNOWN" | "NOT_ALLOWED" | "NOT_GIT_REPO" | "MERGE_CONFLICT";
|
||||
message: string;
|
||||
} | null = null;
|
||||
|
||||
if (hunks === null) {
|
||||
try {
|
||||
const file = await getCheckoutDiffFile(
|
||||
cwd,
|
||||
{
|
||||
mode: msg.compare.mode,
|
||||
baseRef: msg.compare.baseRef,
|
||||
ignoreWhitespace: msg.compare.ignoreWhitespace,
|
||||
includeStructured: true,
|
||||
},
|
||||
msg.path,
|
||||
{ paseoHome: this.paseoHome },
|
||||
);
|
||||
if (file) {
|
||||
hunks = file.hunks;
|
||||
} else {
|
||||
hunks = [];
|
||||
error = {
|
||||
code: "UNKNOWN",
|
||||
message: "File no longer present in diff",
|
||||
};
|
||||
}
|
||||
} catch (caughtError) {
|
||||
hunks = [];
|
||||
error = toCheckoutError(caughtError);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: "checkout/file_hunks_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
path: msg.path,
|
||||
hunks,
|
||||
error,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private handleCheckoutUnsubscribeDiffRequest(msg: CheckoutUnsubscribeDiffRequest): void {
|
||||
this.checkoutDiffLazySubscriptions.get(msg.subscriptionId)?.();
|
||||
this.checkoutDiffLazySubscriptions.delete(msg.subscriptionId);
|
||||
}
|
||||
|
||||
private async handleCheckoutSwitchBranchRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
|
||||
): Promise<void> {
|
||||
@@ -7387,6 +7487,11 @@ export class Session {
|
||||
}
|
||||
this.checkoutDiffSubscriptions.clear();
|
||||
|
||||
for (const unsubscribe of this.checkoutDiffLazySubscriptions.values()) {
|
||||
unsubscribe();
|
||||
}
|
||||
this.checkoutDiffLazySubscriptions.clear();
|
||||
|
||||
for (const unsubscribe of this.workspaceGitSubscriptions.values()) {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
@@ -800,6 +800,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
features: {
|
||||
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
|
||||
providersSnapshot: true,
|
||||
lazyDiffRpcs: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,6 +47,13 @@ import {
|
||||
LoopLogsResponseSchema,
|
||||
LoopStopResponseSchema,
|
||||
} from "../server/loop/rpc-schemas.js";
|
||||
import {
|
||||
CheckoutDiffSnapshotSchema,
|
||||
CheckoutFileHunksResponseSchema,
|
||||
CheckoutGetFileHunksRequestSchema,
|
||||
CheckoutSubscribeDiffRequestSchema,
|
||||
CheckoutUnsubscribeDiffRequestSchema,
|
||||
} from "../server/checkout/rpc-schemas.js";
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutable daemon config schemas (shared between server store and client)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1013,12 +1020,12 @@ const CheckoutErrorCodeSchema = z.enum([
|
||||
"UNKNOWN",
|
||||
]);
|
||||
|
||||
const CheckoutErrorSchema = z.object({
|
||||
export const CheckoutErrorSchema = z.object({
|
||||
code: CheckoutErrorCodeSchema,
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
const CheckoutDiffCompareSchema = z.object({
|
||||
export const CheckoutDiffCompareSchema = z.object({
|
||||
mode: z.enum(["uncommitted", "base"]),
|
||||
baseRef: z.string().optional(),
|
||||
ignoreWhitespace: z.boolean().optional(),
|
||||
@@ -1231,18 +1238,18 @@ export const ArchiveWorkspaceRequestSchema = z.object({
|
||||
|
||||
// Highlighted diff token schema
|
||||
// Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter
|
||||
const HighlightTokenSchema = z.object({
|
||||
export const HighlightTokenSchema = z.object({
|
||||
text: z.string(),
|
||||
style: z.string().nullable(),
|
||||
});
|
||||
|
||||
const DiffLineSchema = z.object({
|
||||
export const DiffLineSchema = z.object({
|
||||
type: z.enum(["add", "remove", "context", "header"]),
|
||||
content: z.string(),
|
||||
tokens: z.array(HighlightTokenSchema).optional(),
|
||||
});
|
||||
|
||||
const DiffHunkSchema = z.object({
|
||||
export const DiffHunkSchema = z.object({
|
||||
oldStart: z.number(),
|
||||
oldCount: z.number(),
|
||||
newStart: z.number(),
|
||||
@@ -1250,7 +1257,7 @@ const DiffHunkSchema = z.object({
|
||||
lines: z.array(DiffLineSchema),
|
||||
});
|
||||
|
||||
const ParsedDiffFileSchema = z.object({
|
||||
export const ParsedDiffFileSchema = z.object({
|
||||
path: z.string(),
|
||||
isNew: z.boolean(),
|
||||
isDeleted: z.boolean(),
|
||||
@@ -1464,6 +1471,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CheckoutStatusRequestSchema,
|
||||
SubscribeCheckoutDiffRequestSchema,
|
||||
UnsubscribeCheckoutDiffRequestSchema,
|
||||
CheckoutSubscribeDiffRequestSchema,
|
||||
CheckoutUnsubscribeDiffRequestSchema,
|
||||
CheckoutGetFileHunksRequestSchema,
|
||||
CheckoutCommitRequestSchema,
|
||||
CheckoutMergeRequestSchema,
|
||||
CheckoutMergeFromBaseRequestSchema,
|
||||
@@ -1686,6 +1696,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
features: z
|
||||
.object({
|
||||
providersSnapshot: z.boolean().optional(),
|
||||
lazyDiffRpcs: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -2789,6 +2800,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CheckoutStatusResponseSchema,
|
||||
SubscribeCheckoutDiffResponseSchema,
|
||||
CheckoutDiffUpdateSchema,
|
||||
CheckoutDiffSnapshotSchema,
|
||||
CheckoutFileHunksResponseSchema,
|
||||
CheckoutCommitResponseSchema,
|
||||
CheckoutMergeResponseSchema,
|
||||
CheckoutMergeFromBaseResponseSchema,
|
||||
@@ -2999,8 +3012,13 @@ export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
|
||||
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
|
||||
export type SubscribeCheckoutDiffRequest = z.infer<typeof SubscribeCheckoutDiffRequestSchema>;
|
||||
export type UnsubscribeCheckoutDiffRequest = z.infer<typeof UnsubscribeCheckoutDiffRequestSchema>;
|
||||
export type CheckoutSubscribeDiffRequest = z.infer<typeof CheckoutSubscribeDiffRequestSchema>;
|
||||
export type CheckoutUnsubscribeDiffRequest = z.infer<typeof CheckoutUnsubscribeDiffRequestSchema>;
|
||||
export type CheckoutGetFileHunksRequest = z.infer<typeof CheckoutGetFileHunksRequestSchema>;
|
||||
export type SubscribeCheckoutDiffResponse = z.infer<typeof SubscribeCheckoutDiffResponseSchema>;
|
||||
export type CheckoutDiffUpdate = z.infer<typeof CheckoutDiffUpdateSchema>;
|
||||
export type CheckoutDiffSnapshot = z.infer<typeof CheckoutDiffSnapshotSchema>;
|
||||
export type CheckoutFileHunksResponse = z.infer<typeof CheckoutFileHunksResponseSchema>;
|
||||
export type CheckoutCommitRequest = z.infer<typeof CheckoutCommitRequestSchema>;
|
||||
export type CheckoutCommitResponse = z.infer<typeof CheckoutCommitResponseSchema>;
|
||||
export type CheckoutMergeRequest = z.infer<typeof CheckoutMergeRequestSchema>;
|
||||
|
||||
@@ -1277,9 +1277,10 @@ export async function getCheckoutShortstat(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCheckoutDiff(
|
||||
async function getCheckoutDiffInternal(
|
||||
cwd: string,
|
||||
compare: CheckoutDiffCompare,
|
||||
options?: { path?: string },
|
||||
context?: CheckoutContext,
|
||||
): Promise<CheckoutDiffResult> {
|
||||
await requireGitRepo(cwd);
|
||||
@@ -1305,7 +1306,12 @@ export async function getCheckoutDiff(
|
||||
|
||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||
const changes = await listCheckoutFileChanges(cwd, refForDiff, ignoreWhitespace);
|
||||
changes.sort((a, b) => {
|
||||
const targetPath = options?.path;
|
||||
const filteredChanges =
|
||||
typeof targetPath === "string"
|
||||
? changes.filter((change) => change.path === targetPath)
|
||||
: changes;
|
||||
filteredChanges.sort((a, b) => {
|
||||
if (a.path === b.path) return 0;
|
||||
return a.path < b.path ? -1 : 1;
|
||||
});
|
||||
@@ -1329,8 +1335,8 @@ export async function getCheckoutDiff(
|
||||
}
|
||||
};
|
||||
|
||||
const trackedChanges = changes.filter((change) => !change.isUntracked);
|
||||
const untrackedChanges = changes.filter((change) => change.isUntracked === true);
|
||||
const trackedChanges = filteredChanges.filter((change) => !change.isUntracked);
|
||||
const untrackedChanges = filteredChanges.filter((change) => change.isUntracked === true);
|
||||
const trackedChangeByPath = new Map(trackedChanges.map((change) => [change.path, change]));
|
||||
|
||||
const trackedNumstatByPath =
|
||||
@@ -1514,6 +1520,24 @@ export async function getCheckoutDiff(
|
||||
return { diff: diffText };
|
||||
}
|
||||
|
||||
export async function getCheckoutDiff(
|
||||
cwd: string,
|
||||
compare: CheckoutDiffCompare,
|
||||
context?: CheckoutContext,
|
||||
): Promise<CheckoutDiffResult> {
|
||||
return getCheckoutDiffInternal(cwd, compare, undefined, context);
|
||||
}
|
||||
|
||||
export async function getCheckoutDiffFile(
|
||||
cwd: string,
|
||||
compare: CheckoutDiffCompare,
|
||||
path: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<ParsedDiffFile | null> {
|
||||
const result = await getCheckoutDiffInternal(cwd, compare, { path }, context);
|
||||
return result.structured?.[0] ?? null;
|
||||
}
|
||||
|
||||
export async function commitChanges(
|
||||
cwd: string,
|
||||
options: { message: string; addAll?: boolean },
|
||||
|
||||
Reference in New Issue
Block a user