feat(lanes,ui): per-lane git surface — Changes panel, VS Code-style diff, branch-aware Add lane, redesigned tooltips (#34, #35)
- Changes card beside lane detail with five modes: Files tree, overview, Review (whole diff, per-file collapse), single file, commit history. - LaneDiffView: unified / side-by-side with independent scroll on both axes and mirrored scrollLeft, expand/collapse context, intra-line emphasis. - Staged-only commit box; git actions (fetch/pull/push/stage/unstage/ discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js. - Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind. - Add lane: new/existing branch, current branch, worktree list, prefill from the git bar's "New lane here"; /api/run/cwds gains a repo kind. - GlobalTooltip upgrades every native title into a themed anchored bubble; chart/info popovers use theme tokens.
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @file Workspace.changesPanel.test.tsx
|
||||
* @description The Changes panel is ONE control: it steps narrow → 50/50 →
|
||||
* 70/30 → closed, and the rail reopens it narrow. The width survives a reload.
|
||||
* All of it lives in localStorage and reaches no server — the same rule as the
|
||||
* lane strip's order.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, cleanup, screen, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import i18n from "i18next";
|
||||
|
||||
// Hoisted: `vi.mock`'s factory is lifted above the module body, so the
|
||||
// fixtures it reads have to be lifted with it.
|
||||
const { HOME, LANE_A, LANE_B } = vi.hoisted(() => {
|
||||
const laneStub = (id: number, cwd: string) => ({
|
||||
id,
|
||||
title: `lane-${id}`,
|
||||
cwd,
|
||||
branch: null,
|
||||
kind: "adopted",
|
||||
source_repo: null,
|
||||
pipeline: "default",
|
||||
session_id: null,
|
||||
run_id: null,
|
||||
stage: "idle",
|
||||
stage_since: null,
|
||||
status: "idle",
|
||||
gate_decision: null,
|
||||
ci_status: null,
|
||||
needs_action: null,
|
||||
links: {},
|
||||
stages: {},
|
||||
notes: null,
|
||||
pipeline_name: "Default",
|
||||
pipeline_nodes: [],
|
||||
progress: 0,
|
||||
stage_seconds: null,
|
||||
last_event_seconds: null,
|
||||
liveness: "idle",
|
||||
detected_stage: null,
|
||||
detected_signal: null,
|
||||
slot: null,
|
||||
ports: {},
|
||||
active_feature_id: null,
|
||||
});
|
||||
return {
|
||||
HOME: { kind: "home", path: "/Users/tester", label: "Home" },
|
||||
LANE_A: laneStub(1, "/workspace/alpha"),
|
||||
LANE_B: laneStub(2, "/workspace/beta"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
run: {
|
||||
list: r({ items: [] }),
|
||||
history: r({ items: [] }),
|
||||
binary: r({ found: true, path: "/usr/bin/claude" }),
|
||||
cwds: r({ items: [HOME] }),
|
||||
files: r({ items: [] }),
|
||||
start: r({ id: "run-1", status: "running" }),
|
||||
get: r({ id: "run-1", status: "running" }),
|
||||
},
|
||||
lanes: {
|
||||
list: r({
|
||||
lanes: [LANE_A, LANE_B],
|
||||
counts: { total: 2, running: 0, needs_you: 0, dead: 0 },
|
||||
}),
|
||||
pipelines: r({ pipelines: [] }),
|
||||
git: r({ available: false }),
|
||||
runtime: r({ up: false, ports: {}, lastError: null }),
|
||||
features: { list: r({ features: [] }), show: r({ feature: null }) },
|
||||
proof: { list: r({ features: [] }), imageUrl: () => "" },
|
||||
},
|
||||
aiRouters: { list: r({ items: [] }) },
|
||||
sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
import { Workspace } from "../Workspace";
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver =
|
||||
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Workspace — the Changes panel's width", () => {
|
||||
beforeEach(() => window.localStorage.clear());
|
||||
|
||||
async function openPanel() {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
// Open/closed is itself remembered, so the second mount of a test comes up
|
||||
// already open — clicking the rail then would close it.
|
||||
const rail = screen.queryByTestId("lane-changes-expand");
|
||||
if (rail) {
|
||||
fireEvent.click(rail);
|
||||
await settle();
|
||||
}
|
||||
}
|
||||
|
||||
it("steps narrow → 50/50 → 70/30 → closed on one control", async () => {
|
||||
await openPanel();
|
||||
const card = () => screen.getByTestId("lane-changes-card");
|
||||
expect(card().className).toContain("w-[28rem]");
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
expect(card().className).toContain("w-1/2");
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
expect(card().className).toContain("w-[70%]");
|
||||
|
||||
// Past the widest step the panel closes — and reopens narrow, not back at
|
||||
// 70/30, which is why closing resets the width.
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
await settle();
|
||||
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-changes-expand"));
|
||||
await settle();
|
||||
expect(card().className).toContain("w-[28rem]");
|
||||
});
|
||||
|
||||
it("remembers the width across a reload, and never asks the server", async () => {
|
||||
await openPanel();
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("half");
|
||||
|
||||
cleanup();
|
||||
await openPanel();
|
||||
expect(screen.getByTestId("lane-changes-card").className).toContain("w-1/2");
|
||||
});
|
||||
|
||||
it("keeps the rail as the only way back in", async () => {
|
||||
await openPanel();
|
||||
expect(screen.queryByTestId("lane-changes-collapse")).toBeNull();
|
||||
expect(screen.getByTestId("lane-changes-card")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user