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
@@ -40,3 +40,12 @@ describe("changeTree", () => {
expect(treeFiles(root.dirs[0]!).map((f) => f.path)).toHaveLength(3);
});
});
describe("changeTree — an untracked folder", () => {
it("shows git's `dir/` entry as a named row in its parent folder", () => {
const root = changeTree([{ path: "docs/plans/analytics-parity/" }, { path: "docs/x.md" }]);
const docs = root.dirs[0]!;
expect(docs.files.map((f) => f.name)).toEqual(["x.md"]);
expect(docs.dirs[0]!.files.map((f) => f.name)).toEqual(["analytics-parity/"]);
});
});
@@ -0,0 +1,48 @@
/**
* @file Tests for the History commit-graph layout: a straight line, a branch
* merged back, converging lanes and a parent beyond the loaded page.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { commitGraph } from "../commitGraph";
const c = (sha: string, ...parents: string[]) => ({ sha, parents });
describe("commitGraph", () => {
it("draws a linear history in one column", () => {
const { rows, width } = commitGraph([c("c", "b"), c("b", "a"), c("a")]);
expect(width).toBe(1);
expect(rows.map((r) => r.col)).toEqual([0, 0, 0]);
// The tip has no line from above; the root none below.
expect(rows[0]?.segs).toEqual([{ x1: 0, y1: 0.5, x2: 0, y2: 1, color: 0 }]);
expect(rows[2]?.segs).toEqual([{ x1: 0, y1: 0, x2: 0, y2: 0.5, color: 0 }]);
});
it("opens a lane for a merged branch and closes it at the fork point", () => {
// m merge of b2 into a2
// |\\
// a2 b2
// | b1
// |/
// a1
const { rows, width } = commitGraph([
c("m", "a2", "b2"),
c("a2", "a1"),
c("b2", "b1"),
c("b1", "a1"),
c("a1"),
]);
expect(width).toBe(2);
expect(rows.map((r) => r.col)).toEqual([0, 0, 1, 1, 0]);
expect(rows[0]?.segs).toContainEqual({ x1: 0, y1: 0.5, x2: 1, y2: 1, color: 1 });
// b1's lane converges into a1's column.
expect(rows[4]?.segs).toContainEqual({ x1: 1, y1: 0, x2: 0, y2: 0.5, color: 1 });
expect(rows[1]?.color).toBe(rows[0]?.color);
expect(rows[2]?.color).not.toBe(rows[0]?.color);
});
it("keeps a lane running for a parent past the page", () => {
const { rows } = commitGraph([c("b", "a")]);
expect(rows[0]?.segs).toEqual([{ x1: 0, y1: 0.5, x2: 0, y2: 1, color: 0 }]);
});
});
+6
View File
@@ -390,6 +390,7 @@ import type {
CodexHooksStatus,
AntigravityHooksStatus,
CostResult,
ToolQuota,
DashboardEvent,
Lane,
LaneChangeRange,
@@ -1392,6 +1393,11 @@ export const api = {
applyScope(qs);
return request<CostResult>(`/pricing/cost?${qs.toString()}`);
},
/** GET /api/pricing/quota - plan quota spent per native tool; `force` for a Refresh. */
quota: (force = false) =>
request<{ tools: ToolQuota[]; checkedAt: string }>(
`/pricing/quota${force ? "?force=1" : ""}`
),
/**
* GET /api/pricing/cost/:sessionId - cost for one session, priced as of
* the session's start date.
+3 -1
View File
@@ -20,7 +20,9 @@ export interface ChangeTreeDir<T> {
export function changeTree<T extends { path: string }>(rows: T[]): ChangeTreeDir<T> {
const root: ChangeTreeDir<T> = { name: "", path: "", dirs: [], files: [], count: 0 };
for (const file of rows) {
const parts = file.path.split("/");
// An untracked folder comes from git as `dir/`: a leaf named `dir/`.
const parts = file.path.replace(/\/$/, "").split("/");
if (file.path.endsWith("/")) parts[parts.length - 1] += "/";
let dir = root;
dir.count++;
for (const seg of parts.slice(0, -1)) {
+76
View File
@@ -0,0 +1,76 @@
/**
* @file Lays out the Changes panel's History as a commit graph (the JetBrains /
* `git log --graph` shape): each commit gets a column, and each row lists the
* line segments drawn through it — a lane passing by, the lanes converging on
* the commit from above, and the lines leaving it for its parents below.
* Expects the commits in an order where no parent precedes a child
* (`git log --date-order`); a parent outside the list leaves its lane running
* off the bottom.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** One line in a row, in column units; y runs 0 (top) → 0.5 (node) → 1 (bottom). */
export interface GraphSeg {
x1: number;
y1: number;
x2: number;
y2: number;
color: number;
}
export interface GraphRow {
col: number;
color: number;
segs: GraphSeg[];
}
export function commitGraph(commits: { sha: string; parents: string[] }[]): {
rows: GraphRow[];
width: number;
} {
// lanes[j] = the sha the lane in column j is waiting for; colors[j] its hue.
const lanes: (string | null)[] = [];
const colors: number[] = [];
const hue = (j: number) => colors[j] ?? 0;
let nextColor = 0;
let width = 1;
const open = () => {
const free = lanes.indexOf(null);
const j = free < 0 ? lanes.length : free;
colors[j] = nextColor++;
return j;
};
const rows = commits.map((c) => {
const segs: GraphSeg[] = [];
let col = lanes.indexOf(c.sha);
// A tip nothing above points at: a lane of its own, no line from the top.
if (col < 0) {
col = open();
lanes[col] = null;
}
lanes.forEach((s, j) => {
if (s === null) return;
segs.push(
s === c.sha
? { x1: j, y1: 0, x2: col, y2: 0.5, color: hue(j) }
: { x1: j, y1: 0, x2: j, y2: 1, color: hue(j) }
);
});
const color = hue(col);
lanes.forEach((s, j) => {
if (s === c.sha) lanes[j] = null;
});
c.parents.forEach((p, i) => {
let j = i === 0 ? col : lanes.indexOf(p);
if (j < 0) j = open();
lanes[j] = p;
segs.push({ x1: col, y1: 0.5, x2: j, y2: 1, color: hue(j) });
});
while (lanes.length && lanes[lanes.length - 1] === null) lanes.pop();
width = Math.max(width, lanes.length, col + 1);
return { col, color, segs };
});
return { rows, width };
}
+8 -5
View File
@@ -337,23 +337,26 @@ export function formatMs(ms: number): string {
* Formats how long ago an ISO/SQLite timestamp was, as a translated relative
* string ("just now", "5m ago", "3h ago", "2d ago") using {@link i18n}.
* @param iso A past timestamp (see {@link parseDate}).
* @param short Drop the "ago" ("now", "5m") — the `common:time.*Short` keys.
* @returns A localized relative-time phrase; i18next handles pluralization via `count`.
* @remarks Thresholds cascade seconds -> minutes -> hours -> days (days is the largest
* bucket, so a 40-day-old event reads "40d ago"). Under a minute collapses to the
* "just now" key. Uses translated keys, not `Intl.RelativeTimeFormat`, so the exact
* wording is controlled by the `common:time.*` translation resources.
*/
export function timeAgo(iso: string): string {
export function timeAgo(iso: string, short = false): string {
const ms = Date.now() - parseDate(iso).getTime();
if (Number.isNaN(ms)) return "-";
// `short` drops the "ago" ("17m"), for a narrow column like the sidebar's.
const k = short ? "Short" : "";
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return i18n.t("common:time.justNow");
if (seconds < 60) return i18n.t(`common:time.justNow${k}`);
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return i18n.t("common:time.mAgo", { count: minutes });
if (minutes < 60) return i18n.t(`common:time.mAgo${k}`, { count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return i18n.t("common:time.hAgo", { count: hours });
if (hours < 24) return i18n.t(`common:time.hAgo${k}`, { count: hours });
const days = Math.floor(hours / 24);
return i18n.t("common:time.dAgo", { count: days });
return i18n.t(`common:time.dAgo${k}`, { count: days });
}
// ===========================================================================
+13
View File
@@ -3,6 +3,8 @@
* @description Keyboard helpers shared by every single-line submit field.
* `isSubmitEnter` ignores the Enter that confirms an IME candidate (Vietnamese
* Telex, CJK): without it a rename or commit message is committed half-typed.
* `copyOnCtrlShiftC` makes Ctrl+Shift+C a copy, as in a Linux terminal, instead
* of the browser's inspect-element shortcut (App.tsx installs it, capture phase).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -13,3 +15,14 @@ export function isSubmitEnter(e: ReactKeyboardEvent | KeyboardEvent): boolean {
const native = "nativeEvent" in e ? e.nativeEvent : e;
return e.key === "Enter" && !e.shiftKey && !native.isComposing && native.keyCode !== 229;
}
/**
* Ctrl+Shift+C copies the selection — a field's, the page's or a terminal's
* (xterm answers the `copy` event) — instead of opening DevTools. F12 and
* Ctrl+Shift+I still open them. With nothing selected it does nothing.
*/
export function copyOnCtrlShiftC(e: KeyboardEvent): void {
if (e.code !== "KeyC" || !e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey) return;
e.preventDefault();
document.execCommand("copy");
}
+12 -5
View File
@@ -34,8 +34,9 @@ export const SETTINGS_INDEX: {
to?: string;
}[] = [
{
section: "general",
section: "appearance",
titleKey: "language.title",
to: "#language",
keywords: ["english", "tiếng việt", "vietnamese", "ngôn ngữ"],
},
{
@@ -90,14 +91,20 @@ export const SETTINGS_INDEX: {
keywords: ["version", "server", "database", "uptime"],
},
{
section: "pet",
section: "appearance",
titleKey: "sagi.title",
descKey: "sagi.description",
keywords: ["sagi", "mascot", "pet"],
to: "#pet",
},
{ section: "appearance", titleKey: "sagi.bubbles", descKey: "sagi.bubblesDesc", to: "#pet" },
{ section: "appearance", titleKey: "sagi.pet.size", to: "#pet" },
{
section: "appearance",
titleKey: "sagi.pet.choose",
keywords: ["codex-pets", "upload", "import"],
to: "#pet",
},
{ section: "pet", titleKey: "sagi.bubbles", descKey: "sagi.bubblesDesc" },
{ section: "pet", titleKey: "sagi.pet.size" },
{ section: "pet", titleKey: "sagi.pet.choose", keywords: ["codex-pets", "upload", "import"] },
{ section: "appearance", titleKey: "appearance.mode", keywords: ["dark", "light", "system"] },
{ section: "appearance", titleKey: "appearance.darkThemes", keywords: ["theme", "colors"] },
{ section: "appearance", titleKey: "appearance.lightThemes" },
+23
View File
@@ -1206,6 +1206,25 @@ export interface CostResult {
unpriced_models?: UnpricedModel[];
}
/** GET /api/pricing/quota: plan quota spent per native tool (server/lib/quota.js). */
export interface ToolQuota {
id: "claude" | "codex";
/** One per plan window: `5h`, `wk`, `mo`, …; `usedPercent` is 0–100. */
windows: Array<{ label: string; usedPercent: number | null; resetsAt: string | null }>;
/** Why there is no figure, e.g. `ELIVEEXPIRED` (Claude's token needs a CLI refresh). */
error?: string;
/** False for a saved Claude auth profile that is not the live login. */
active?: boolean;
/** The saved profile's label, when the account has one. */
label?: string | null;
/** The signed-in account's email, when the tool's login names one. */
account?: string | null;
/** When the stored access token expires (ISO). */
expiresAt?: string | null;
/** When the figure was read, if not just now (Codex's journal, an expired profile). */
asOf?: string;
}
// ───── Import progress ─────
/** Payload of the `import.progress` WebSocket message, streamed while a
@@ -2716,6 +2735,8 @@ export interface LaneCommit {
date: string;
/** `%D` decorations — `HEAD -> main`, `origin/main`, `tag: v1`… */
refs: string[];
/** Parent shas, first parent first — the history's commit graph. */
parents: string[];
}
/** `GET /api/lanes/:id/git/file` — one file's whole content at a revision. */
@@ -2795,6 +2816,8 @@ export type LaneChanges =
base: string | null;
/** The ref the base actually resolved to (`origin/main`, `main`, …). */
baseRef: string | null;
/** `range: "branch"`: the merge base the diff is taken from (full sha). */
mergeBase?: string | null;
/** The commit being shown, for `range: "commit"`; its full sha. */
sha: string | null;
/** Lines of unchanged context each hunk carries. */