Push-driven checkout diff subscriptions for changes sidebar (#24)
* Implement push-driven checkout diff subscriptions * Remove legacy checkout diff polling RPC * Fix checkout diff watcher coverage and fallback refresh
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compareCheckoutDiffPaths,
|
||||
orderCheckoutDiffFiles,
|
||||
} from "./checkout-diff-order";
|
||||
|
||||
function createFile(path: string, additions = 0) {
|
||||
return {
|
||||
path,
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions,
|
||||
deletions: 0,
|
||||
hunks: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("checkout diff ordering", () => {
|
||||
it("compares paths deterministically", () => {
|
||||
expect(compareCheckoutDiffPaths("a.ts", "b.ts")).toBeLessThan(0);
|
||||
expect(compareCheckoutDiffPaths("b.ts", "a.ts")).toBeGreaterThan(0);
|
||||
expect(compareCheckoutDiffPaths("same.ts", "same.ts")).toBe(0);
|
||||
});
|
||||
|
||||
it("sorts files by path", () => {
|
||||
const ordered = orderCheckoutDiffFiles([
|
||||
createFile("zeta.ts"),
|
||||
createFile("alpha.ts"),
|
||||
createFile("beta.ts"),
|
||||
]);
|
||||
|
||||
expect(ordered.map((file) => file.path)).toEqual([
|
||||
"alpha.ts",
|
||||
"beta.ts",
|
||||
"zeta.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves relative order for equal paths", () => {
|
||||
const ordered = orderCheckoutDiffFiles([
|
||||
createFile("same.ts", 1),
|
||||
createFile("same.ts", 2),
|
||||
createFile("same.ts", 3),
|
||||
]);
|
||||
|
||||
expect(ordered.map((file) => file.additions)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
||||
|
||||
type ParsedDiffFile = SubscribeCheckoutDiffResponse["payload"]["files"][number];
|
||||
|
||||
export function compareCheckoutDiffPaths(left: string, right: string): number {
|
||||
if (left === right) {
|
||||
return 0;
|
||||
}
|
||||
return left < right ? -1 : 1;
|
||||
}
|
||||
|
||||
export function orderCheckoutDiffFiles(
|
||||
files: ParsedDiffFile[]
|
||||
): ParsedDiffFile[] {
|
||||
if (files.length < 2) {
|
||||
return files;
|
||||
}
|
||||
const ordered = [...files];
|
||||
ordered.sort((a, b) => compareCheckoutDiffPaths(a.path, b.path));
|
||||
return ordered;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useId, useMemo } from "react";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import type { CheckoutDiffResponse } from "@server/shared/messages";
|
||||
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
||||
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
||||
|
||||
const CHECKOUT_DIFF_STALE_TIME = 30_000;
|
||||
|
||||
@@ -24,11 +25,29 @@ interface UseCheckoutDiffQueryOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export type ParsedDiffFile = CheckoutDiffResponse["payload"]["files"][number];
|
||||
type CheckoutDiffQueryPayload = Omit<
|
||||
SubscribeCheckoutDiffResponse["payload"],
|
||||
"subscriptionId"
|
||||
>;
|
||||
|
||||
export type ParsedDiffFile = CheckoutDiffQueryPayload["files"][number];
|
||||
export type DiffHunk = ParsedDiffFile["hunks"][number];
|
||||
export type DiffLine = DiffHunk["lines"][number];
|
||||
export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
|
||||
|
||||
function normalizeCheckoutDiffCompare(compare: {
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string } {
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { mode: "uncommitted" };
|
||||
}
|
||||
const trimmedBaseRef = compare.baseRef?.trim();
|
||||
return trimmedBaseRef
|
||||
? { mode: "base", baseRef: trimmedBaseRef }
|
||||
: { mode: "base" };
|
||||
}
|
||||
|
||||
export function useCheckoutDiffQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
@@ -49,29 +68,142 @@ export function useCheckoutDiffQuery({
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const hookInstanceId = useId();
|
||||
const normalizedCompare = useMemo(
|
||||
() => normalizeCheckoutDiffCompare({ mode, baseRef }),
|
||||
[mode, baseRef]
|
||||
);
|
||||
const compareMode = normalizedCompare.mode;
|
||||
const compareBaseRef = normalizedCompare.baseRef;
|
||||
const queryKey = useMemo(
|
||||
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef),
|
||||
[serverId, cwd, mode, baseRef]
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: checkoutDiffQueryKey(serverId, cwd, mode, baseRef),
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await client.getCheckoutDiff(cwd, { mode, baseRef });
|
||||
const payload = await client.getCheckoutDiff(cwd, {
|
||||
mode: compareMode,
|
||||
baseRef: compareBaseRef,
|
||||
});
|
||||
return {
|
||||
...payload,
|
||||
files: orderCheckoutDiffFiles(payload.files),
|
||||
};
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd && enabled,
|
||||
staleTime: CHECKOUT_DIFF_STALE_TIME,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
// Revalidate when sidebar opens with "changes" tab active
|
||||
useEffect(() => {
|
||||
if (!isOpen || explorerTab !== "changes" || !cwd) {
|
||||
if (!client || !isConnected || !cwd || !enabled) {
|
||||
return;
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: checkoutDiffQueryKey(serverId, cwd, mode, baseRef),
|
||||
if (!isOpen || explorerTab !== "changes") {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionId = [
|
||||
"checkoutDiff",
|
||||
hookInstanceId,
|
||||
serverId,
|
||||
cwd,
|
||||
compareMode,
|
||||
compareBaseRef ?? "",
|
||||
].join(":");
|
||||
let cancelled = false;
|
||||
|
||||
const unsubscribeUpdate = client.on("checkout_diff_update", (message) => {
|
||||
if (message.type !== "checkout_diff_update") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.subscriptionId !== subscriptionId) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData<CheckoutDiffQueryPayload>(queryKey, {
|
||||
cwd: message.payload.cwd,
|
||||
files: orderCheckoutDiffFiles(message.payload.files),
|
||||
error: message.payload.error,
|
||||
requestId: `subscription:${subscriptionId}`,
|
||||
});
|
||||
});
|
||||
}, [isOpen, explorerTab, serverId, cwd, mode, baseRef, queryClient]);
|
||||
const unsubscribeSubscribeResponse = client.on(
|
||||
"subscribe_checkout_diff_response",
|
||||
(message) => {
|
||||
if (message.type !== "subscribe_checkout_diff_response") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.subscriptionId !== subscriptionId) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData<CheckoutDiffQueryPayload>(queryKey, {
|
||||
cwd: message.payload.cwd,
|
||||
files: orderCheckoutDiffFiles(message.payload.files),
|
||||
error: message.payload.error,
|
||||
requestId: message.payload.requestId,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
void client
|
||||
.subscribeCheckoutDiff(
|
||||
cwd,
|
||||
{
|
||||
mode: compareMode,
|
||||
baseRef: compareBaseRef,
|
||||
},
|
||||
{ subscriptionId }
|
||||
)
|
||||
.then((payload) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData<CheckoutDiffQueryPayload>(queryKey, {
|
||||
cwd: payload.cwd,
|
||||
files: orderCheckoutDiffFiles(payload.files),
|
||||
error: payload.error,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
console.error("[useCheckoutDiffQuery] subscribeCheckoutDiff failed", {
|
||||
serverId,
|
||||
cwd,
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribeUpdate();
|
||||
unsubscribeSubscribeResponse();
|
||||
try {
|
||||
client.unsubscribeCheckoutDiff(subscriptionId);
|
||||
} catch {
|
||||
// Ignore disconnect race during effect cleanup.
|
||||
}
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
isConnected,
|
||||
cwd,
|
||||
enabled,
|
||||
isOpen,
|
||||
explorerTab,
|
||||
hookInstanceId,
|
||||
serverId,
|
||||
compareMode,
|
||||
compareBaseRef,
|
||||
queryKey,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
return query.refetch();
|
||||
|
||||
Reference in New Issue
Block a user