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,108 @@
|
||||
/**
|
||||
* @file Tooltip.test.tsx
|
||||
* @description Pins the app-wide tooltip renderer: a control's native `title`
|
||||
* becomes the designed bubble on hover and on keyboard focus, the native
|
||||
* tooltip is suppressed only while the bubble is up and the attribute is put
|
||||
* back afterwards (it is the control's accessible name), and `data-tip-off`
|
||||
* opts out.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { GlobalTooltip } from "../Tooltip";
|
||||
|
||||
function setup(extra?: React.ReactNode) {
|
||||
return render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Refresh the working copy">refresh</button>
|
||||
{extra}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const hover = (el: Element) => fireEvent.pointerOver(el, { bubbles: true });
|
||||
const unhover = (el: Element) => fireEvent.pointerOut(el, { bubbles: true });
|
||||
|
||||
beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const settle = async () => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
};
|
||||
|
||||
describe("GlobalTooltip", () => {
|
||||
it("shows the bubble after a delay and takes the native title while it is up", async () => {
|
||||
setup();
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
|
||||
hover(button);
|
||||
// Nothing yet: a cursor crossing a toolbar must not strobe every control.
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
|
||||
expect(button).not.toHaveAttribute("title");
|
||||
});
|
||||
|
||||
it("hides and gives the title back on the way out", async () => {
|
||||
setup();
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
hover(button);
|
||||
await settle();
|
||||
|
||||
unhover(button);
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
expect(button).toHaveAttribute("title", "Refresh the working copy");
|
||||
});
|
||||
|
||||
it("opens immediately on keyboard focus and closes on Escape", async () => {
|
||||
setup();
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
|
||||
fireEvent.focusIn(button);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
expect(button).toHaveAttribute("title", "Refresh the working copy");
|
||||
});
|
||||
|
||||
it("leaves a data-tip-off control on the browser's own tooltip", async () => {
|
||||
setup(
|
||||
<button title="Raw value" data-tip-off>
|
||||
opted out
|
||||
</button>
|
||||
);
|
||||
const opted = screen.getByRole("button", { name: "opted out" });
|
||||
|
||||
hover(opted);
|
||||
await settle();
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
expect(opted).toHaveAttribute("title", "Raw value");
|
||||
});
|
||||
|
||||
it("reads the title through a child element, and ignores an empty one", async () => {
|
||||
setup(
|
||||
<button title="">
|
||||
<span data-testid="blank-child">blank</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
hover(screen.getByTestId("blank-child"));
|
||||
await settle();
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
button.innerHTML = "<span data-testid='deep'>refresh</span>";
|
||||
hover(screen.getByTestId("deep"));
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user