637 lines
17 KiB
TypeScript
637 lines
17 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
activateChangesPanel,
|
|
applyChangesPanelAutoFocusState,
|
|
markInactiveChangesIncreases,
|
|
migrateEnvironmentKeys,
|
|
selectChangesMarkerByEnvironment,
|
|
shouldClearPendingChangesFocus,
|
|
} from "./changes-panel-focus";
|
|
import type { FileInfo, GitStatusEntry } from "@/lib/state/slices/session-runtime/types";
|
|
import { RIGHT_TOP_GROUP } from "@/lib/state/layout-manager";
|
|
import { useDockviewStore } from "@/lib/state/dockview-store";
|
|
|
|
const hashDiffMock = vi.hoisted(() => vi.fn((diff: string) => `hash:${diff}`));
|
|
|
|
vi.mock("@/lib/utils/hash", () => ({
|
|
djb2Hash: hashDiffMock,
|
|
}));
|
|
|
|
const TEST_TIMESTAMP = "2026-06-29T00:00:00Z";
|
|
const INITIAL_FINGERPRINT = "repo:path:initial";
|
|
const UPDATED_FINGERPRINT = "repo:path:updated";
|
|
|
|
beforeEach(() => {
|
|
hashDiffMock.mockClear();
|
|
useDockviewStore.setState({
|
|
activeLayoutProfile: { kind: "built-in", id: "default" },
|
|
});
|
|
});
|
|
|
|
function activationApi(groupId: string, panelIds: string[]) {
|
|
const setActive = vi.fn();
|
|
const changesPanel = {
|
|
id: "changes",
|
|
group: { id: groupId, panels: panelIds.map((id) => ({ id })) },
|
|
api: { setActive },
|
|
};
|
|
const api = {
|
|
getPanel: vi.fn().mockReturnValue(changesPanel),
|
|
} as unknown as Parameters<typeof activateChangesPanel>[0];
|
|
return { api, setActive };
|
|
}
|
|
|
|
// @covers AC-UI-TASK-LAYOUT-PROFILES-001.11
|
|
describe("activateChangesPanel", () => {
|
|
it("activates Changes in the Default Files and Changes group", () => {
|
|
const { api, setActive } = activationApi(RIGHT_TOP_GROUP, ["files", "changes"]);
|
|
|
|
expect(activateChangesPanel(api)).toBe("activated");
|
|
expect(setActive).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("does not activate Changes in a copied Default custom profile", () => {
|
|
useDockviewStore.setState({
|
|
activeLayoutProfile: { kind: "custom", id: "layout-copied-default" },
|
|
});
|
|
const { api, setActive } = activationApi(RIGHT_TOP_GROUP, ["files", "changes"]);
|
|
|
|
expect(activateChangesPanel(api)).toBe("blocked-layout");
|
|
expect(setActive).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
["a non-Default group", "group-center", ["files", "changes"]],
|
|
["a Default group with an extra VS Code tab", RIGHT_TOP_GROUP, ["files", "changes", "vscode"]],
|
|
])("does not activate Changes in %s", (_name, groupId, panelIds) => {
|
|
const { api, setActive } = activationApi(groupId, panelIds);
|
|
|
|
expect(activateChangesPanel(api)).toBe("blocked-layout");
|
|
expect(setActive).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
function gitStatus(files: string[], timestamp = TEST_TIMESTAMP, diff = ""): GitStatusEntry {
|
|
return {
|
|
branch: "main",
|
|
remote_branch: null,
|
|
modified: [],
|
|
added: [],
|
|
deleted: [],
|
|
untracked: files,
|
|
renamed: [],
|
|
ahead: 0,
|
|
behind: 0,
|
|
files: Object.fromEntries(
|
|
files.map((path) => [path, { path, status: "untracked", staged: false, diff }]),
|
|
),
|
|
timestamp,
|
|
};
|
|
}
|
|
|
|
describe("selectChangesMarkerByEnvironment cache", () => {
|
|
it("reuses cached fingerprints for unchanged git status objects", () => {
|
|
const file: FileInfo = {
|
|
path: "one.ts",
|
|
status: "modified",
|
|
staged: false,
|
|
diff: "large diff body",
|
|
};
|
|
const status: GitStatusEntry = {
|
|
branch: "main",
|
|
remote_branch: null,
|
|
modified: ["one.ts"],
|
|
added: [],
|
|
deleted: [],
|
|
untracked: [],
|
|
renamed: [],
|
|
ahead: 0,
|
|
behind: 0,
|
|
files: { "one.ts": file },
|
|
timestamp: TEST_TIMESTAMP,
|
|
};
|
|
const state = {
|
|
gitStatus: {
|
|
byEnvironmentId: {},
|
|
byEnvironmentRepo: {
|
|
envA: {
|
|
repo1: status,
|
|
},
|
|
},
|
|
},
|
|
sessionCommits: {
|
|
loading: {},
|
|
refetchTrigger: {},
|
|
byEnvironmentId: {},
|
|
},
|
|
};
|
|
|
|
const baseMarker = selectChangesMarkerByEnvironment(state).envA;
|
|
const nextMarker = selectChangesMarkerByEnvironment(state).envA;
|
|
|
|
expect(nextMarker).toEqual(baseMarker);
|
|
expect(hashDiffMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("selectChangesMarkerByEnvironment", () => {
|
|
it("ignores timestamp-only git refreshes", () => {
|
|
const baseState = {
|
|
gitStatus: {
|
|
byEnvironmentId: {},
|
|
byEnvironmentRepo: {
|
|
envA: {
|
|
repo1: gitStatus(["one.ts"], "2026-06-29T00:00:00Z"),
|
|
},
|
|
},
|
|
},
|
|
sessionCommits: {
|
|
loading: {},
|
|
refetchTrigger: {},
|
|
byEnvironmentId: {},
|
|
},
|
|
};
|
|
const nextState = {
|
|
...baseState,
|
|
gitStatus: {
|
|
byEnvironmentId: {},
|
|
byEnvironmentRepo: {
|
|
envA: {
|
|
repo1: gitStatus(["one.ts"], "2026-06-29T00:01:00Z"),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const baseMarker = selectChangesMarkerByEnvironment(baseState).envA;
|
|
const nextMarker = selectChangesMarkerByEnvironment(nextState).envA;
|
|
|
|
expect(nextMarker.count).toBe(baseMarker.count);
|
|
expect(nextMarker.fingerprint).toBe(baseMarker.fingerprint);
|
|
});
|
|
|
|
it("changes fingerprint for diff-only git updates with the same count", () => {
|
|
const baseState = {
|
|
gitStatus: {
|
|
byEnvironmentId: {},
|
|
byEnvironmentRepo: {
|
|
envA: {
|
|
repo1: gitStatus(["one.ts"], TEST_TIMESTAMP, "old diff"),
|
|
},
|
|
},
|
|
},
|
|
sessionCommits: {
|
|
loading: {},
|
|
refetchTrigger: {},
|
|
byEnvironmentId: {},
|
|
},
|
|
};
|
|
const nextState = {
|
|
...baseState,
|
|
gitStatus: {
|
|
byEnvironmentId: {},
|
|
byEnvironmentRepo: {
|
|
envA: {
|
|
repo1: gitStatus(["one.ts"], TEST_TIMESTAMP, "new diff"),
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const baseMarker = selectChangesMarkerByEnvironment(baseState).envA;
|
|
const nextMarker = selectChangesMarkerByEnvironment(nextState).envA;
|
|
|
|
expect(nextMarker.count).toBe(baseMarker.count);
|
|
expect(nextMarker.fingerprint).not.toBe(baseMarker.fingerprint);
|
|
});
|
|
});
|
|
|
|
function signal(count: number, fingerprint: string): string {
|
|
return `${count}\u0000${fingerprint}`;
|
|
}
|
|
|
|
describe("applyChangesPanelAutoFocusState fallback session keys", () => {
|
|
it("activates pending fallback session keys before the env id mapping arrives", () => {
|
|
const previousMarkers = {
|
|
"session-B": { count: 1, fingerprint: INITIAL_FINGERPRINT },
|
|
};
|
|
const pendingEnvKeys = new Set(["session-B"]);
|
|
let activateCalls = 0;
|
|
|
|
const previousActiveEnvKey = applyChangesPanelAutoFocusState({
|
|
signalsByEnv: {
|
|
"session-B": signal(1, INITIAL_FINGERPRINT),
|
|
},
|
|
activeEnvKey: "session-B",
|
|
previousActiveEnvKey: "envA",
|
|
environmentIdBySessionId: {},
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
isRestoringLayout: false,
|
|
isMaximized: false,
|
|
activate: () => {
|
|
activateCalls += 1;
|
|
return "activated";
|
|
},
|
|
});
|
|
|
|
expect(previousActiveEnvKey).toBe("session-B");
|
|
expect(activateCalls).toBe(1);
|
|
expect(pendingEnvKeys.size).toBe(0);
|
|
});
|
|
|
|
it("does not queue updates for the active fallback session key", () => {
|
|
const previousMarkers = {
|
|
"session-A": { count: 1, fingerprint: INITIAL_FINGERPRINT },
|
|
};
|
|
const pendingEnvKeys = new Set<string>();
|
|
let activateCalls = 0;
|
|
|
|
const previousActiveEnvKey = applyChangesPanelAutoFocusState({
|
|
signalsByEnv: {
|
|
"session-A": signal(2, UPDATED_FINGERPRINT),
|
|
},
|
|
activeEnvKey: "session-A",
|
|
previousActiveEnvKey: "session-A",
|
|
environmentIdBySessionId: {},
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
isRestoringLayout: false,
|
|
isMaximized: false,
|
|
activate: () => {
|
|
activateCalls += 1;
|
|
return "activated";
|
|
},
|
|
});
|
|
|
|
expect(previousActiveEnvKey).toBe("session-A");
|
|
expect(activateCalls).toBe(0);
|
|
expect(pendingEnvKeys.size).toBe(0);
|
|
expect(previousMarkers["session-A"]).toEqual({
|
|
count: 2,
|
|
fingerprint: UPDATED_FINGERPRINT,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("applyChangesPanelAutoFocusState returning environments", () => {
|
|
it("activates pending changes when returning from another environment", () => {
|
|
const previousMarkers = {
|
|
envB: { count: 1, fingerprint: "b1" },
|
|
};
|
|
const pendingEnvKeys = new Set(["envB"]);
|
|
let activateCalls = 0;
|
|
|
|
applyChangesPanelAutoFocusState({
|
|
signalsByEnv: {
|
|
envB: signal(1, "b1"),
|
|
},
|
|
activeEnvKey: "envB",
|
|
previousActiveEnvKey: "envA",
|
|
environmentIdBySessionId: {},
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
isRestoringLayout: false,
|
|
isMaximized: false,
|
|
activate: () => {
|
|
activateCalls += 1;
|
|
return "activated";
|
|
},
|
|
});
|
|
|
|
expect(activateCalls).toBe(1);
|
|
expect(pendingEnvKeys.size).toBe(0);
|
|
});
|
|
|
|
it("keeps pending changes while maximized and activates them after exit", () => {
|
|
const previousMarkers = {
|
|
envB: { count: 1, fingerprint: "b1" },
|
|
};
|
|
const pendingEnvKeys = new Set(["envB"]);
|
|
let activateCalls = 0;
|
|
const baseArgs = {
|
|
signalsByEnv: {
|
|
envB: signal(1, "b1"),
|
|
},
|
|
activeEnvKey: "envB",
|
|
previousActiveEnvKey: "envA",
|
|
environmentIdBySessionId: {},
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
isRestoringLayout: false,
|
|
};
|
|
|
|
applyChangesPanelAutoFocusState({
|
|
...baseArgs,
|
|
isMaximized: true,
|
|
activate: () => {
|
|
activateCalls += 1;
|
|
return "no-panel" as const;
|
|
},
|
|
});
|
|
const whileMaximized = {
|
|
activateCalls,
|
|
pendingEnvKeys: [...pendingEnvKeys],
|
|
};
|
|
|
|
applyChangesPanelAutoFocusState({
|
|
...baseArgs,
|
|
isMaximized: false,
|
|
activate: () => {
|
|
activateCalls += 1;
|
|
return "activated" as const;
|
|
},
|
|
});
|
|
|
|
expect({
|
|
whileMaximized,
|
|
afterExit: {
|
|
activateCalls,
|
|
pendingEnvKeys: [...pendingEnvKeys],
|
|
},
|
|
}).toEqual({
|
|
whileMaximized: {
|
|
activateCalls: 1,
|
|
pendingEnvKeys: ["envB"],
|
|
},
|
|
afterExit: {
|
|
activateCalls: 2,
|
|
pendingEnvKeys: [],
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("applyChangesPanelAutoFocusState", () => {
|
|
it("migrates keys before queuing, defers during restore, and clears after activation", () => {
|
|
const previousMarkers = {};
|
|
const pendingEnvKeys = new Set<string>();
|
|
let previousActiveEnvKey: string | null = "envA";
|
|
let activateCalls = 0;
|
|
|
|
previousActiveEnvKey = applyChangesPanelAutoFocusState({
|
|
signalsByEnv: {
|
|
"session-B": signal(1, INITIAL_FINGERPRINT),
|
|
},
|
|
activeEnvKey: "envA",
|
|
previousActiveEnvKey,
|
|
environmentIdBySessionId: {},
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
isRestoringLayout: false,
|
|
isMaximized: false,
|
|
activate: () => {
|
|
activateCalls += 1;
|
|
return "activated";
|
|
},
|
|
});
|
|
|
|
expect(pendingEnvKeys.size).toBe(0);
|
|
|
|
previousActiveEnvKey = applyChangesPanelAutoFocusState({
|
|
signalsByEnv: {
|
|
envB: signal(12, UPDATED_FINGERPRINT),
|
|
},
|
|
activeEnvKey: "envA",
|
|
previousActiveEnvKey,
|
|
environmentIdBySessionId: { "session-B": "envB" },
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
isRestoringLayout: false,
|
|
isMaximized: false,
|
|
activate: () => {
|
|
activateCalls += 1;
|
|
return "activated";
|
|
},
|
|
});
|
|
|
|
expect([...pendingEnvKeys]).toEqual(["envB"]);
|
|
expect(previousMarkers).toEqual({
|
|
envB: { count: 12, fingerprint: UPDATED_FINGERPRINT },
|
|
});
|
|
|
|
previousActiveEnvKey = applyChangesPanelAutoFocusState({
|
|
signalsByEnv: {
|
|
envB: signal(12, UPDATED_FINGERPRINT),
|
|
},
|
|
activeEnvKey: "envB",
|
|
previousActiveEnvKey,
|
|
environmentIdBySessionId: { "session-B": "envB" },
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
isRestoringLayout: true,
|
|
isMaximized: false,
|
|
activate: () => {
|
|
activateCalls += 1;
|
|
return "activated";
|
|
},
|
|
});
|
|
|
|
expect(activateCalls).toBe(0);
|
|
expect([...pendingEnvKeys]).toEqual(["envB"]);
|
|
|
|
previousActiveEnvKey = applyChangesPanelAutoFocusState({
|
|
signalsByEnv: {
|
|
envB: signal(12, UPDATED_FINGERPRINT),
|
|
},
|
|
activeEnvKey: "envB",
|
|
previousActiveEnvKey,
|
|
environmentIdBySessionId: { "session-B": "envB" },
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
isRestoringLayout: false,
|
|
isMaximized: false,
|
|
activate: () => {
|
|
activateCalls += 1;
|
|
return "activated";
|
|
},
|
|
});
|
|
|
|
expect(previousActiveEnvKey).toBe("envB");
|
|
expect(activateCalls).toBe(1);
|
|
expect(pendingEnvKeys.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("markInactiveChangesIncreases", () => {
|
|
it("baselines first observations and queues only inactive environment increases", () => {
|
|
const previousMarkers = {};
|
|
const pendingEnvKeys = new Set<string>();
|
|
|
|
markInactiveChangesIncreases({
|
|
markersByEnv: {
|
|
envA: { count: 1, fingerprint: "a1" },
|
|
envB: { count: 0, fingerprint: "b0" },
|
|
},
|
|
activeEnvKey: "envA",
|
|
previousActiveEnvKey: "envA",
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
});
|
|
|
|
expect(pendingEnvKeys.size).toBe(0);
|
|
|
|
markInactiveChangesIncreases({
|
|
markersByEnv: {
|
|
envA: { count: 2, fingerprint: "a2" },
|
|
envB: { count: 1, fingerprint: "b1" },
|
|
},
|
|
activeEnvKey: "envA",
|
|
previousActiveEnvKey: "envA",
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
});
|
|
|
|
expect([...pendingEnvKeys]).toEqual(["envB"]);
|
|
expect(previousMarkers).toEqual({
|
|
envA: { count: 2, fingerprint: "a2" },
|
|
envB: { count: 1, fingerprint: "b1" },
|
|
});
|
|
});
|
|
|
|
it("queues a same-batch update for the previously inactive environment", () => {
|
|
const previousMarkers = {
|
|
envB: { count: 0, fingerprint: "b0" },
|
|
};
|
|
const pendingEnvKeys = new Set<string>();
|
|
|
|
markInactiveChangesIncreases({
|
|
markersByEnv: {
|
|
envB: { count: 1, fingerprint: "b1" },
|
|
},
|
|
activeEnvKey: "envB",
|
|
previousActiveEnvKey: "envA",
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
});
|
|
|
|
expect([...pendingEnvKeys]).toEqual(["envB"]);
|
|
});
|
|
|
|
it("queues count-neutral meaningful updates for inactive environments", () => {
|
|
const previousMarkers = {
|
|
envB: { count: 1, fingerprint: "b1" },
|
|
};
|
|
const pendingEnvKeys = new Set<string>();
|
|
|
|
markInactiveChangesIncreases({
|
|
markersByEnv: {
|
|
envB: { count: 1, fingerprint: "b2" },
|
|
},
|
|
activeEnvKey: "envA",
|
|
previousActiveEnvKey: "envA",
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
});
|
|
|
|
expect([...pendingEnvKeys]).toEqual(["envB"]);
|
|
});
|
|
});
|
|
|
|
describe("markInactiveChangesIncreases pending cleanup", () => {
|
|
it("queues first observed inactive changes after bootstrap", () => {
|
|
const previousMarkers = {
|
|
envA: { count: 0, fingerprint: "a0" },
|
|
};
|
|
const pendingEnvKeys = new Set<string>();
|
|
|
|
markInactiveChangesIncreases({
|
|
markersByEnv: {
|
|
envA: { count: 0, fingerprint: "a0" },
|
|
envB: { count: 1, fingerprint: "b1" },
|
|
},
|
|
activeEnvKey: "envA",
|
|
previousActiveEnvKey: "envA",
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
queueFirstObservedInactiveChanges: true,
|
|
});
|
|
|
|
expect([...pendingEnvKeys]).toEqual(["envB"]);
|
|
});
|
|
|
|
it("clears pending focus when changes disappear or the environment disappears", () => {
|
|
const previousMarkers = {
|
|
envB: { count: 1, fingerprint: "b1" },
|
|
envC: { count: 1, fingerprint: "c1" },
|
|
};
|
|
const pendingEnvKeys = new Set(["envB", "envC"]);
|
|
|
|
markInactiveChangesIncreases({
|
|
markersByEnv: {
|
|
envB: { count: 0, fingerprint: "b0" },
|
|
},
|
|
activeEnvKey: "envA",
|
|
previousActiveEnvKey: "envA",
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
queueFirstObservedInactiveChanges: true,
|
|
});
|
|
|
|
expect(pendingEnvKeys.size).toBe(0);
|
|
expect(previousMarkers).toEqual({
|
|
envB: { count: 0, fingerprint: "b0" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("applyChangesPanelAutoFocusState restore races", () => {
|
|
it("keeps pending focus when a stale restore flag produces no panel", () => {
|
|
const previousMarkers = {
|
|
envB: { count: 1, fingerprint: "b1" },
|
|
};
|
|
const pendingEnvKeys = new Set(["envB"]);
|
|
let restoring = false;
|
|
|
|
applyChangesPanelAutoFocusState({
|
|
signalsByEnv: {
|
|
envB: signal(1, "b1"),
|
|
},
|
|
activeEnvKey: "envB",
|
|
previousActiveEnvKey: "envA",
|
|
environmentIdBySessionId: {},
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
isRestoringLayout: false,
|
|
isMaximized: false,
|
|
getIsRestoringLayout: () => restoring,
|
|
activate: () => {
|
|
restoring = true;
|
|
return "no-panel";
|
|
},
|
|
});
|
|
|
|
expect([...pendingEnvKeys]).toEqual(["envB"]);
|
|
});
|
|
});
|
|
|
|
describe("migrateEnvironmentKeys", () => {
|
|
it("migrates pending and previous fallback session keys to environment keys", () => {
|
|
const previousMarkers = {
|
|
"session-B": { count: 1, fingerprint: "b1" },
|
|
};
|
|
const pendingEnvKeys = new Set(["session-B"]);
|
|
|
|
migrateEnvironmentKeys({
|
|
environmentIdBySessionId: { "session-B": "envB" },
|
|
previousMarkers,
|
|
pendingEnvKeys,
|
|
});
|
|
|
|
expect([...pendingEnvKeys]).toEqual(["envB"]);
|
|
expect(previousMarkers).toEqual({
|
|
envB: { count: 1, fingerprint: "b1" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("shouldClearPendingChangesFocus", () => {
|
|
it("keeps retryable activation results pending", () => {
|
|
expect(shouldClearPendingChangesFocus("activated")).toBe(true);
|
|
expect(shouldClearPendingChangesFocus("no-panel")).toBe(true);
|
|
expect(shouldClearPendingChangesFocus("blocked-agent-group")).toBe(false);
|
|
expect(shouldClearPendingChangesFocus("blocked-layout")).toBe(false);
|
|
expect(shouldClearPendingChangesFocus("no-api")).toBe(false);
|
|
});
|
|
});
|