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
+264 -6
View File
@@ -11,22 +11,30 @@
* the URL's `session`, or the focused pane's session from the page's run poll,
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
* whenever the URL names another workspace or session. Each figure carries its
* token total, and hovering it lists tokens and cost per model. No provider quota: reading Claude's quota rotates
* the stored OAuth token (`cc-usage.js`), and the CLI shows it in the pane.
* token total, and hovering it lists tokens and cost per model. At the right
* end, the plan quota spent per native tool (`GET /api/pricing/quota`, every
* 5 min while visible; a click forces a re-read): one "N% used" chip per tool —
* its fullest window, live account only — and, on hover or focus, a panel
* above grouping every account per tool (saved Claude profiles too): name,
* sign-in expiry, and a row per window with its bar, percent and reset time.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { Wifi, WifiOff } from "lucide-react";
import { RefreshCw, RotateCcw, Wifi, WifiOff } from "lucide-react";
import { api, type RunHandle } from "../lib/api";
import type { CostResult } from "../lib/types";
import { fmt, fmtCostFull, formatModelName } from "../lib/format";
import type { CostResult, ToolQuota } from "../lib/types";
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
const COST_POLL_MS = 30_000;
const QUOTA_POLL_MS = 5 * 60_000;
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = { claude: "Claude", codex: "Codex" };
function localDate(d = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0");
@@ -64,6 +72,255 @@ function CostFigure({ testId, label, r }: { testId: string; label: string; r: Co
);
}
/** Re-run `load` every `ms` and whenever the tab becomes visible again. */
function useVisiblePoll(load: () => void, ms: number, deps: unknown[]) {
useEffect(() => {
const tick = () => document.visibilityState === "visible" && load();
tick();
const id = setInterval(tick, ms);
document.addEventListener("visibilitychange", tick);
return () => {
clearInterval(id);
document.removeEventListener("visibilitychange", tick);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
const fullest = (q: ToolQuota) => Math.max(0, ...q.windows.map((w) => w.usedPercent ?? 0));
const quotaTone = (p: number) =>
p >= 90 ? "text-status-danger" : p >= 70 ? "text-status-warning" : "";
const barTone = (p: number) =>
p >= 90 ? "bg-status-danger" : p >= 70 ? "bg-status-warning" : "bg-fg-muted";
function untilText(iso: string | null): string | null {
const ms = iso ? Date.parse(iso) - Date.now() : NaN;
if (!(ms > 0)) return null;
const m = Math.floor(ms / 60_000);
const d = Math.floor(m / 1440);
const h = Math.floor((m % 1440) / 60);
return d ? `${d}d ${h}h` : h ? `${h}h ${m % 60}m` : `${m}m`;
}
/** "account · sign-in state · as of", whichever parts the entry has. */
function quotaMeta(q: ToolQuota, t: (k: string, o?: Record<string, unknown>) => string) {
const until = untilText(q.expiresAt ?? null);
return [
q.label && q.account,
q.expiresAt &&
q.error !== "ELIVEEXPIRED" &&
(until
? t("statusBar.quotaSignInExpires", { time: until })
: t("statusBar.quotaSignInExpired")),
q.asOf && t("statusBar.quotaAsOf", { ago: timeAgo(q.asOf) }),
]
.filter(Boolean)
.join(" · ");
}
const QUOTA_ERROR_KEY: Record<string, string> = {
ELIVEEXPIRED: "statusBar.quotaExpired",
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
};
/** One account: its name, sign-in line, then a row per window with its reset. */
function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
const { t } = useTranslation("nav");
const meta = quotaMeta(q, t);
return (
<div data-testid="status-bar-quota-account" className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate font-medium">{q.label || q.account}</span>
{many && q.active !== false && (
<span className="shrink-0 rounded bg-accent-solid/15 px-1 text-[10px] leading-4 text-accent">
{t("statusBar.quotaActive")}
</span>
)}
</div>
{meta && <div className="truncate text-[10px] text-fg-muted">{meta}</div>}
{q.error && !q.windows.length ? (
<div className="text-status-warning">
{t(QUOTA_ERROR_KEY[q.error] ?? "statusBar.quotaFailed")}
</div>
) : (
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_4rem] items-center gap-x-2 gap-y-0.5 tabular-nums">
{q.windows.map((w) => {
const p = Math.round(w.usedPercent ?? 0);
const resets = untilText(w.resetsAt);
return (
<Fragment key={w.label}>
<span className="text-fg-muted">
{t(`statusBar.quotaWindow.${w.label}`, { defaultValue: w.label })}
</span>
<span className="h-1.5 overflow-hidden rounded-full bg-surface-4">
<span
className={`block h-full ${barTone(p)}`}
style={{ width: `${Math.min(p, 100)}%` }}
/>
</span>
<span className={`text-right ${quotaTone(p)}`}>{p}%</span>
<span className="flex items-center justify-end gap-1 text-fg-muted">
{resets && (
<>
<RotateCcw className="h-3 w-3" aria-hidden />
<span aria-hidden>{resets}</span>
<span className="sr-only">
{t("statusBar.quotaResets", { time: resets })}
</span>
</>
)}
</span>
</Fragment>
);
})}
</div>
)}
</div>
);
}
function QuotaPanel({
tools,
onEnter,
onLeave,
onRefresh,
refreshing,
}: {
tools: ToolQuota[];
/** The footer's Refresh: re-reads the quota, as a click on the chips does. */
onRefresh: () => void;
/** A forced read is in flight: the Refresh icon spins. */
refreshing: boolean;
/** The pointer moved onto the panel / off it: it stays open while read. */
onEnter: () => void;
onLeave: () => void;
}) {
const { t } = useTranslation("nav");
const groups = (Object.keys(QUOTA_TOOL_LABEL) as ToolQuota["id"][])
.map((id) => [id, tools.filter((q) => q.id === id)] as const)
.filter(([, list]) => list.length);
return createPortal(
<div
role="dialog"
aria-label={t("statusBar.quotaTitle")}
data-testid="status-bar-quota-panel"
onMouseEnter={onEnter}
onMouseLeave={onLeave}
onFocus={onEnter}
onBlur={onLeave}
className="fixed bottom-7 right-2 z-[9999] w-[19rem] max-w-[calc(100vw-1rem)] rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
>
<div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div>
{groups.map(([id, list]) => (
<div key={id} className="mb-2 border-b border-border pb-2 last-of-type:border-0">
<div className="mb-1 flex items-center gap-1.5">
<AiToolIcon tool={{ id, label: QUOTA_TOOL_LABEL[id] }} className="h-4 w-4 !p-0" />
<span className="font-semibold">{QUOTA_TOOL_LABEL[id]}</span>
</div>
<div className="space-y-2 pl-[1.375rem]">
{list.map((q, i) => (
<QuotaAccount
key={`${q.label ?? q.account ?? ""}-${i}`}
q={q}
many={list.length > 1}
/>
))}
</div>
</div>
))}
<button
type="button"
data-testid="status-bar-quota-refresh"
// Not `disabled`: a focused button that turns disabled loses focus, and
// that blur would close the panel under the pointer.
onClick={() => !refreshing && onRefresh()}
aria-busy={refreshing}
className={`flex items-center gap-1 text-[10px] text-fg-muted ${
refreshing ? "cursor-default" : "hover:text-fg-primary"
}`}
>
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} aria-hidden />
{t(refreshing ? "statusBar.quotaRefreshing" : "statusBar.quotaRefresh")}
</button>
</div>,
document.body
);
}
/** The right-end quota chips; nothing at all until a tool reports one. */
function QuotaChips() {
const { t } = useTranslation("nav");
const [tools, setTools] = useState<ToolQuota[]>([]);
const [open, setOpen] = useState(false);
// Leaving the chips closes the panel only after a beat, and entering the
// panel cancels that: the pointer crosses the gap to read it.
const closeTimer = useRef<number>();
const show = () => {
window.clearTimeout(closeTimer.current);
setOpen(true);
};
const hide = () => {
window.clearTimeout(closeTimer.current);
closeTimer.current = window.setTimeout(() => setOpen(false), 200);
};
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
const [refreshing, setRefreshing] = useState(false);
const load = useCallback((force = false) => {
if (force) setRefreshing(true);
api.pricing
.quota(force)
.then((r) => setTools(r.tools))
.catch(() => undefined)
.finally(() => force && setRefreshing(false));
}, []);
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
if (!tools.length) return null;
return (
<>
<button
type="button"
data-testid="status-bar-quota"
data-tip-off
onClick={() => load(true)}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
className="ml-auto flex items-center gap-3 tabular-nums hover:text-fg-primary"
>
{tools
.filter((q) => q.active !== false)
.map((q) => {
const p = Math.round(fullest(q));
return (
<span key={q.id} className="flex items-center gap-1">
<AiToolIcon
tool={{ id: q.id, label: QUOTA_TOOL_LABEL[q.id] }}
className="h-4 w-4 !p-0"
/>
<span className="sr-only">{QUOTA_TOOL_LABEL[q.id]}</span>
{q.error ? (
<span className="text-status-warning">!</span>
) : (
<span className={quotaTone(p)}>{t("statusBar.quotaUsed", { pct: p })}</span>
)}
</span>
);
})}
</button>
{open && (
<QuotaPanel
tools={tools}
onEnter={show}
onLeave={hide}
onRefresh={() => load(true)}
refreshing={refreshing}
/>
)}
</>
);
}
export function StatusBar({
wsConnected,
left,
@@ -213,6 +470,7 @@ export function StatusBar({
r={sessionCost}
/>
)}
<QuotaChips />
</footer>
);
}