fix(web): preserve pending git action ownership (#3373)
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -244,6 +244,31 @@ describe("FileRow hover swap (stats <-> actions occupy same cell)", () => {
|
||||
expect(statsLayer.className.split(/\s+/)).toContain("opacity-0");
|
||||
expect(actionsLayer.className.split(/\s+/)).toContain("opacity-100");
|
||||
});
|
||||
|
||||
it("keeps the pending spinner inside a touch-sized target for coarse pointers", () => {
|
||||
responsive.isFinePointer = false;
|
||||
const { container } = render(
|
||||
<TooltipProvider>
|
||||
<ul>
|
||||
<FileRow
|
||||
file={{ ...baseFile, path: "pending-mobile.go" }}
|
||||
isPending
|
||||
onSelect={noopSelect}
|
||||
onOpenDiff={noop}
|
||||
onStage={noop}
|
||||
onUnstage={noop}
|
||||
onDiscard={noop}
|
||||
onEditFile={noop}
|
||||
/>
|
||||
</ul>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const spinner = container.querySelector("svg.animate-spin");
|
||||
expect(spinner).not.toBeNull();
|
||||
expect(spinner?.parentElement?.className).toContain("min-h-11");
|
||||
expect(spinner?.parentElement?.className).toContain("min-w-11");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileRow tree-mode hover stage action", () => {
|
||||
|
||||
@@ -99,26 +99,56 @@ type WorktreeFileAction = "worktree.stage" | "worktree.unstage";
|
||||
type PausedWorktreeFileAction = {
|
||||
action: WorktreeFileAction;
|
||||
frame: string;
|
||||
requestId: string;
|
||||
server: WebSocketRoute;
|
||||
};
|
||||
|
||||
function isWorktreeFileActionFrame(part: string, action: WorktreeFileAction): boolean {
|
||||
if (!part.trim()) return false;
|
||||
type AwaitedWorktreeFileState = {
|
||||
action: WorktreeFileAction;
|
||||
requestId: string;
|
||||
path: string;
|
||||
staged: boolean;
|
||||
response: { success: boolean; error?: string } | null;
|
||||
fileStateObserved: boolean;
|
||||
};
|
||||
|
||||
type WorktreeFrame = {
|
||||
id?: unknown;
|
||||
type?: unknown;
|
||||
action?: unknown;
|
||||
payload?: {
|
||||
success?: unknown;
|
||||
error?: unknown;
|
||||
type?: unknown;
|
||||
status?: { files?: Record<string, { staged?: unknown }> };
|
||||
};
|
||||
};
|
||||
|
||||
function parseWorktreeFrame(part: string): WorktreeFrame | null {
|
||||
if (!part.trim()) return null;
|
||||
try {
|
||||
const frame = JSON.parse(part) as { type?: unknown; action?: unknown };
|
||||
return frame.type === "request" && frame.action === action;
|
||||
return JSON.parse(part) as WorktreeFrame;
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function worktreeFileActionRequestId(part: string, action: WorktreeFileAction): string | null {
|
||||
const frame = parseWorktreeFrame(part);
|
||||
return frame?.type === "request" && frame.action === action && typeof frame.id === "string"
|
||||
? frame.id
|
||||
: null;
|
||||
}
|
||||
|
||||
function partitionWorktreeFileAction(
|
||||
message: string | Buffer,
|
||||
action: WorktreeFileAction,
|
||||
): { frame: string; passthrough: string | null } | null {
|
||||
if (typeof message !== "string") return null;
|
||||
const frames = message.split("\n");
|
||||
const actionIndex = frames.findIndex((part) => isWorktreeFileActionFrame(part, action));
|
||||
const actionIndex = frames.findIndex(
|
||||
(part) => worktreeFileActionRequestId(part, action) !== null,
|
||||
);
|
||||
if (actionIndex === -1) return null;
|
||||
const passthrough = frames.filter((_, index) => index !== actionIndex).join("\n");
|
||||
return {
|
||||
@@ -127,23 +157,63 @@ function partitionWorktreeFileAction(
|
||||
};
|
||||
}
|
||||
|
||||
function observeWorktreeResponse(frame: WorktreeFrame, awaited: AwaitedWorktreeFileState) {
|
||||
if (
|
||||
frame.type !== "response" ||
|
||||
frame.action !== awaited.action ||
|
||||
frame.id !== awaited.requestId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
awaited.response = {
|
||||
success: frame.payload?.success === true,
|
||||
...(typeof frame.payload?.error === "string" ? { error: frame.payload.error } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function observeWorktreeFileState(frame: WorktreeFrame, awaited: AwaitedWorktreeFileState) {
|
||||
if (
|
||||
frame.action === "session.git.event" &&
|
||||
frame.payload?.type === "status_update" &&
|
||||
frame.payload.status?.files?.[awaited.path]?.staged === awaited.staged
|
||||
) {
|
||||
awaited.fileStateObserved = true;
|
||||
}
|
||||
}
|
||||
|
||||
function observeWorktreeFileAction(message: string | Buffer, awaited: AwaitedWorktreeFileState) {
|
||||
const parts = (typeof message === "string" ? message : message.toString("utf8")).split("\n");
|
||||
for (const part of parts) {
|
||||
const frame = parseWorktreeFrame(part);
|
||||
if (!frame) continue;
|
||||
observeWorktreeResponse(frame, awaited);
|
||||
observeWorktreeFileState(frame, awaited);
|
||||
}
|
||||
}
|
||||
|
||||
async function pauseNextWorktreeFileAction(page: Page) {
|
||||
let armedAction: WorktreeFileAction | null = null;
|
||||
let paused: PausedWorktreeFileAction | null = null;
|
||||
let awaitedState: AwaitedWorktreeFileState | null = null;
|
||||
|
||||
await page.routeWebSocket(/\/ws$/, (socket) => {
|
||||
await page.context().routeWebSocket(/\/ws$/, (socket) => {
|
||||
const server = socket.connectToServer();
|
||||
socket.onMessage((message) => {
|
||||
const partition = armedAction ? partitionWorktreeFileAction(message, armedAction) : null;
|
||||
if (partition && armedAction) {
|
||||
paused = { action: armedAction, frame: partition.frame, server };
|
||||
const requestId = worktreeFileActionRequestId(partition.frame, armedAction);
|
||||
if (!requestId) throw new Error(`Paused ${armedAction} request has no ID`);
|
||||
paused = { action: armedAction, frame: partition.frame, requestId, server };
|
||||
armedAction = null;
|
||||
if (partition.passthrough) server.send(partition.passthrough);
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => socket.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (awaitedState) observeWorktreeFileAction(message, awaitedState);
|
||||
socket.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -158,10 +228,33 @@ async function pauseNextWorktreeFileAction(page: Page) {
|
||||
})
|
||||
.toBe(action);
|
||||
},
|
||||
release(action: WorktreeFileAction) {
|
||||
async releaseAndWaitForFileState(action: WorktreeFileAction, path: string, staged: boolean) {
|
||||
if (paused?.action !== action) throw new Error(`No paused ${action} request`);
|
||||
paused.server.send(paused.frame);
|
||||
const request = paused;
|
||||
awaitedState = {
|
||||
action,
|
||||
requestId: request.requestId,
|
||||
path,
|
||||
staged,
|
||||
response: null,
|
||||
fileStateObserved: false,
|
||||
};
|
||||
paused = null;
|
||||
request.server.send(request.frame);
|
||||
await expect
|
||||
.poll(() => awaitedState?.response ?? null, {
|
||||
message: `${action} should return a response`,
|
||||
})
|
||||
.not.toBeNull();
|
||||
if (!awaitedState?.response?.success) {
|
||||
throw new Error(awaitedState?.response?.error ?? `${action} failed`);
|
||||
}
|
||||
await expect
|
||||
.poll(() => awaitedState?.fileStateObserved ?? false, {
|
||||
message: `${action} should publish ${path} with staged=${staged}`,
|
||||
})
|
||||
.toBe(true);
|
||||
awaitedState = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -349,7 +442,7 @@ test.describe("Git Changes Panel", () => {
|
||||
await expect(pendingStageSlot.locator("svg.animate-spin")).toBeVisible();
|
||||
await expect(siblingRow.locator("svg.animate-spin")).toHaveCount(0);
|
||||
|
||||
pausedAction.release("worktree.stage");
|
||||
await pausedAction.releaseAndWaitForFileState("worktree.stage", "pending-action.txt", true);
|
||||
await session.expandChangesSection("staged-files-section");
|
||||
const stagedSection = testPage.getByTestId("staged-files-section");
|
||||
const stagedRow = stagedSection.getByTestId("file-row-pending-action.txt");
|
||||
@@ -377,7 +470,7 @@ test.describe("Git Changes Panel", () => {
|
||||
);
|
||||
await expect(pendingUnstageSlot.locator("svg.animate-spin")).toBeVisible();
|
||||
|
||||
pausedAction.release("worktree.unstage");
|
||||
await pausedAction.releaseAndWaitForFileState("worktree.unstage", "pending-action.txt", false);
|
||||
await expect(unstagedSection.getByTestId("file-row-pending-action.txt")).toBeVisible();
|
||||
await expect(stagedRow).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types";
|
||||
import { pendingKey, useSessionGit } from "./use-session-git";
|
||||
import { buildPendingFileStateIndex } from "./use-session-git-pending";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
statuses: [] as Array<{ repository_name: string; status: GitStatusEntry }>,
|
||||
checkoutGenerations: {} as Record<string, number>,
|
||||
gitOps: {
|
||||
isLoading: false,
|
||||
loadingOperation: null,
|
||||
pull: vi.fn(),
|
||||
push: vi.fn(),
|
||||
rebase: vi.fn(),
|
||||
merge: vi.fn(),
|
||||
abort: vi.fn(),
|
||||
commit: vi.fn(),
|
||||
stage: vi.fn(),
|
||||
unstage: vi.fn(),
|
||||
discard: vi.fn(),
|
||||
revertCommit: vi.fn(),
|
||||
renameBranch: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
createPR: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./use-session-git-status", () => ({
|
||||
useSessionGitStatus: () => undefined,
|
||||
useSessionGitStatusByRepo: () => mocks.statuses,
|
||||
useSessionGitPendingCheckoutGenerations: () => mocks.checkoutGenerations,
|
||||
useSessionGitPendingScope: (sessionId: string | null) =>
|
||||
sessionId ? `${sessionId}:environment` : "",
|
||||
}));
|
||||
|
||||
vi.mock("./use-session-commits", () => ({
|
||||
useSessionCommits: () => ({ commits: [], loading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("./use-cumulative-diff", () => ({
|
||||
useCumulativeDiff: () => ({ diff: null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-git-operations", () => ({
|
||||
useGitOperations: () => mocks.gitOps,
|
||||
}));
|
||||
|
||||
function status(path: string, staged: boolean, branch = "main"): GitStatusEntry {
|
||||
return {
|
||||
branch,
|
||||
remote_branch: null,
|
||||
modified: [path],
|
||||
added: [],
|
||||
deleted: [],
|
||||
untracked: [],
|
||||
renamed: [],
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
files: {
|
||||
[path]: {
|
||||
path,
|
||||
status: "modified",
|
||||
staged,
|
||||
},
|
||||
},
|
||||
timestamp: null,
|
||||
};
|
||||
}
|
||||
|
||||
type OperationResult = {
|
||||
success: boolean;
|
||||
operation: string;
|
||||
output: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function deferredResult() {
|
||||
let resolve!: (value: OperationResult) => void;
|
||||
const promise = new Promise<OperationResult>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.statuses = [];
|
||||
mocks.checkoutGenerations = {};
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("useSessionGit pending target-state reconciliation", () => {
|
||||
it.each([
|
||||
{ operation: "stage" as const, initialStaged: false, completedStaged: true },
|
||||
{ operation: "unstage" as const, initialStaged: true, completedStaged: false },
|
||||
])(
|
||||
"keeps $operation pending through stale repository refreshes",
|
||||
async ({ operation, initialStaged, completedStaged }) => {
|
||||
const path = `${operation}-pending.txt`;
|
||||
const pending = deferredResult();
|
||||
mocks.gitOps[operation].mockReturnValueOnce(pending.promise);
|
||||
mocks.statuses = [{ repository_name: "", status: status(path, initialStaged) }];
|
||||
|
||||
const hook = renderHook(() => useSessionGit("session-1"));
|
||||
|
||||
act(() => {
|
||||
void hook.result.current[`${operation}File`]([path], "");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
|
||||
});
|
||||
|
||||
mocks.statuses = [{ repository_name: "", status: status(path, initialStaged) }];
|
||||
hook.rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
|
||||
});
|
||||
|
||||
mocks.statuses = [{ repository_name: "", status: status(path, completedStaged) }];
|
||||
hook.rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
pending.resolve({ success: true, operation, output: "" });
|
||||
await pending.promise;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).not.toContain(pendingKey("", path));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("clears an unstage after the refreshed status removes a clean file", async () => {
|
||||
const path = "clean-after-unstage.txt";
|
||||
const pending = deferredResult();
|
||||
mocks.gitOps.unstage.mockReturnValueOnce(pending.promise);
|
||||
mocks.statuses = [{ repository_name: "", status: status(path, true) }];
|
||||
|
||||
const hook = renderHook(() => useSessionGit("session-1"));
|
||||
|
||||
act(() => {
|
||||
void hook.result.current.unstageFile([path], "");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
|
||||
});
|
||||
|
||||
mocks.statuses = [];
|
||||
hook.rerender();
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
pending.resolve({ success: true, operation: "unstage", output: "" });
|
||||
await pending.promise;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).not.toContain(pendingKey("", path));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionGit failed request cleanup", () => {
|
||||
it("clears pending state when the operation returns a failure without a status change", async () => {
|
||||
const path = "failed-stage.txt";
|
||||
mocks.gitOps.stage.mockResolvedValueOnce({
|
||||
success: false,
|
||||
operation: "stage",
|
||||
output: "",
|
||||
error: "stage failed",
|
||||
});
|
||||
mocks.statuses = [{ repository_name: "", status: status(path, false) }];
|
||||
|
||||
const hook = renderHook(() => useSessionGit("session-1"));
|
||||
|
||||
await act(async () => {
|
||||
await hook.result.current.stageFile([path], "");
|
||||
});
|
||||
|
||||
expect(hook.result.current.pendingStageFiles).not.toContain(pendingKey("", path));
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionGit partial repository failures", () => {
|
||||
it.each([
|
||||
{ operation: "stage" as const, initialStaged: false, completedStaged: true },
|
||||
{ operation: "unstage" as const, initialStaged: true, completedStaged: false },
|
||||
])(
|
||||
"keeps successful repository scopes pending after a partial $operation failure",
|
||||
async ({ operation, initialStaged, completedStaged }) => {
|
||||
const successfulPath = `successful-${operation}.txt`;
|
||||
const failedPath = `failed-${operation}.txt`;
|
||||
mocks.statuses = [
|
||||
{ repository_name: "repo-a", status: status(successfulPath, initialStaged) },
|
||||
{ repository_name: "repo-b", status: status(failedPath, initialStaged) },
|
||||
];
|
||||
mocks.gitOps[operation].mockImplementation(
|
||||
async (_paths: string[] | undefined, repositoryName: string | undefined) => ({
|
||||
success: repositoryName === "repo-a",
|
||||
operation,
|
||||
output: "",
|
||||
error: repositoryName === "repo-a" ? undefined : `${operation} failed`,
|
||||
}),
|
||||
);
|
||||
|
||||
const hook = renderHook(() => useSessionGit("session-1"));
|
||||
|
||||
await act(async () => {
|
||||
await hook.result.current[`${operation}File`]([successfulPath, failedPath]);
|
||||
});
|
||||
|
||||
expect(hook.result.current.pendingStageFiles).toEqual(
|
||||
new Set([pendingKey("repo-a", successfulPath)]),
|
||||
);
|
||||
|
||||
mocks.statuses = [
|
||||
{ repository_name: "repo-a", status: status(successfulPath, completedStaged) },
|
||||
{ repository_name: "repo-b", status: status(failedPath, initialStaged) },
|
||||
];
|
||||
hook.rerender();
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).not.toContain(
|
||||
pendingKey("repo-a", successfulPath),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("useSessionGit bulk response ownership", () => {
|
||||
it.each([
|
||||
{ operation: "stage" as const, initialStaged: false, completedStaged: true },
|
||||
{ operation: "unstage" as const, initialStaged: true, completedStaged: false },
|
||||
])(
|
||||
"does not clear a sibling $operation key when one repository reaches target state before the aggregate response",
|
||||
async ({ operation, initialStaged, completedStaged }) => {
|
||||
const firstPath = `first-${operation}.txt`;
|
||||
const secondPath = `second-${operation}.txt`;
|
||||
const first = deferredResult();
|
||||
const second = deferredResult();
|
||||
mocks.statuses = [
|
||||
{ repository_name: "repo-a", status: status(firstPath, initialStaged) },
|
||||
{ repository_name: "repo-b", status: status(secondPath, initialStaged) },
|
||||
];
|
||||
mocks.gitOps[operation].mockImplementation((_paths, repositoryName) =>
|
||||
repositoryName === "repo-a" ? first.promise : second.promise,
|
||||
);
|
||||
|
||||
const hook = renderHook(() => useSessionGit("session-1"));
|
||||
|
||||
let aggregate!: Promise<OperationResult>;
|
||||
act(() => {
|
||||
aggregate = hook.result.current[`${operation}File`]([firstPath, secondPath]);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).toEqual(
|
||||
new Set([pendingKey("repo-a", firstPath), pendingKey("repo-b", secondPath)]),
|
||||
);
|
||||
});
|
||||
|
||||
mocks.statuses = [
|
||||
{ repository_name: "repo-a", status: status(firstPath, completedStaged) },
|
||||
{ repository_name: "repo-b", status: status(secondPath, initialStaged) },
|
||||
];
|
||||
hook.rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).toContain(pendingKey("repo-a", firstPath));
|
||||
});
|
||||
|
||||
first.resolve({ success: true, operation, output: "" });
|
||||
await waitFor(() => expect(mocks.gitOps[operation]).toHaveBeenCalledTimes(2));
|
||||
second.resolve({ success: true, operation, output: "" });
|
||||
await aggregate;
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).toEqual(
|
||||
new Set([pendingKey("repo-b", secondPath)]),
|
||||
);
|
||||
});
|
||||
|
||||
mocks.statuses = [
|
||||
{ repository_name: "repo-a", status: status(firstPath, completedStaged) },
|
||||
{ repository_name: "repo-b", status: status(secondPath, completedStaged) },
|
||||
];
|
||||
hook.rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).toEqual(new Set());
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("useSessionGit overlapping request ownership", () => {
|
||||
it.each([
|
||||
{ operation: "stage" as const, initialStaged: false, completedStaged: true },
|
||||
{ operation: "unstage" as const, initialStaged: true, completedStaged: false },
|
||||
])(
|
||||
"keeps a newer same-direction $operation request pending when the older request fails",
|
||||
async ({ operation, initialStaged, completedStaged }) => {
|
||||
const path = `overlapping-${operation}.txt`;
|
||||
const older = deferredResult();
|
||||
const newer = deferredResult();
|
||||
mocks.statuses = [{ repository_name: "", status: status(path, initialStaged) }];
|
||||
mocks.gitOps[operation].mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise);
|
||||
|
||||
const hook = renderHook(() => useSessionGit("session-1"));
|
||||
|
||||
act(() => {
|
||||
void hook.result.current[`${operation}File`]([path], "");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
void hook.result.current[`${operation}File`]([path], "");
|
||||
});
|
||||
await act(async () => {
|
||||
older.resolve({
|
||||
success: false,
|
||||
operation,
|
||||
output: "",
|
||||
error: `older ${operation} failed`,
|
||||
});
|
||||
await older.promise;
|
||||
});
|
||||
|
||||
expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
|
||||
|
||||
newer.resolve({ success: true, operation, output: "" });
|
||||
mocks.statuses = [{ repository_name: "", status: status(path, completedStaged) }];
|
||||
hook.rerender();
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).not.toContain(pendingKey("", path));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ operation: "stage" as const, initialStaged: false, completedStaged: true },
|
||||
{ operation: "unstage" as const, initialStaged: true, completedStaged: false },
|
||||
])(
|
||||
"does not clear a newer same-direction $operation request from an older success status",
|
||||
async ({ operation, initialStaged, completedStaged }) => {
|
||||
const path = `overlapping-success-${operation}.txt`;
|
||||
const older = deferredResult();
|
||||
const newer = deferredResult();
|
||||
mocks.statuses = [{ repository_name: "", status: status(path, initialStaged) }];
|
||||
mocks.gitOps[operation].mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise);
|
||||
|
||||
const hook = renderHook(() => useSessionGit("session-1"));
|
||||
|
||||
act(() => {
|
||||
void hook.result.current[`${operation}File`]([path], "");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
void hook.result.current[`${operation}File`]([path], "");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
older.resolve({ success: true, operation, output: "" });
|
||||
await older.promise;
|
||||
});
|
||||
|
||||
mocks.statuses = [{ repository_name: "", status: status(path, completedStaged) }];
|
||||
hook.rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
newer.resolve({ success: true, operation, output: "" });
|
||||
await newer.promise;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).not.toContain(pendingKey("", path));
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("pending file state indexing", () => {
|
||||
it("tracks staged and unstaged facets for each repository and path", () => {
|
||||
const index = buildPendingFileStateIndex([
|
||||
{
|
||||
path: "shared.ts",
|
||||
repository_name: "repo-a",
|
||||
status: "modified",
|
||||
staged: true,
|
||||
staged_change: { status: "modified" },
|
||||
unstaged_change: { status: "modified" },
|
||||
},
|
||||
{
|
||||
path: "only-staged.ts",
|
||||
repository_name: "repo-b",
|
||||
status: "modified",
|
||||
staged: true,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(index.get(pendingKey("repo-a", "shared.ts"))).toEqual({
|
||||
hasStaged: true,
|
||||
hasUnstaged: true,
|
||||
});
|
||||
expect(index.get(pendingKey("repo-b", "only-staged.ts"))).toEqual({
|
||||
hasStaged: true,
|
||||
hasUnstaged: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionGit pending scope ownership", () => {
|
||||
it("resets pending ownership when the active session changes", async () => {
|
||||
const path = "shared-worktree-path.txt";
|
||||
const older = deferredResult();
|
||||
const newer = deferredResult();
|
||||
mocks.statuses = [{ repository_name: "", status: status(path, false) }];
|
||||
mocks.gitOps.stage.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise);
|
||||
|
||||
const hook = renderHook(({ sessionId }) => useSessionGit(sessionId), {
|
||||
initialProps: { sessionId: "session-1" },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
void hook.result.current.stageFile([path], "");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
|
||||
});
|
||||
|
||||
hook.rerender({ sessionId: "session-2" });
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).not.toContain(pendingKey("", path));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
void hook.result.current.stageFile([path], "");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
older.resolve({
|
||||
success: false,
|
||||
operation: "stage",
|
||||
output: "",
|
||||
error: "previous session failed",
|
||||
});
|
||||
await older.promise;
|
||||
});
|
||||
|
||||
expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
|
||||
|
||||
newer.resolve({ success: true, operation: "stage", output: "" });
|
||||
});
|
||||
|
||||
it("resets pending ownership when the checked-out branch changes", async () => {
|
||||
const path = "branch-owned-stage.txt";
|
||||
const pending = deferredResult();
|
||||
mocks.statuses = [{ repository_name: "", status: status(path, false, "feature/old") }];
|
||||
mocks.gitOps.stage.mockReturnValueOnce(pending.promise);
|
||||
|
||||
const hook = renderHook(() => useSessionGit("session-1"));
|
||||
|
||||
act(() => {
|
||||
void hook.result.current.stageFile([path], "");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).toContain(pendingKey("", path));
|
||||
});
|
||||
|
||||
mocks.checkoutGenerations = { "": 1 };
|
||||
mocks.statuses = [{ repository_name: "", status: status(path, false, "feature/new") }];
|
||||
hook.rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).not.toContain(pendingKey("", path));
|
||||
});
|
||||
|
||||
pending.resolve({ success: true, operation: "stage", output: "" });
|
||||
});
|
||||
|
||||
it("resets only the repository affected by a checkout generation", async () => {
|
||||
const repoAPath = "repo-a-stage.txt";
|
||||
const repoBPath = "repo-b-stage.txt";
|
||||
const repoAPending = deferredResult();
|
||||
const repoBPending = deferredResult();
|
||||
mocks.statuses = [
|
||||
{ repository_name: "repo-a", status: status(repoAPath, false) },
|
||||
{ repository_name: "repo-b", status: status(repoBPath, false) },
|
||||
];
|
||||
mocks.gitOps.stage
|
||||
.mockReturnValueOnce(repoAPending.promise)
|
||||
.mockReturnValueOnce(repoBPending.promise);
|
||||
|
||||
const hook = renderHook(() => useSessionGit("session-1"));
|
||||
act(() => {
|
||||
void hook.result.current.stageFile([repoAPath], "repo-a");
|
||||
void hook.result.current.stageFile([repoBPath], "repo-b");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(hook.result.current.pendingStageFiles).toEqual(
|
||||
new Set([pendingKey("repo-a", repoAPath), pendingKey("repo-b", repoBPath)]),
|
||||
);
|
||||
});
|
||||
|
||||
mocks.checkoutGenerations = { "repo-a": 1 };
|
||||
mocks.statuses = [
|
||||
{ repository_name: "repo-a", status: status(repoAPath, false) },
|
||||
{ repository_name: "repo-b", status: status(repoBPath, false) },
|
||||
];
|
||||
hook.rerender();
|
||||
|
||||
expect(hook.result.current.pendingStageFiles).toEqual(
|
||||
new Set([pendingKey("repo-b", repoBPath)]),
|
||||
);
|
||||
|
||||
repoAPending.resolve({ success: true, operation: "stage", output: "" });
|
||||
repoBPending.resolve({ success: true, operation: "stage", output: "" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import type { FileInfo } from "@/lib/state/slices/session-runtime/types";
|
||||
import type { useSessionGitStatusByRepo } from "./use-session-git-status";
|
||||
import { splitFilesByChangeLayer } from "./git-change-facets";
|
||||
|
||||
export type PendingFileOperation = "stage" | "unstage";
|
||||
|
||||
export type PendingFileOperationOwner = {
|
||||
operation: PendingFileOperation;
|
||||
requestId: number;
|
||||
scopeIdentity: string;
|
||||
responseSucceeded: boolean;
|
||||
targetStateObservedKeys: Set<string>;
|
||||
};
|
||||
|
||||
export function pendingKey(repo: string | undefined, path: string): string {
|
||||
return `${repo ?? ""}::${path}`;
|
||||
}
|
||||
|
||||
export function pendingKeysForFailedRepositories(
|
||||
buckets: Map<string, string[]>,
|
||||
perRepoResults: Array<{ repository_name: string; success: boolean }> | undefined,
|
||||
): string[] {
|
||||
const allKeys = Array.from(buckets).flatMap(([repositoryName, paths]) =>
|
||||
paths.map((path) => pendingKey(repositoryName, path)),
|
||||
);
|
||||
if (!perRepoResults) return allKeys;
|
||||
|
||||
const failedRepositories = new Set(
|
||||
perRepoResults.filter((entry) => !entry.success).map((entry) => entry.repository_name),
|
||||
);
|
||||
if (failedRepositories.size === 0) return allKeys;
|
||||
|
||||
return Array.from(buckets).flatMap(([repositoryName, paths]) =>
|
||||
failedRepositories.has(repositoryName)
|
||||
? paths.map((path) => pendingKey(repositoryName, path))
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
export function clearPendingFileOperations(
|
||||
keys: string[],
|
||||
owner: PendingFileOperationOwner,
|
||||
pendingFileOperations: MutableRefObject<Map<string, PendingFileOperationOwner>>,
|
||||
setPendingStageFiles: Dispatch<SetStateAction<Set<string>>>,
|
||||
) {
|
||||
const cleared = keys.filter((key) => pendingFileOperations.current.get(key) === owner);
|
||||
if (cleared.length === 0) return;
|
||||
for (const key of cleared) pendingFileOperations.current.delete(key);
|
||||
setPendingStageFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const key of cleared) next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/** Marks the current owner's response as successful and clears keys already observed at target state. */
|
||||
export function markPendingFileOperationsSucceeded(
|
||||
keys: string[],
|
||||
owner: PendingFileOperationOwner,
|
||||
pendingFileOperations: MutableRefObject<Map<string, PendingFileOperationOwner>>,
|
||||
setPendingStageFiles: Dispatch<SetStateAction<Set<string>>>,
|
||||
) {
|
||||
for (const key of keys) {
|
||||
const current = pendingFileOperations.current.get(key);
|
||||
if (current === owner) current.responseSucceeded = true;
|
||||
}
|
||||
clearPendingFileOperations(
|
||||
keys.filter(
|
||||
(key) =>
|
||||
pendingFileOperations.current.get(key) === owner && owner.targetStateObservedKeys.has(key),
|
||||
),
|
||||
owner,
|
||||
pendingFileOperations,
|
||||
setPendingStageFiles,
|
||||
);
|
||||
}
|
||||
|
||||
export function usePendingFileOperationScope(
|
||||
scopeIdentity: string,
|
||||
pendingFileOperations: MutableRefObject<Map<string, PendingFileOperationOwner>>,
|
||||
setPendingStageFiles: Dispatch<SetStateAction<Set<string>>>,
|
||||
): boolean {
|
||||
const activeScopeIdentity = useRef(scopeIdentity);
|
||||
const scopeMatches = activeScopeIdentity.current === scopeIdentity;
|
||||
|
||||
useEffect(() => {
|
||||
if (activeScopeIdentity.current === scopeIdentity) return;
|
||||
activeScopeIdentity.current = scopeIdentity;
|
||||
pendingFileOperations.current.clear();
|
||||
setPendingStageFiles(new Set());
|
||||
}, [pendingFileOperations, scopeIdentity, setPendingStageFiles]);
|
||||
|
||||
return scopeMatches;
|
||||
}
|
||||
|
||||
/** Clears pending operations only for repositories whose checkout generation changed. */
|
||||
export function usePendingFileOperationRepositoryScope(
|
||||
generations: Record<string, number>,
|
||||
pendingFileOperations: MutableRefObject<Map<string, PendingFileOperationOwner>>,
|
||||
setPendingStageFiles: Dispatch<SetStateAction<Set<string>>>,
|
||||
) {
|
||||
const previousGenerations = useRef(generations);
|
||||
|
||||
useEffect(() => {
|
||||
const changedScopes = new Set<string>();
|
||||
const scopes = new Set([
|
||||
...Object.keys(previousGenerations.current),
|
||||
...Object.keys(generations),
|
||||
]);
|
||||
for (const scope of scopes) {
|
||||
if (previousGenerations.current[scope] !== generations[scope]) changedScopes.add(scope);
|
||||
}
|
||||
previousGenerations.current = generations;
|
||||
if (changedScopes.size === 0) return;
|
||||
|
||||
setPendingStageFiles((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const next = new Set(prev);
|
||||
for (const key of prev) {
|
||||
const separator = key.indexOf("::");
|
||||
const repositoryName = separator === -1 ? "" : key.slice(0, separator);
|
||||
if (!changedScopes.has("") && !changedScopes.has(repositoryName)) continue;
|
||||
next.delete(key);
|
||||
pendingFileOperations.current.delete(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [generations, pendingFileOperations, setPendingStageFiles]);
|
||||
}
|
||||
|
||||
type PendingFileState = {
|
||||
hasStaged: boolean;
|
||||
hasUnstaged: boolean;
|
||||
};
|
||||
|
||||
/** Indexes the current staged/unstaged facets once so each pending key is O(1) to reconcile. */
|
||||
export function buildPendingFileStateIndex(allFiles: FileInfo[]): Map<string, PendingFileState> {
|
||||
const index = new Map<string, PendingFileState>();
|
||||
const { stagedFiles, unstagedFiles } = splitFilesByChangeLayer(allFiles);
|
||||
const mark = (files: FileInfo[], layer: keyof PendingFileState) => {
|
||||
for (const file of files) {
|
||||
const key = pendingKey(file.repository_name, file.path);
|
||||
const current = index.get(key) ?? { hasStaged: false, hasUnstaged: false };
|
||||
current[layer] = true;
|
||||
index.set(key, current);
|
||||
}
|
||||
};
|
||||
mark(stagedFiles, "hasStaged");
|
||||
mark(unstagedFiles, "hasUnstaged");
|
||||
return index;
|
||||
}
|
||||
|
||||
function pendingFileOperationCompleted(
|
||||
key: string,
|
||||
operation: PendingFileOperation,
|
||||
fileStateIndex: Map<string, PendingFileState>,
|
||||
): boolean {
|
||||
const state = fileStateIndex.get(key);
|
||||
// Unstaging a staged-only file can make it clean, so the refreshed status
|
||||
// legitimately omits it entirely.
|
||||
if (!state) return operation === "unstage";
|
||||
return operation === "stage"
|
||||
? state.hasStaged && !state.hasUnstaged
|
||||
: state.hasUnstaged && !state.hasStaged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles pending markers only when a refreshed repository status reaches
|
||||
* the requested staged or unstaged state. Repository polling can publish the
|
||||
* previous state after the next action starts, so refresh identity alone is
|
||||
* not a completion signal.
|
||||
*/
|
||||
export function usePerRepoPendingClear(
|
||||
statusByRepo: ReturnType<typeof useSessionGitStatusByRepo>,
|
||||
allFiles: FileInfo[],
|
||||
setPendingStageFiles: Dispatch<SetStateAction<Set<string>>>,
|
||||
pendingFileOperations: MutableRefObject<Map<string, PendingFileOperationOwner>>,
|
||||
) {
|
||||
const prevStatusRef = useRef<Map<string, unknown>>(new Map());
|
||||
useEffect(() => {
|
||||
const next = new Map<string, unknown>();
|
||||
const refreshed: string[] = [];
|
||||
for (const { repository_name, status } of statusByRepo) {
|
||||
next.set(repository_name, status);
|
||||
if (prevStatusRef.current.get(repository_name) !== status) {
|
||||
refreshed.push(repository_name);
|
||||
}
|
||||
}
|
||||
const isLegacySingleRepo = statusByRepo.length === 0;
|
||||
prevStatusRef.current = next;
|
||||
if (refreshed.length === 0 && !isLegacySingleRepo) return;
|
||||
const fileStateIndex = buildPendingFileStateIndex(allFiles);
|
||||
setPendingStageFiles((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const out = new Set<string>();
|
||||
for (const key of prev) {
|
||||
const separator = key.indexOf("::");
|
||||
const repositoryName = separator === -1 ? "" : key.slice(0, separator);
|
||||
const owner = pendingFileOperations.current.get(key);
|
||||
const repositoryRefreshed = isLegacySingleRepo || refreshed.includes(repositoryName);
|
||||
if (
|
||||
!repositoryRefreshed ||
|
||||
!owner ||
|
||||
!pendingFileOperationCompleted(key, owner.operation, fileStateIndex)
|
||||
) {
|
||||
out.add(key);
|
||||
continue;
|
||||
}
|
||||
owner.targetStateObservedKeys.add(key);
|
||||
if (owner.responseSucceeded) {
|
||||
pendingFileOperations.current.delete(key);
|
||||
continue;
|
||||
}
|
||||
out.add(key);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}, [allFiles, statusByRepo, setPendingStageFiles, pendingFileOperations]);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { cleanup, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSessionGitPendingScope } from "./use-session-git-status";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
state: {
|
||||
environmentIdBySessionId: {} as Record<string, string>,
|
||||
sessionCommits: { refetchTrigger: {} as Record<string, number> },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state),
|
||||
}));
|
||||
|
||||
describe("useSessionGitPendingScope", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
// Reviewer-required contract coverage for the pending ownership boundary.
|
||||
it("changes across session and environment scopes but ignores commit refetches", () => {
|
||||
mocks.state.environmentIdBySessionId = { "session-1": "environment-1" };
|
||||
mocks.state.sessionCommits.refetchTrigger = { "environment-1": 0 };
|
||||
|
||||
const hook = renderHook(({ sessionId }) => useSessionGitPendingScope(sessionId), {
|
||||
initialProps: { sessionId: "session-1" as string | null },
|
||||
});
|
||||
const initial = hook.result.current;
|
||||
|
||||
hook.rerender({ sessionId: "session-2" });
|
||||
expect(hook.result.current).not.toBe(initial);
|
||||
|
||||
mocks.state.environmentIdBySessionId["session-2"] = "environment-2";
|
||||
hook.rerender({ sessionId: "session-2" });
|
||||
const environmentScoped = hook.result.current;
|
||||
expect(environmentScoped).not.toBe(initial);
|
||||
|
||||
mocks.state.sessionCommits.refetchTrigger["environment-2"] = 1;
|
||||
hook.rerender({ sessionId: "session-2" });
|
||||
expect(hook.result.current).toBe(environmentScoped);
|
||||
});
|
||||
});
|
||||
@@ -81,3 +81,29 @@ export function useSessionGitStatusByRepo(
|
||||
.sort((a, b) => a.repository_name.localeCompare(b.repository_name));
|
||||
}, [map]);
|
||||
}
|
||||
|
||||
/** Identifies the active pending-operation scope across session/environment replacement. */
|
||||
export function useSessionGitPendingScope(sessionId: string | null): string {
|
||||
return useAppStore((state) => {
|
||||
if (!sessionId) return "";
|
||||
const envKey = state.environmentIdBySessionId[sessionId] ?? sessionId;
|
||||
return `${sessionId}\u0000${envKey}`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout/reset generations are repository-scoped. Commit refetches and
|
||||
* comparison-base refreshes intentionally do not participate here because
|
||||
* they do not replace the checked-out worktree.
|
||||
*/
|
||||
export function useSessionGitPendingCheckoutGenerations(
|
||||
sessionId: string | null,
|
||||
): Record<string, number> {
|
||||
return useAppStore(
|
||||
useShallow((state) => {
|
||||
if (!sessionId) return {};
|
||||
const envKey = state.environmentIdBySessionId[sessionId] ?? sessionId;
|
||||
return state.gitCheckoutGeneration.byEnvironmentId[envKey] ?? {};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { useSessionGitStatus, useSessionGitStatusByRepo } from "./use-session-git-status";
|
||||
/* eslint-disable max-lines */
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo, useRef, type MutableRefObject } from "react";
|
||||
import {
|
||||
useSessionGitPendingCheckoutGenerations,
|
||||
useSessionGitPendingScope,
|
||||
useSessionGitStatus,
|
||||
useSessionGitStatusByRepo,
|
||||
} from "./use-session-git-status";
|
||||
import { useSessionCommits } from "./use-session-commits";
|
||||
import { useCumulativeDiff } from "./use-cumulative-diff";
|
||||
import { useGitOperations } from "@/hooks/use-git-operations";
|
||||
@@ -26,6 +33,18 @@ import { deriveComparisonValues, deriveSessionGitValues } from "./use-session-gi
|
||||
import { useScopedStageOperations } from "./use-scoped-stage-operations";
|
||||
import { normalizeGitStatusFiles } from "@/lib/state/slices/session-runtime/git-status-normalizer";
|
||||
import { splitFilesByChangeLayer } from "./git-change-facets";
|
||||
import {
|
||||
clearPendingFileOperations,
|
||||
markPendingFileOperationsSucceeded,
|
||||
pendingKey,
|
||||
pendingKeysForFailedRepositories,
|
||||
usePendingFileOperationRepositoryScope,
|
||||
usePendingFileOperationScope,
|
||||
usePerRepoPendingClear,
|
||||
type PendingFileOperationOwner,
|
||||
} from "./use-session-git-pending";
|
||||
|
||||
export { pendingKey } from "./use-session-git-pending";
|
||||
|
||||
/**
|
||||
* Per-repo result emitted by frontend-side fan-outs (commit, push, pull,
|
||||
@@ -248,19 +267,10 @@ type StageDispatchArgs = {
|
||||
reposInFiles: string[];
|
||||
stagedFiles: FileInfo[];
|
||||
setPendingStageFiles: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
pendingFileOperations: MutableRefObject<Map<string, PendingFileOperationOwner>>;
|
||||
pendingScopeIdentity: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Encodes a (repo, path) pair as a single Set entry. We need a per-repo key
|
||||
* because two repos can have files at the same relative path (README.md,
|
||||
* .gitignore, etc.) and a flat path-only Set would conflate them. The
|
||||
* separator "::" keeps the key trivially parseable for debugging — repo
|
||||
* names don't contain "::".
|
||||
*/
|
||||
export function pendingKey(repo: string | undefined, path: string): string {
|
||||
return `${repo ?? ""}::${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates a list of per-repo results into a single GitOperationResult.
|
||||
* - `success` is true only if every repo succeeded
|
||||
@@ -357,7 +367,10 @@ function useStageDispatch({
|
||||
reposInFiles,
|
||||
stagedFiles,
|
||||
setPendingStageFiles,
|
||||
pendingFileOperations,
|
||||
pendingScopeIdentity,
|
||||
}: StageDispatchArgs) {
|
||||
const nextPendingRequestId = useRef(0);
|
||||
const groupPathsByRepo = useCallback(
|
||||
(paths: string[]): Map<string, string[]> => groupPathsByRepoName(paths, repoForPath),
|
||||
[repoForPath],
|
||||
@@ -433,31 +446,75 @@ function useStageDispatch({
|
||||
paths: string[],
|
||||
repo: string | undefined,
|
||||
op: (rp: string[], r: string | undefined) => Promise<GitOperationResult>,
|
||||
operation: string,
|
||||
operation: PendingFileOperationOwner["operation"],
|
||||
) => {
|
||||
// Key pending entries by `repo::path` so concurrent operations in
|
||||
// different repositories remain scoped. The consumer `FileRow` checks
|
||||
// membership via the same encoding.
|
||||
// Track the requested transition with each repo/path key so an unrelated
|
||||
// or stale status refresh cannot clear a newer pending action.
|
||||
const buckets = repo !== undefined ? new Map([[repo, paths]]) : groupPathsByRepo(paths);
|
||||
const keys: string[] = [];
|
||||
for (const [r, rp] of buckets) for (const p of rp) keys.push(pendingKey(r, p));
|
||||
const owner: PendingFileOperationOwner = {
|
||||
operation,
|
||||
requestId: ++nextPendingRequestId.current,
|
||||
scopeIdentity: pendingScopeIdentity,
|
||||
responseSucceeded: false,
|
||||
targetStateObservedKeys: new Set(),
|
||||
};
|
||||
for (const key of keys) pendingFileOperations.current.set(key, owner);
|
||||
setPendingStageFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const k of keys) next.add(k);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
return await runPerRepo(paths, repo, operation, op);
|
||||
} finally {
|
||||
setPendingStageFiles((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const next = new Set(prev);
|
||||
for (const k of keys) next.delete(k);
|
||||
return next;
|
||||
});
|
||||
const result = await runPerRepo(paths, repo, operation, op);
|
||||
if (result.success) {
|
||||
markPendingFileOperationsSucceeded(
|
||||
keys,
|
||||
owner,
|
||||
pendingFileOperations,
|
||||
setPendingStageFiles,
|
||||
);
|
||||
} else if (result.per_repo) {
|
||||
const successfulKeys = Array.from(buckets).flatMap(
|
||||
([repositoryName, repositoryPaths]) => {
|
||||
const repositoryResult = result.per_repo?.find(
|
||||
(entry) => entry.repository_name === repositoryName,
|
||||
);
|
||||
return repositoryResult?.success
|
||||
? repositoryPaths.map((path) => pendingKey(repositoryName, path))
|
||||
: [];
|
||||
},
|
||||
);
|
||||
markPendingFileOperationsSucceeded(
|
||||
successfulKeys,
|
||||
owner,
|
||||
pendingFileOperations,
|
||||
setPendingStageFiles,
|
||||
);
|
||||
const failedKeys = pendingKeysForFailedRepositories(buckets, result.per_repo);
|
||||
clearPendingFileOperations(
|
||||
failedKeys,
|
||||
owner,
|
||||
pendingFileOperations,
|
||||
setPendingStageFiles,
|
||||
);
|
||||
} else {
|
||||
clearPendingFileOperations(keys, owner, pendingFileOperations, setPendingStageFiles);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
clearPendingFileOperations(keys, owner, pendingFileOperations, setPendingStageFiles);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[runPerRepo, setPendingStageFiles, groupPathsByRepo],
|
||||
[
|
||||
runPerRepo,
|
||||
setPendingStageFiles,
|
||||
groupPathsByRepo,
|
||||
pendingFileOperations,
|
||||
pendingScopeIdentity,
|
||||
],
|
||||
);
|
||||
const stageFile = useCallback(
|
||||
async (paths: string[], repo?: string) =>
|
||||
@@ -624,10 +681,13 @@ export function useSessionGit(sessionId: string | null | undefined): SessionGit
|
||||
const sid = sessionId ?? null;
|
||||
const gitStatus = useSessionGitStatus(sid);
|
||||
const statusByRepo = useSessionGitStatusByRepo(sid);
|
||||
const pendingScopeIdentity = useSessionGitPendingScope(sid);
|
||||
const pendingCheckoutGenerations = useSessionGitPendingCheckoutGenerations(sid);
|
||||
const { commits, loading: commitsLoading } = useSessionCommits(sid);
|
||||
const { diff: cumulativeDiff } = useCumulativeDiff(sid);
|
||||
const gitOps = useGitOperations(sid);
|
||||
const [pendingStageFiles, setPendingStageFiles] = useState<Set<string>>(new Set());
|
||||
const pendingFileOperations = useRef<Map<string, PendingFileOperationOwner>>(new Map());
|
||||
const {
|
||||
allFiles,
|
||||
unstagedFiles,
|
||||
@@ -637,12 +697,26 @@ export function useSessionGit(sessionId: string | null | undefined): SessionGit
|
||||
repoNamesForControls,
|
||||
perRepoStatus,
|
||||
} = useFileDerivations(statusByRepo, gitStatus);
|
||||
const pendingScopeMatches = usePendingFileOperationScope(
|
||||
pendingScopeIdentity,
|
||||
pendingFileOperations,
|
||||
setPendingStageFiles,
|
||||
);
|
||||
usePendingFileOperationRepositoryScope(
|
||||
pendingCheckoutGenerations,
|
||||
pendingFileOperations,
|
||||
setPendingStageFiles,
|
||||
);
|
||||
usePerRepoPendingClear(statusByRepo, allFiles, setPendingStageFiles, pendingFileOperations);
|
||||
|
||||
const stageOps = useStageDispatch({
|
||||
gitOps,
|
||||
repoForPath,
|
||||
reposInFiles,
|
||||
stagedFiles,
|
||||
setPendingStageFiles,
|
||||
pendingFileOperations,
|
||||
pendingScopeIdentity,
|
||||
});
|
||||
const { stageAll, unstageAll, commit, stageFile, unstageFile, discard } = stageOps;
|
||||
const derived = deriveSessionGitValues(
|
||||
@@ -682,7 +756,7 @@ export function useSessionGit(sessionId: string | null | undefined): SessionGit
|
||||
|
||||
isLoading: gitOps.isLoading,
|
||||
loadingOperation: gitOps.loadingOperation,
|
||||
pendingStageFiles,
|
||||
pendingStageFiles: pendingScopeMatches ? pendingStageFiles : new Set<string>(),
|
||||
|
||||
pull: remoteOps.pull,
|
||||
push: remoteOps.push,
|
||||
|
||||
@@ -139,6 +139,7 @@ export type AppState = KanbanSlice & {
|
||||
gitStatus: (typeof defaultSessionRuntimeState)["gitStatus"];
|
||||
environmentIdBySessionId: (typeof defaultSessionRuntimeState)["environmentIdBySessionId"];
|
||||
sessionCommits: (typeof defaultSessionRuntimeState)["sessionCommits"];
|
||||
gitCheckoutGeneration: (typeof defaultSessionRuntimeState)["gitCheckoutGeneration"];
|
||||
contextWindow: (typeof defaultSessionRuntimeState)["contextWindow"];
|
||||
agents: (typeof defaultSessionRuntimeState)["agents"];
|
||||
availableCommands: (typeof defaultSessionRuntimeState)["availableCommands"];
|
||||
@@ -490,6 +491,7 @@ export type AppState = KanbanSlice & {
|
||||
addSessionCommit: (sessionId: string, commit: SessionCommit) => void;
|
||||
clearSessionCommits: (sessionId: string) => void;
|
||||
bumpSessionCommitsRefetch: (sessionId: string) => void;
|
||||
bumpSessionGitCheckoutGeneration: (sessionId: string, repositoryName?: string) => void;
|
||||
setContextWindow: (sessionId: string, contextWindow: ContextWindowEntry) => void;
|
||||
clearContextWindow: (sessionId: string) => void;
|
||||
bumpAgentProfilesVersion: () => void;
|
||||
|
||||
@@ -72,6 +72,7 @@ export const defaultState = {
|
||||
gitStatus: defaultSessionRuntimeState.gitStatus,
|
||||
environmentIdBySessionId: defaultSessionRuntimeState.environmentIdBySessionId,
|
||||
sessionCommits: defaultSessionRuntimeState.sessionCommits,
|
||||
gitCheckoutGeneration: defaultSessionRuntimeState.gitCheckoutGeneration,
|
||||
contextWindow: defaultSessionRuntimeState.contextWindow,
|
||||
agents: defaultSessionRuntimeState.agents,
|
||||
availableCommands: defaultSessionRuntimeState.availableCommands,
|
||||
@@ -445,6 +446,10 @@ export function mergeInitialState(initialState?: HydrationState): DefaultState {
|
||||
processes: { ...defaultState.processes, ...initialState.processes },
|
||||
gitStatus: { ...defaultState.gitStatus, ...initialState.gitStatus },
|
||||
sessionCommits: { ...defaultState.sessionCommits, ...initialState.sessionCommits },
|
||||
gitCheckoutGeneration: {
|
||||
...defaultState.gitCheckoutGeneration,
|
||||
...initialState.gitCheckoutGeneration,
|
||||
},
|
||||
contextWindow: { ...defaultState.contextWindow, ...initialState.contextWindow },
|
||||
agents: { ...defaultState.agents, ...initialState.agents },
|
||||
sessionMode: { ...defaultState.sessionMode, ...initialState.sessionMode },
|
||||
|
||||
@@ -72,6 +72,7 @@ function purgeEnvScopedRuntime(state: SessionRuntimeSliceState, envKey: string)
|
||||
delete state.sessionCommits.byEnvironmentId[envKey];
|
||||
delete state.sessionCommits.loading[envKey];
|
||||
delete state.sessionCommits.refetchTrigger[envKey];
|
||||
delete state.gitCheckoutGeneration.byEnvironmentId[envKey];
|
||||
delete state.userShells.byEnvironmentId[envKey];
|
||||
delete state.userShells.dismissedByEnvironmentId[envKey];
|
||||
delete state.userShells.loading[envKey];
|
||||
@@ -101,6 +102,7 @@ export const defaultSessionRuntimeState: SessionRuntimeSliceState = {
|
||||
gitStatus: { byEnvironmentId: {}, byEnvironmentRepo: {} },
|
||||
environmentIdBySessionId: {},
|
||||
sessionCommits: { byEnvironmentId: {}, loading: {}, refetchTrigger: {} },
|
||||
gitCheckoutGeneration: { byEnvironmentId: {} },
|
||||
contextWindow: { bySessionId: {} },
|
||||
agents: { agents: [] },
|
||||
availableCommands: { bySessionId: {} },
|
||||
@@ -236,6 +238,13 @@ function buildSessionCommitActions(set: ImmerSet) {
|
||||
const prev = draft.sessionCommits.refetchTrigger[envKey] ?? 0;
|
||||
draft.sessionCommits.refetchTrigger[envKey] = prev + 1;
|
||||
}),
|
||||
bumpSessionGitCheckoutGeneration: (sessionId: string, repositoryName?: string) =>
|
||||
set((draft) => {
|
||||
const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId;
|
||||
const byRepository = (draft.gitCheckoutGeneration.byEnvironmentId[envKey] ??= {});
|
||||
const scope = repositoryName ?? "";
|
||||
byRepository[scope] = (byRepository[scope] ?? 0) + 1;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -325,6 +334,7 @@ export function migrateEnvKeyedData(
|
||||
migrate(draft.gitStatus.byEnvironmentRepo);
|
||||
migrate(draft.sessionCommits.loading);
|
||||
migrate(draft.sessionCommits.refetchTrigger);
|
||||
migrate(draft.gitCheckoutGeneration.byEnvironmentId);
|
||||
migrate(draft.gitStatus.byEnvironmentId);
|
||||
migrate(draft.shell.outputs);
|
||||
migrate(draft.shell.statuses);
|
||||
|
||||
@@ -178,6 +178,11 @@ export type SessionCommitsState = {
|
||||
refetchTrigger: Record<string, number>;
|
||||
};
|
||||
|
||||
/** Checkout generations keyed by environment and repository scope. */
|
||||
export type GitCheckoutGenerationState = {
|
||||
byEnvironmentId: Record<string, Record<string, number>>;
|
||||
};
|
||||
|
||||
export type ContextWindowEntry = {
|
||||
size: number;
|
||||
used: number;
|
||||
@@ -441,6 +446,7 @@ export type SessionRuntimeSliceState = {
|
||||
/** Maps sessionId → environmentId for workspace state sharing. */
|
||||
environmentIdBySessionId: Record<string, string>;
|
||||
sessionCommits: SessionCommitsState;
|
||||
gitCheckoutGeneration: GitCheckoutGenerationState;
|
||||
contextWindow: ContextWindowState;
|
||||
agents: AgentState;
|
||||
availableCommands: AvailableCommandsState;
|
||||
@@ -493,6 +499,8 @@ export type SessionRuntimeSliceActions = {
|
||||
// Signal a refetch without clearing the visible list — see
|
||||
// SessionCommitsState.refetchTrigger.
|
||||
bumpSessionCommitsRefetch: (sessionId: string) => void;
|
||||
/** Bump only the affected repository's checkout generation. */
|
||||
bumpSessionGitCheckoutGeneration: (sessionId: string, repositoryName?: string) => void;
|
||||
// Available commands actions
|
||||
setAvailableCommands: (sessionId: string, commands: AvailableCommand[]) => void;
|
||||
clearAvailableCommands: (sessionId: string) => void;
|
||||
|
||||
@@ -43,6 +43,7 @@ export function buildStateOverrides(m: DefaultState) {
|
||||
shell: m.shell,
|
||||
processes: m.processes,
|
||||
gitStatus: m.gitStatus,
|
||||
gitCheckoutGeneration: m.gitCheckoutGeneration,
|
||||
contextWindow: m.contextWindow,
|
||||
agents: m.agents,
|
||||
sessionMode: m.sessionMode,
|
||||
|
||||
@@ -60,6 +60,7 @@ export type GitCommitData = {
|
||||
|
||||
// Git reset data
|
||||
export type GitResetData = {
|
||||
repository_name?: string;
|
||||
previous_head: string;
|
||||
current_head: string;
|
||||
deleted_count: number;
|
||||
@@ -67,6 +68,7 @@ export type GitResetData = {
|
||||
|
||||
// Git branch switch data
|
||||
export type GitBranchSwitchData = {
|
||||
repository_name?: string;
|
||||
previous_branch: string;
|
||||
current_branch: string;
|
||||
current_head: string;
|
||||
|
||||
@@ -36,6 +36,7 @@ function makeStore(overrides: Record<string, unknown> = {}) {
|
||||
queue: { bySessionId: {}, metaBySessionId: {} },
|
||||
setQueueEntries: vi.fn(),
|
||||
clearLegacyGitStatusEntry: vi.fn(),
|
||||
bumpSessionGitCheckoutGeneration: vi.fn(),
|
||||
bumpSessionCommitsRefetch: vi.fn(),
|
||||
bumpWorkspaceFilesRefresh: vi.fn(),
|
||||
reconcileWorkspaceSourcesAdopted: vi.fn(),
|
||||
@@ -475,12 +476,14 @@ describe("session.workspace_sources.updated handler", () => {
|
||||
it("adopts the workspace root and bumps the Files refresh key", () => {
|
||||
const setTaskSession = vi.fn();
|
||||
const bumpWorkspaceFilesRefresh = vi.fn();
|
||||
const bumpSessionGitCheckoutGeneration = vi.fn();
|
||||
const store = makeStore({
|
||||
taskSessions: {
|
||||
items: { "s-1": { id: "s-1", task_id: "t-1", state: "IDLE", worktree_path: "/old" } },
|
||||
},
|
||||
setTaskSession,
|
||||
bumpWorkspaceFilesRefresh,
|
||||
bumpSessionGitCheckoutGeneration,
|
||||
});
|
||||
|
||||
const handler = registerTaskSessionHandlers(store)["session.workspace_sources.updated"]!;
|
||||
@@ -496,6 +499,7 @@ describe("session.workspace_sources.updated handler", () => {
|
||||
expect.objectContaining({ id: "s-1", worktree_path: "/old", workspace_path: "/new" }),
|
||||
);
|
||||
expect(bumpWorkspaceFilesRefresh).toHaveBeenCalledWith("s-1");
|
||||
expect(bumpSessionGitCheckoutGeneration).toHaveBeenCalledWith("s-1");
|
||||
// The server-issued envelope timestamp is forwarded as the adoption
|
||||
// boundary so the client clock can never retire legitimate turns.
|
||||
expect(store.getState().reconcileWorkspaceSourcesAdopted).toHaveBeenCalledWith(
|
||||
@@ -1240,6 +1244,7 @@ describe("session.state_changed → agentctl ready fallback", () => {
|
||||
it("preserves the primary worktree when a sibling agentctl_ready arrives", () => {
|
||||
const upsertTaskSessionFromEvent = vi.fn();
|
||||
const setTaskSession = vi.fn();
|
||||
const bumpSessionGitCheckoutGeneration = vi.fn();
|
||||
const store = makeStore({
|
||||
taskSessions: {
|
||||
items: {
|
||||
@@ -1258,6 +1263,7 @@ describe("session.state_changed → agentctl ready fallback", () => {
|
||||
sessionWorktreesBySessionId: { itemsBySessionId: { "s-1": ["primary-worktree"] } },
|
||||
setSessionAgentctlStatus: vi.fn(),
|
||||
setTaskSession,
|
||||
bumpSessionGitCheckoutGeneration,
|
||||
upsertTaskSessionFromEvent,
|
||||
setWorktree: vi.fn(),
|
||||
setSessionWorktrees: vi.fn(),
|
||||
@@ -1300,6 +1306,7 @@ describe("session.state_changed → agentctl ready fallback", () => {
|
||||
workspace_path: TASK_ROOT,
|
||||
}),
|
||||
);
|
||||
expect(bumpSessionGitCheckoutGeneration).toHaveBeenCalledWith("s-1");
|
||||
});
|
||||
|
||||
it("does not call upsertTaskSessionFromEvent when agentctl payload omits task_environment_id", () => {
|
||||
|
||||
@@ -535,6 +535,7 @@ function handleAgentctlReady(store: StoreApi<AppState>, payload: any): void {
|
||||
// surfacing the frozen snapshot until the user reloads the tab, masking
|
||||
// the per-repo updates streaming in for both the primary and the sibling.
|
||||
store.getState().clearLegacyGitStatusEntry(payload.session_id);
|
||||
store.getState().bumpSessionGitCheckoutGeneration(payload.session_id);
|
||||
store.getState().bumpSessionCommitsRefetch(payload.session_id);
|
||||
}
|
||||
}
|
||||
@@ -615,6 +616,7 @@ function handleWorkspaceSourcesUpdated(
|
||||
.reconcileWorkspaceSourcesAdopted(adoptedSessionIds ?? [sessionId], boundaryTimestamp);
|
||||
store.getState().bumpWorkspaceFilesRefresh(sessionId);
|
||||
store.getState().clearLegacyGitStatusEntry(sessionId);
|
||||
store.getState().bumpSessionGitCheckoutGeneration(sessionId);
|
||||
store.getState().bumpSessionCommitsRefetch(sessionId);
|
||||
}
|
||||
|
||||
|
||||
@@ -160,13 +160,19 @@ describe("git-status WS handler — commit events", () => {
|
||||
type: "commits_reset",
|
||||
session_id: SESSION,
|
||||
timestamp: "2026-05-28T00:00:01Z",
|
||||
reset: { previous_head: "old-head", current_head: "new-head", deleted_count: 1 },
|
||||
reset: {
|
||||
previous_head: "old-head",
|
||||
current_head: "new-head",
|
||||
deleted_count: 1,
|
||||
repository_name: "repo-a",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const state = store.getState();
|
||||
// Trigger bumped — useSessionCommits will refetch.
|
||||
expect(state.sessionCommits.refetchTrigger[SESSION]).toBe(1);
|
||||
expect(state.gitCheckoutGeneration.byEnvironmentId[SESSION]?.["repo-a"]).toBe(1);
|
||||
// Existing commits remain — this is the whole point. Clearing would make
|
||||
// the Changes panel briefly render its empty state until the refetch
|
||||
// resolved.
|
||||
@@ -188,12 +194,14 @@ describe("git-status WS handler — commit events", () => {
|
||||
current_branch: "new",
|
||||
current_head: "head",
|
||||
base_commit: "base",
|
||||
repository_name: "repo-b",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const state = store.getState();
|
||||
expect(state.sessionCommits.refetchTrigger[SESSION]).toBe(1);
|
||||
expect(state.gitCheckoutGeneration.byEnvironmentId[SESSION]?.["repo-b"]).toBe(1);
|
||||
expect(state.sessionCommits.byEnvironmentId[SESSION]).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -125,22 +125,36 @@ const gitEventHandlers: GitEventHandlers = {
|
||||
},
|
||||
|
||||
commits_reset: (store, event) => {
|
||||
if (isDebug()) debug("commits_reset", { sessionId: event.session_id });
|
||||
if (isDebug())
|
||||
debug("commits_reset", {
|
||||
sessionId: event.session_id,
|
||||
repositoryName: event.reset.repository_name ?? null,
|
||||
});
|
||||
// Trigger a refetch without clearing the visible commits — the Changes
|
||||
// panel would otherwise flicker through its empty state ("Your changed
|
||||
// files will appear here") while the refetch is in flight, because
|
||||
// useSessionCommits returns `commits ?? []` and the panel's hasAnything
|
||||
// gate flips to false the moment commits goes undefined.
|
||||
store.getState().bumpSessionCommitsRefetch(event.session_id);
|
||||
store
|
||||
.getState()
|
||||
.bumpSessionGitCheckoutGeneration(event.session_id, event.reset.repository_name);
|
||||
// Invalidate cumulative diff cache when commits are reset
|
||||
invalidateCumulativeDiffCache(resolveEnvKey(store, event.session_id));
|
||||
},
|
||||
|
||||
branch_switched: (store, event) => {
|
||||
if (isDebug()) debug("branch_switched", { sessionId: event.session_id });
|
||||
if (isDebug())
|
||||
debug("branch_switched", {
|
||||
sessionId: event.session_id,
|
||||
repositoryName: event.branch_switch.repository_name ?? null,
|
||||
});
|
||||
// Stale-while-revalidate (see commits_reset above): refetch with the new
|
||||
// base commit but keep the old list visible until the new one arrives.
|
||||
store.getState().bumpSessionCommitsRefetch(event.session_id);
|
||||
store
|
||||
.getState()
|
||||
.bumpSessionGitCheckoutGeneration(event.session_id, event.branch_switch.repository_name);
|
||||
// Invalidate cumulative diff cache when branch switches
|
||||
invalidateCumulativeDiffCache(resolveEnvKey(store, event.session_id));
|
||||
},
|
||||
|
||||
@@ -15,15 +15,18 @@ On fine-pointer layouts, the action shares the file-type icon slot and normally
|
||||
appears only while the row is hovered. Once an action starts, its progress
|
||||
feedback must remain visible until the associated request finishes, even when
|
||||
the pointer leaves the row. The UI system owns this transient presentation
|
||||
contract; Git operation execution and pending-state lifetime remain owned by
|
||||
their existing systems.
|
||||
contract and the per-file pending lifetime that drives it; Git operation
|
||||
execution and refreshed repository status remain owned by their existing
|
||||
systems.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **File action slot:** The leading Changes-row position that shows either the
|
||||
file-type icon, the stage or unstage action, or its pending indicator.
|
||||
- **Pending file action:** A stage or unstage request whose per-file pending
|
||||
state has started and has not yet cleared.
|
||||
- **Pending file action:** The latest stage or unstage request within the active
|
||||
session, environment, and checked-out branch generation that owns the pending
|
||||
state for one repository and file and has not reached its target state or
|
||||
failed.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -56,12 +59,29 @@ the action is complete.
|
||||
shall retain their always-visible, touch-sized file actions and show the same
|
||||
pending indicator without introducing a hover dependency or reducing the
|
||||
existing touch target.
|
||||
- **AC-UI-CHANGES-FILE-ACTION-FEEDBACK-001.6:** A successful stage or unstage
|
||||
request shall remain pending through stale status updates and clear only after
|
||||
both the successful request response and refreshed status reach the requested
|
||||
staged or unstaged state. Either signal may arrive first.
|
||||
- **AC-UI-CHANGES-FILE-ACTION-FEEDBACK-001.7:** When the current stage or
|
||||
unstage request fails, its pending feedback shall clear. If a newer request in
|
||||
either direction has superseded it for the same repository and file, the
|
||||
older request's completion or failure shall not clear the newer request's
|
||||
pending feedback.
|
||||
- **AC-UI-CHANGES-FILE-ACTION-FEEDBACK-001.8:** When one request spans multiple
|
||||
repositories and only some repository scopes fail, failed scopes shall clear
|
||||
immediately while successful scopes remain pending until their refreshed
|
||||
status reaches the requested state.
|
||||
- **AC-UI-CHANGES-FILE-ACTION-FEEDBACK-001.9:** Pending ownership shall reset
|
||||
when the active session, environment, checked-out branch generation, or
|
||||
workspace source generation changes. A callback from the prior scope shall
|
||||
not add, retain, or clear feedback in the successor scope.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing Git stage or unstage execution, pending-state creation, or
|
||||
pending-state cleanup.
|
||||
- Changing bulk stage, bulk unstage, discard, edit, or commit feedback.
|
||||
- Changing Git stage or unstage execution, transport, or repository status
|
||||
refresh.
|
||||
- Changing discard, edit, commit, or stage-all control presentation.
|
||||
- Adding user-facing copy, notifications, cancellation, or progress estimates.
|
||||
- Changing Changes-panel navigation, grouping, row density, or scroll
|
||||
ownership.
|
||||
|
||||
@@ -10,24 +10,34 @@ requirements:
|
||||
## Purpose and boundaries
|
||||
|
||||
This design keeps the existing per-file stage and unstage spinner visible after
|
||||
a fine-pointer user leaves the affected Changes row. It changes only the
|
||||
presentation precedence inside the shared file-action slot. Git operation
|
||||
dispatch, repository scoping, status refresh, and pending-state cleanup remain
|
||||
unchanged.
|
||||
a fine-pointer user leaves the affected Changes row. It defines both the
|
||||
presentation precedence inside the shared file-action slot and operation-owned
|
||||
pending cleanup in `useSessionGit`. Git operation transport, repository
|
||||
scoping, and status acquisition remain unchanged.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design section |
|
||||
| --- | --- |
|
||||
| `REQ-UI-CHANGES-FILE-ACTION-FEEDBACK-001` | [Components and responsibilities](#components-and-responsibilities), [Visibility precedence](#visibility-precedence), [Responsive behavior](#responsive-behavior), and [Verification](#verification) |
|
||||
| Requirement | Design section |
|
||||
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `REQ-UI-CHANGES-FILE-ACTION-FEEDBACK-001` | [Components and responsibilities](#components-and-responsibilities), [Visibility precedence](#visibility-precedence), [Responsive behavior](#responsive-behavior), [Failure and recovery](#failure-and-recovery), and [Verification](#verification) |
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
- `useSessionGit` continues to own `pendingStageFiles`, keyed by repository and
|
||||
path. It starts the per-file pending state before dispatching
|
||||
`worktree.stage` or `worktree.unstage` and clears it when the associated
|
||||
request completes, including the failure path. Independent status frames do
|
||||
not clear an in-flight marker.
|
||||
path, plus a unique request owner containing the requested stage or unstage
|
||||
transition and the active session/environment/branch generation. A newer
|
||||
request in either direction replaces the owner for overlapping keys.
|
||||
- Pending reconciliation clears a successful operation only after both its
|
||||
successful response and refreshed status reach the requested staged or
|
||||
unstaged state. Either signal can arrive first. Partial failures clear only
|
||||
failed repository scopes; successful scopes remain pending for status
|
||||
reconciliation. Failure cleanup clears a key only while the failed request
|
||||
still owns it, so stale status and an older superseded request cannot clear a
|
||||
newer action.
|
||||
- Session changes, environment replacement, branch switches, and related
|
||||
workspace-source generations reset the ownership map and visible pending set.
|
||||
Late callbacks retain their prior request owner and cannot mutate the
|
||||
successor scope.
|
||||
- `changes-panel-tree.tsx` and `changes-panel-timeline.tsx` continue to derive
|
||||
`FileRow.isPending` from that set. No additional local loading state is added.
|
||||
- `FileRow` continues to share one implementation across the desktop Changes
|
||||
@@ -79,10 +89,19 @@ section.
|
||||
## Failure and recovery
|
||||
|
||||
The UI does not infer completion from hover or elapsed time. It follows the
|
||||
existing `isPending` input. Request completion or failure clears that state;
|
||||
the slot then restores the idle icon/action presentation. Independent status
|
||||
refreshes cannot clear an in-flight marker. Backend and WebSocket errors remain
|
||||
surfaced through their current paths.
|
||||
existing `isPending` input. Pending state clears only after the operation that
|
||||
currently owns the repository/path key has returned success and a refreshed
|
||||
status shows the target state. Either event can arrive first.
|
||||
|
||||
A failed or thrown request clears its keys only when it remains their current
|
||||
owner. For a multi-repository result, failure cleanup selects only unsuccessful
|
||||
or skipped repository scopes. If any newer operation supersedes the request,
|
||||
its later completion or failure cannot clear the newer pending state. The newer
|
||||
action remains pending until its own target state appears or its request fails.
|
||||
Changing the session/environment/branch/workspace-source generation clears the
|
||||
visible state before the successor scope can reuse the same repository/path
|
||||
key. After cleanup, the slot restores the idle icon/action presentation, and
|
||||
backend or WebSocket errors remain surfaced through their current paths.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -92,6 +111,12 @@ surfaced through their current paths.
|
||||
staged section.
|
||||
- The same regression pauses `worktree.unstage`, repeats the pointer-leave
|
||||
assertion, and then proves the file returns to the unstaged section.
|
||||
- A focused hook regression publishes a stale same-repository snapshot during
|
||||
each paused action and proves pending state survives until status reflects
|
||||
the requested transition.
|
||||
- Focused hook regressions cover stage and unstage partial failures across two
|
||||
repositories, same-direction overlapping requests, active-session changes,
|
||||
and checked-out branch generation changes.
|
||||
- The test intercepts only the selected worktree request at the WebSocket
|
||||
transport boundary and forwards every unrelated frame.
|
||||
- Existing coarse-pointer component and Pixel 5 Changes tests continue to prove
|
||||
|
||||
Reference in New Issue
Block a user