feat: edit in the Changes panel, git graph history, plan quota (v2.3.0)

- Changes panel: a working file opens in a CodeMirror editor with its diff
  inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the
  unchanged lines, Whole file shows them; side by side at 50/50 and full
  width; a Revert per block; original from HEAD or the branch's merge base
  (`mergeBase` on GET /git/changes?range=branch)
- Every diff: green = line added whole, red = removed whole, blue = edited
  in place (--diff-mod)
- History drawn as a commit graph (#58: `parents` on GET /git/log,
  --date-order; lib/commitGraph.ts)
- Per-workspace never-stage list, drawn orange in the Files tree (--kept);
  closed Terminal rows past three deleted
- Status bar plan quota for Claude (per account) and Codex
  (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that
  stays open, Refresh button with a spinner
- Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies;
  compact sidebar navigation and short session times
This commit is contained in:
2026-10-06 15:38:25 +07:00
parent f0500dc592
commit daf5b7be92
65 changed files with 2414 additions and 587 deletions
@@ -4,12 +4,13 @@
* state with the version (click opens the connection details), the running and
* needs-you SESSION counts, today's spend (`date=` on the pricing cost call)
* with its tokens and a per-model hover list, and on the Workspace page the
* open workspace's and the shown session's totals.
* open workspace's and the shown session's totals; the right-end quota chips
* (fullest window per tool) and their hover panel.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
@@ -68,6 +69,42 @@ vi.mock("../../lib/api", () => ({
],
daily_costs: [],
})),
quota: vi.fn().mockResolvedValue({
tools: [
{
id: "claude",
active: true,
label: "Work",
account: "me@example.com",
expiresAt: new Date(Date.now() + 3 * 3600_000 + 30_000).toISOString(),
windows: [
{
label: "5h",
usedPercent: 95,
resetsAt: new Date(Date.now() + 41 * 60_000 + 30_000).toISOString(),
},
{ label: "wk", usedPercent: 32, resetsAt: null },
],
},
{
id: "claude",
active: false,
label: "Personal",
account: "home@example.com",
expiresAt: "2020-01-01T00:00:00.000Z",
windows: [],
error: "EPROFILEEXPIRED",
},
{
id: "codex",
account: "dev@example.com",
expiresAt: "2020-01-01T00:00:00.000Z",
windows: [],
error: "EUPSTREAM",
},
],
checkedAt: "",
}),
sessionCost: vi.fn().mockImplementation(async (id: string) => ({
total_cost: id === "s-live" ? 4.5 : 7,
breakdown: [],
@@ -144,4 +181,52 @@ describe("StatusBar", () => {
});
expect(await screen.findByText(/^Session \$4\.50/)).toBeInTheDocument();
});
it("shows each tool's fullest quota window and the details on hover", async () => {
const user = userEvent.setup();
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
const chips = await screen.findByTestId("status-bar-quota");
expect(chips).toHaveTextContent("Claude95% used");
expect(chips.textContent?.match(/Claude/g)).toHaveLength(1);
expect(screen.getByText("95% used")).toHaveClass("text-status-danger");
await user.hover(chips);
const panel = screen.getByTestId("status-bar-quota-panel");
const [work, personal, codex] = screen.getAllByTestId("status-bar-quota-account");
expect(work).toHaveTextContent("Workin use");
expect(work).toHaveTextContent("me@example.com · Sign-in expires in 3h 0m");
expect(work).toHaveTextContent("5h95%41m");
expect(work).toHaveTextContent("Week32%");
expect(personal).toHaveTextContent("home@example.com · Sign-in expired");
expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles");
expect(personal).not.toHaveTextContent("in use");
expect(codex).toHaveTextContent("dev@example.com");
expect(codex).toHaveTextContent("Sign-in expired");
expect(panel).toHaveTextContent("Could not read the quota");
// Moving onto the panel keeps it open, so it can be read; leaving it closes it.
await user.unhover(chips);
await user.hover(panel);
await new Promise((r) => setTimeout(r, 300));
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
// Only the footer button refreshes: it spins and says so until the read lands.
const same = await api.pricing.quota(false);
let answer!: (v: unknown) => void;
vi.mocked(api.pricing.quota)
.mockClear()
.mockReturnValueOnce(new Promise((r) => (answer = r)) as never);
const refresh = screen.getByTestId("status-bar-quota-refresh");
await user.click(refresh);
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
expect(refresh).toHaveTextContent("Refreshing…");
expect(refresh).toHaveAttribute("aria-busy", "true");
await user.click(refresh);
expect(api.pricing.quota).toHaveBeenCalledTimes(1);
await act(async () => answer(same));
expect(screen.getByTestId("status-bar-quota-refresh")).toHaveTextContent("Refresh");
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
await user.unhover(panel);
await waitFor(() => expect(screen.queryByTestId("status-bar-quota-panel")).toBeNull());
vi.mocked(api.pricing.quota).mockClear();
await user.click(chips);
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
});
});