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
+14 -5
View File
@@ -508,7 +508,7 @@ async function synthesizeAddedPatch(dir, file) {
* `file` is set, which is already a full read of one file.
* @param {number} [opts.context] Unchanged lines around each hunk. The panel
* raises this to expand a diff towards the whole file.
* @returns {Promise<{range, base, baseRef, sha, context, files, truncated}>}
* @returns {Promise<{range, base, baseRef, mergeBase, sha, context, files, truncated}>}
*/
async function collectChanges(
dir,
@@ -520,11 +520,17 @@ async function collectChanges(
const ctx = normalizeContext(context);
let baseRef = null;
// The commit a branch diff is taken from (`<baseRef>...HEAD`): the editor
// reads a file's original text there.
let mergeBase = null;
if (range === "branch") {
baseRef = await resolveBaseRef(dir, base);
if (!baseRef) {
throw badRequest("ENOBASE", "no base branch to compare against");
}
mergeBase = await git(dir, ["merge-base", baseRef, "HEAD"])
.then((r) => r.stdout.trim() || null)
.catch(() => null);
}
if (range === "commit") {
if (!sha || !SAFE_REV.test(sha)) throw badRequest("EBADREV", "sha is not a valid revision");
@@ -598,7 +604,7 @@ async function collectChanges(
}
}
return { range, base, baseRef, sha, context: ctx, files, truncated };
return { range, base, baseRef, mergeBase, sha, context: ctx, files, truncated };
}
/**
@@ -615,8 +621,10 @@ async function collectChanges(
*/
async function listCommits(dir, { limit = 30, file = null } = {}) {
const n = Math.min(Math.max(Number(limit) || 30, 1), 200);
const format = ["%H", "%h", "%s", "%an", "%aI", "%D"].join("%x1f");
const args = ["log", `-${n}`, `--format=${format}%x1e`];
const format = ["%H", "%h", "%s", "%an", "%aI", "%D", "%P"].join("%x1f");
// --date-order: no parent before all its children, which the history's
// commit graph needs to draw a lane down to each parent.
const args = ["log", `-${n}`, "--date-order", `--format=${format}%x1e`];
// Through the same lexical guard as every other path: in a subdirectory
// lane `../x` would list commit metadata for a path outside the lane.
if (file) args.push("--", lanePath(file));
@@ -625,7 +633,7 @@ async function listCommits(dir, { limit = 30, file = null } = {}) {
for (const record of result.stdout.split("\x1e")) {
const line = record.replace(/^\n/, "");
if (!line.trim()) continue;
const [sha, short, subject, author, date, refs] = line.split("\x1f");
const [sha, short, subject, author, date, refs, parents] = line.split("\x1f");
commits.push({
sha,
short,
@@ -638,6 +646,7 @@ async function listCommits(dir, { limit = 30, file = null } = {}) {
.split(", ")
.map((r) => r.trim())
.filter(Boolean),
parents: (parents || "").split(" ").filter(Boolean),
});
}
return commits;