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:
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user