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:
+1
-1
@@ -52,7 +52,7 @@ The client is a single-page application (SPA) built with modern web technologies
|
||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → General) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
|
||||
|
||||
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
||||
|
||||
|
||||
Generated
+112
@@ -8,6 +8,10 @@
|
||||
"name": "agenthub-client",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/merge": "^6.12.2",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
@@ -410,6 +414,66 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
|
||||
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/merge": {
|
||||
"version": "6.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
|
||||
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"style-mod": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
|
||||
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.13",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz",
|
||||
"integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||
@@ -1052,6 +1116,36 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
|
||||
"integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
|
||||
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
|
||||
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -2580,6 +2674,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||
@@ -5702,6 +5802,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
|
||||
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-to-js": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
||||
@@ -6402,6 +6508,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"test:watch": "NODE_OPTIONS=--no-experimental-webstorage vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/merge": "^6.12.2",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
|
||||
@@ -70,6 +70,7 @@ import { NotFound } from "./pages/NotFound";
|
||||
import { useWebSocket } from "./hooks/useWebSocket";
|
||||
import { useNotifications } from "./hooks/useNotifications";
|
||||
import { eventBus } from "./lib/eventBus";
|
||||
import { copyOnCtrlShiftC } from "./lib/keys";
|
||||
import type { WSMessage } from "./lib/types";
|
||||
|
||||
// The heavy pages (xterm, the diff viewer, the settings editors) load on first
|
||||
@@ -128,6 +129,12 @@ export default function App() {
|
||||
return () => window.removeEventListener("vite:preloadError", onPreloadError);
|
||||
}, []);
|
||||
|
||||
// Ctrl+Shift+C is copy here (Linux terminal habit), not the browser's inspector.
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", copyOnCtrlShiftC, true);
|
||||
return () => window.removeEventListener("keydown", copyOnCtrlShiftC, true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SplashScreen />
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* codex-pets.net for more, and open the Sagi settings. The choice and the size
|
||||
* are viewer preferences (`prefs.ts`); the pets themselves live on the server
|
||||
* (`server/lib/pets.js`); the list and its actions are `usePets`, shared
|
||||
* with Settings → Pet.
|
||||
* with Settings → Appearance.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
|
||||
@@ -315,7 +315,7 @@ export function Sagi() {
|
||||
onClose={() => setMenu(false)}
|
||||
onSettings={() => {
|
||||
setMenu(false);
|
||||
navigate("/settings/pet");
|
||||
navigate("/settings/appearance#pet");
|
||||
}}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
|
||||
@@ -159,7 +159,7 @@ describe("PetMenu", () => {
|
||||
fireEvent.click(screen.getByText("Hide pet"));
|
||||
expect(sagiPrefs.getEnabled()).toBe(false);
|
||||
// Says where to bring it back.
|
||||
expect(toasts.some((x) => /Settings → Pet/.test(x.message))).toBe(true);
|
||||
expect(toasts.some((x) => /Settings → Appearance/.test(x.message))).toBe(true);
|
||||
off();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file usePets — the pet list and its actions, shared by the mascot's
|
||||
* right-click menu (`PetMenu.tsx`) and Settings → Pet (`settings/PetSettings`):
|
||||
* right-click menu (`PetMenu.tsx`) and Settings → Appearance (`settings/PetSettings`):
|
||||
* `GET /api/pets`, wearing a pet (a viewer preference, `prefs.ts`), installing
|
||||
* a featured pet (downloaded on its first pick), uploading an image or a
|
||||
* `.codex-pet.zip` (worn at once), and deleting your own after a confirm
|
||||
|
||||
@@ -396,13 +396,15 @@ export function Sidebar({
|
||||
onUpdateAvailableChange?.(updateAvailable);
|
||||
}, [updateAvailable, onUpdateAvailableChange]);
|
||||
|
||||
// Same density and selection as a workspace row, so the whole sidebar
|
||||
// reads as one list: no border, the active item tinted with an accent icon.
|
||||
const navClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`flex items-center gap-3 rounded-lg text-sm font-medium transition-colors duration-150 ${
|
||||
collapsed ? "justify-center px-2 py-2.5" : "px-3 py-2.5"
|
||||
`flex items-center gap-2.5 rounded-md text-[13px] transition-colors duration-150 ${
|
||||
collapsed ? "justify-center px-2 py-2" : "px-2.5 py-1.5"
|
||||
} ${
|
||||
isActive
|
||||
? "bg-accent/10 text-accent border border-accent/20"
|
||||
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
? "bg-accent/10 font-medium text-fg-primary [&>svg]:text-accent"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`;
|
||||
|
||||
return (
|
||||
@@ -451,7 +453,7 @@ export function Sidebar({
|
||||
Chevron buttons appear at the edges when content is clipped, so the
|
||||
user knows there's more to reach without inspecting the scrollbar. */}
|
||||
<div className="flex-1 min-h-0 relative flex">
|
||||
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-3 space-y-1">
|
||||
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
|
||||
{NAV_KEYS.map(({ to, icon: Icon, key }) => {
|
||||
const label = t(key);
|
||||
return (
|
||||
@@ -475,7 +477,7 @@ export function Sidebar({
|
||||
onClick={onToggle}
|
||||
title={t("lanes:tree.title")}
|
||||
aria-label={t("lanes:tree.title")}
|
||||
className="flex w-full items-center justify-center rounded-lg border border-transparent px-2 py-2.5 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
className="flex w-full items-center justify-center rounded-md px-2 py-2 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<FolderTree className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -528,8 +530,8 @@ export function Sidebar({
|
||||
data-testid="sidebar-toggle"
|
||||
title={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||
aria-label={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||
className={`inline-flex items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
||||
collapsed ? "py-2.5" : "h-9 w-9 flex-shrink-0"
|
||||
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
||||
collapsed ? "py-2" : "h-8 w-8 flex-shrink-0"
|
||||
}`}
|
||||
>
|
||||
{collapsed ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* @file The Changes panel's file editor: CodeMirror 6 diffed against the
|
||||
* file's original text (HEAD, or a branch's merge base), editable while the
|
||||
* diff re-colours as you type. `unified` is one editor with the removed lines
|
||||
* inline above their replacement (`unifiedMergeView`); `split` is the
|
||||
* original read-only on the left beside the editable file (`MergeView`).
|
||||
* `collapse` folds unchanged stretches away — the panel's Changes view —
|
||||
* and every changed block carries a Revert that puts the original back in the
|
||||
* editor (nothing is written until Save). Green marks a line added whole, red
|
||||
* a line removed whole, blue a line edited in place (it keeps some of its
|
||||
* text), its edit in a stronger blue — the same scheme as the read-only diff in
|
||||
* LaneDiffView. Syntax colours come from the dashboard's own per-line
|
||||
* tokenizer (`lib/highlight.ts`), like every other code view here.
|
||||
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
|
||||
* CodeMirror stays out of the main bundle, and one mount is one view.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { EditorState, RangeSetBuilder } from "@codemirror/state";
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
drawSelection,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
type DecorationSet,
|
||||
type ViewUpdate,
|
||||
} from "@codemirror/view";
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { MergeView, getChunks, unifiedMergeView, type Chunk } from "@codemirror/merge";
|
||||
import { highlight, tokenClass } from "../../lib/highlight";
|
||||
|
||||
/**
|
||||
* True when a letter or digit of a line (`text`, starting at document offset
|
||||
* `from`) lies outside every changed range: an edit inside the line, not a
|
||||
* line added or removed whole. Kept indentation or punctuation alone (a `;`,
|
||||
* a `}`) does not make two lines the same line.
|
||||
*/
|
||||
export function keepsText(text: string, from: number, changed: [number, number][]): boolean {
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (!/\w/.test(text[i]!)) continue;
|
||||
const at = from + i;
|
||||
if (!changed.some(([a, b]) => at >= a && at < b)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const modLine = Decoration.line({ class: "cm-ah-mod" });
|
||||
const marks = new Map<string, Decoration>();
|
||||
const mark = (cls: string) => {
|
||||
let d = marks.get(cls);
|
||||
if (!d) marks.set(cls, (d = Decoration.mark({ class: cls })));
|
||||
return d;
|
||||
};
|
||||
|
||||
/** Syntax marks and the edited-in-place class for the visible lines — on
|
||||
* either side of a split view (`a` is the original). */
|
||||
function decorate(view: EditorView, lang: string): DecorationSet {
|
||||
const out = new RangeSetBuilder<Decoration>();
|
||||
const info = getChunks(view.state);
|
||||
const a = info?.side === "a";
|
||||
const span = (k: Chunk) => (a ? [k.fromA, k.toA, k.endA] : [k.fromB, k.toB, k.endB]);
|
||||
const chunks = info?.chunks ?? [];
|
||||
const doc = view.state.doc;
|
||||
let last = 0;
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
for (let pos = from; pos <= to; ) {
|
||||
const line = doc.lineAt(pos);
|
||||
pos = line.to + 1;
|
||||
if (line.number <= last) continue;
|
||||
last = line.number;
|
||||
const c = chunks.find((k) => {
|
||||
const [from, to, end] = span(k);
|
||||
return to! > from! && line.from >= from! && line.from <= end!;
|
||||
});
|
||||
const at0 = c ? span(c)[0]! : 0;
|
||||
if (
|
||||
c &&
|
||||
keepsText(
|
||||
line.text,
|
||||
line.from,
|
||||
c.changes.map((ch) =>
|
||||
a ? [at0 + ch.fromA, at0 + ch.toA] : [at0 + ch.fromB, at0 + ch.toB]
|
||||
)
|
||||
)
|
||||
) {
|
||||
out.add(line.from, line.from, modLine);
|
||||
}
|
||||
let at = line.from;
|
||||
for (const tok of highlight(line.text, lang)) {
|
||||
const cls = tokenClass(tok.type);
|
||||
if (cls && tok.text) out.add(at, at + tok.text.length, mark(cls));
|
||||
at += tok.text.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.finish();
|
||||
}
|
||||
|
||||
/** The removed lines are drawn by @codemirror/merge's own widget, so they are
|
||||
* classed after the fact, by `keepsText`'s rule: text outside its
|
||||
* `.cm-deletedText` spans is text the line kept. */
|
||||
function classDeleted(root: HTMLElement) {
|
||||
root.querySelectorAll<HTMLElement>(".cm-deletedLine:not([data-ah])").forEach((line) => {
|
||||
let kept = "";
|
||||
(line.firstElementChild ?? line).childNodes.forEach((n) => {
|
||||
if (!(n instanceof Element && n.classList.contains("cm-deletedText"))) {
|
||||
kept += n.textContent ?? "";
|
||||
}
|
||||
});
|
||||
line.dataset.ah = /\w/.test(kept) ? "mod" : "del";
|
||||
});
|
||||
}
|
||||
|
||||
const diffColours = (lang: string) =>
|
||||
ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = decorate(view, lang);
|
||||
this.classLater(view);
|
||||
}
|
||||
update(u: ViewUpdate) {
|
||||
if (u.docChanged || u.viewportChanged) this.decorations = decorate(u.view, lang);
|
||||
this.classLater(u.view);
|
||||
}
|
||||
// After CodeMirror has drawn, and before the browser paints.
|
||||
classLater(view: EditorView) {
|
||||
view.requestMeasure({
|
||||
key: this,
|
||||
read: () => null,
|
||||
write: () => classDeleted(view.contentDOM),
|
||||
});
|
||||
}
|
||||
},
|
||||
{ decorations: (p) => p.decorations }
|
||||
);
|
||||
|
||||
const GREEN = "rgb(var(--status-success) / 0.18)";
|
||||
const RED = "rgb(var(--status-danger) / 0.18)";
|
||||
const BLUE = "rgb(var(--diff-mod) / 0.18)";
|
||||
const BLUE_EDIT = "rgb(var(--diff-mod) / 0.45)";
|
||||
// `&.cm-merge-b.cm-merge-b`: the doubled class outranks the merge package's
|
||||
// base theme, whose selectors carry an extra light/dark scope class.
|
||||
const M = "&.cm-merge-b.cm-merge-b";
|
||||
const A = "&.cm-merge-a.cm-merge-a";
|
||||
const theme = EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
fontSize: "11px",
|
||||
backgroundColor: "rgb(var(--surface-0))",
|
||||
color: "rgb(var(--fg-primary))",
|
||||
},
|
||||
"&.cm-focused": { outline: "none" },
|
||||
".cm-scroller": {
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", Consolas, monospace',
|
||||
lineHeight: "1.45",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "rgb(var(--surface-1))",
|
||||
color: "rgb(var(--fg-muted))",
|
||||
border: "none",
|
||||
},
|
||||
".cm-activeLine": { backgroundColor: "rgb(var(--fg-primary) / 0.04)" },
|
||||
".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
|
||||
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: "rgb(var(--accent) / 0.3)",
|
||||
},
|
||||
[`${M} .cm-changedLine`]: { backgroundColor: GREEN },
|
||||
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
|
||||
[`${M} .cm-changedText`]: { background: "none" },
|
||||
[`${M} .cm-ah-mod .cm-changedText`]: { background: BLUE_EDIT },
|
||||
[`${M} .cm-deletedChunk`]: { backgroundColor: "transparent" },
|
||||
[`${M} .cm-deletedLine`]: { backgroundColor: RED },
|
||||
[`${M} .cm-deletedLine[data-ah=mod]`]: { backgroundColor: BLUE },
|
||||
[`${M} .cm-deletedChunk .cm-deletedText`]: { background: "none" },
|
||||
[`${M} .cm-deletedLine[data-ah=mod] .cm-deletedText`]: { background: BLUE_EDIT },
|
||||
[`${M} .cm-changedLineGutter`]: { background: "rgb(var(--status-success))" },
|
||||
[`${M} .cm-deletedLineGutter`]: { background: "rgb(var(--status-danger))" },
|
||||
// The original, left of a split view: its changed lines are removals.
|
||||
[`${A} .cm-changedLine`]: { backgroundColor: RED },
|
||||
[`${A} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
|
||||
[`${A} .cm-changedText`]: { background: "none" },
|
||||
[`${A} .cm-ah-mod .cm-changedText`]: { background: BLUE_EDIT },
|
||||
[`${A} .cm-changedLineGutter`]: { background: "rgb(var(--status-danger))" },
|
||||
"&.cm-editor.cm-editor .cm-collapsedLines": {
|
||||
color: "rgb(var(--fg-muted))",
|
||||
background: "rgb(var(--surface-2))",
|
||||
},
|
||||
".cm-chunkButtons": { zIndex: "1" },
|
||||
});
|
||||
|
||||
/** The Revert button on a changed block; @codemirror/merge wires the click.
|
||||
* Between split columns there is room for an arrow only (`→`: left onto right). */
|
||||
function revertButton(label: string, arrow = false) {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.textContent = arrow ? "→" : label;
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", label);
|
||||
b.className =
|
||||
"rounded border border-border-light bg-surface-2 px-1.5 text-[10px] leading-4 text-fg-secondary hover:text-fg-primary";
|
||||
return b;
|
||||
}
|
||||
|
||||
export default function DiffEditor({
|
||||
value,
|
||||
original,
|
||||
lang,
|
||||
layout = "unified",
|
||||
collapse = false,
|
||||
label,
|
||||
revertLabel,
|
||||
onChange,
|
||||
onSave,
|
||||
}: {
|
||||
value: string;
|
||||
/** The text the diff is taken against; read once, at mount. */
|
||||
original: string;
|
||||
lang: string;
|
||||
/** One editor with removals inline, or the original beside the file. */
|
||||
layout?: "unified" | "split";
|
||||
/** Fold unchanged stretches away: only the changes and their context. */
|
||||
collapse?: boolean;
|
||||
label: string;
|
||||
/** The text of each changed block's Revert button. */
|
||||
revertLabel: string;
|
||||
onChange: (text: string) => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const view = useRef<EditorView | null>(null);
|
||||
const cb = useRef({ onChange, onSave });
|
||||
cb.current = { onChange, onSave };
|
||||
|
||||
useEffect(() => {
|
||||
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
|
||||
const common = [lineNumbers(), diffColours(lang), theme];
|
||||
const editing = [
|
||||
...common,
|
||||
highlightActiveLineGutter(),
|
||||
history(),
|
||||
drawSelection(),
|
||||
highlightActiveLine(),
|
||||
keymap.of([
|
||||
{
|
||||
key: "Mod-s",
|
||||
preventDefault: true,
|
||||
run: () => {
|
||||
cb.current.onSave();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
indentWithTab,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
]),
|
||||
EditorView.contentAttributes.of({ "aria-label": label }),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
|
||||
}),
|
||||
];
|
||||
let v: EditorView;
|
||||
let destroy: () => void;
|
||||
if (layout === "split") {
|
||||
const mv = new MergeView({
|
||||
parent: host.current!,
|
||||
a: {
|
||||
doc: original,
|
||||
extensions: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
|
||||
},
|
||||
b: { doc: value, extensions: editing },
|
||||
revertControls: "a-to-b",
|
||||
renderRevertControl: () => revertButton(revertLabel, true),
|
||||
collapseUnchanged: folded,
|
||||
});
|
||||
v = mv.b;
|
||||
destroy = () => mv.destroy();
|
||||
} else {
|
||||
v = new EditorView({
|
||||
parent: host.current!,
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
...editing,
|
||||
unifiedMergeView({
|
||||
original,
|
||||
syntaxHighlightDeletions: false,
|
||||
collapseUnchanged: folded,
|
||||
// Accept would only hide a block; Revert is the one that edits.
|
||||
mergeControls: (type, action) => {
|
||||
const b = revertButton(revertLabel);
|
||||
if (type === "accept") b.hidden = true;
|
||||
else b.onmousedown = action;
|
||||
return b;
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
destroy = () => v.destroy();
|
||||
}
|
||||
v.focus();
|
||||
view.current = v;
|
||||
return destroy;
|
||||
// One mount per view: the parent keys this component by file and shape.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// A value set from outside (Cancel's revert, a save's fresh text).
|
||||
useEffect(() => {
|
||||
const v = view.current;
|
||||
if (v && v.state.doc.toString() !== value) {
|
||||
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={host}
|
||||
data-testid="lane-file-editor"
|
||||
data-layout={layout}
|
||||
className="min-h-0 flex-1 overflow-hidden rounded border border-border-light [&_.cm-mergeView]:h-full [&_.cm-mergeView]:overflow-auto"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,10 @@
|
||||
* to open it
|
||||
* review — every file's full diff, stacked, for reading a whole change-set
|
||||
* file — one file, as a diff or as its whole content (issue #35); a
|
||||
* working file is always the editor (`DiffEditor`, diffed
|
||||
* against HEAD / the branch's merge base, unified or side by
|
||||
* side): Changes folds the unchanged lines away, Whole file
|
||||
* shows them; any other file opens on its read-only diff; a
|
||||
* whole markdown file can also be read rendered (Preview), and an
|
||||
* image file is shown as the picture (`GET …/git/raw`)
|
||||
* history — recent commits; opening one shows that commit's diff
|
||||
@@ -45,7 +49,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronDown,
|
||||
@@ -57,6 +61,7 @@ import {
|
||||
Code2,
|
||||
Columns2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileText,
|
||||
FoldVertical,
|
||||
Folder,
|
||||
@@ -77,12 +82,19 @@ import { isSubmitEnter } from "../../lib/keys";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { useArmed } from "../../hooks/useArmed";
|
||||
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
|
||||
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
||||
import {
|
||||
LaneDiffView,
|
||||
LaneFileView,
|
||||
langFor,
|
||||
type DiffLayout,
|
||||
type LaneDiffChange,
|
||||
} from "./LaneDiffView";
|
||||
import { LaneConflictView } from "./LaneConflictView";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||
import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree";
|
||||
import { commitGraph, type GraphRow } from "../../lib/commitGraph";
|
||||
import type {
|
||||
LaneChangeFile,
|
||||
LaneChangeRange,
|
||||
@@ -136,6 +148,16 @@ function Delta({
|
||||
|
||||
/** What the viewer shows as a picture (`readImageAt`'s list on the server). */
|
||||
const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i;
|
||||
// CodeMirror is loaded only once a file is edited.
|
||||
const DiffEditor = lazy(() => import("./DiffEditor"));
|
||||
|
||||
/** A file that opens straight in the editor: a working file with text — not a
|
||||
* commit's, an image, a binary, a deleted file or a merge conflict (which
|
||||
* opens in the conflict view). */
|
||||
function editableFile(range: LaneChangeRange, path: string, f?: LaneChangeFile) {
|
||||
if (range === "commit" || IMAGE_FILE.test(path)) return false;
|
||||
return !f || (!f.binary && !f.conflicted && !f.status.includes("D"));
|
||||
}
|
||||
|
||||
/** An image file, shown as itself; a broken one (deleted, too big) says so. */
|
||||
function ImageView({ src, path }: { src: string; path: string }) {
|
||||
@@ -198,10 +220,13 @@ function TreeNodes({
|
||||
onOpenFile,
|
||||
onMenu,
|
||||
active,
|
||||
kept,
|
||||
}: {
|
||||
path: string;
|
||||
depth: number;
|
||||
children: Record<string, LaneTreeEntry[]>;
|
||||
/** Paths on the never-stage list: drawn orange, whatever their git status. */
|
||||
kept?: Set<string>;
|
||||
expanded: Set<string>;
|
||||
onToggleDir: (path: string) => void;
|
||||
onOpenFile: (path: string) => void;
|
||||
@@ -216,17 +241,25 @@ function TreeNodes({
|
||||
<>
|
||||
{entries.map((e) => {
|
||||
const mark = e.status ? treeMark(e.status) : null;
|
||||
const isKept = !e.dir && !!kept?.has(e.path);
|
||||
return (
|
||||
<li key={e.path}>
|
||||
<button
|
||||
type="button"
|
||||
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
|
||||
data-tree-kept={isKept || undefined}
|
||||
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
||||
onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
|
||||
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
|
||||
aria-current={active === e.path ? "true" : undefined}
|
||||
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
|
||||
mark ? mark.tone : e.changed ? "text-status-warning" : "text-fg-primary"
|
||||
isKept
|
||||
? "bg-kept/10 text-kept"
|
||||
: mark
|
||||
? mark.tone
|
||||
: e.changed
|
||||
? "text-status-warning"
|
||||
: "text-fg-primary"
|
||||
} ${active === e.path ? "!bg-surface-4" : ""}`}
|
||||
>
|
||||
{e.dir ? (
|
||||
@@ -268,6 +301,7 @@ function TreeNodes({
|
||||
onOpenFile={onOpenFile}
|
||||
onMenu={onMenu}
|
||||
active={active}
|
||||
kept={kept}
|
||||
/>
|
||||
</ul>
|
||||
)}
|
||||
@@ -322,12 +356,17 @@ function FileActions({
|
||||
armed,
|
||||
onAct,
|
||||
onArm,
|
||||
kept = false,
|
||||
onKeep,
|
||||
}: {
|
||||
f: LaneChangeFile;
|
||||
busy: boolean;
|
||||
armed: boolean;
|
||||
onAct: FileAct;
|
||||
onArm: (path: string) => void;
|
||||
/** On the never-stage list: Stage all and a folder's stage skip it. */
|
||||
kept?: boolean;
|
||||
onKeep?: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
return (
|
||||
@@ -350,6 +389,14 @@ function FileActions({
|
||||
<Plus className="h-3 w-3" />
|
||||
</IconButton>
|
||||
) : null}
|
||||
{onKeep && (kept || f.unstaged || f.untracked) ? (
|
||||
<IconButton
|
||||
label={t(kept ? "changes.allowStage" : "changes.neverStage")}
|
||||
onClick={() => onKeep(f.path)}
|
||||
>
|
||||
{kept ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3" />}
|
||||
</IconButton>
|
||||
) : null}
|
||||
<IconButton
|
||||
label={armed ? t("changes.discardConfirm") : t("changes.discard")}
|
||||
disabled={busy}
|
||||
@@ -425,8 +472,12 @@ function ChangeRow({
|
||||
onMenu,
|
||||
name,
|
||||
depth = 0,
|
||||
kept = false,
|
||||
onKeep,
|
||||
}: {
|
||||
f: LaneChangeFile;
|
||||
kept?: boolean;
|
||||
onKeep?: (path: string) => void;
|
||||
/** In the folder tree: the file's own name, and how deep it sits. */
|
||||
name?: string;
|
||||
depth?: number;
|
||||
@@ -461,8 +512,10 @@ function ChangeRow({
|
||||
{f.untracked ? "A" : f.status.trim()[0] || "M"}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary"
|
||||
title={f.path}
|
||||
className={`min-w-0 flex-1 truncate font-mono text-[11px] ${
|
||||
kept ? "text-fg-muted line-through" : "text-fg-primary"
|
||||
}`}
|
||||
title={kept ? `${f.path}\n${t("changes.keptHint")}` : f.path}
|
||||
>
|
||||
{name ?? f.path}
|
||||
</span>
|
||||
@@ -475,7 +528,15 @@ function ChangeRow({
|
||||
(f.conflicted ? (
|
||||
<ConflictActions f={f} busy={busy} onResolve={onResolve} />
|
||||
) : (
|
||||
<FileActions f={f} busy={busy} armed={armed} onAct={onAct} onArm={onArm} />
|
||||
<FileActions
|
||||
f={f}
|
||||
busy={busy}
|
||||
armed={armed}
|
||||
onAct={onAct}
|
||||
onArm={onArm}
|
||||
kept={kept}
|
||||
onKeep={onKeep}
|
||||
/>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
@@ -559,11 +620,56 @@ function ChangeTreeNodes({
|
||||
const HISTORY_PAGE = 50;
|
||||
const HISTORY_MAX = 200;
|
||||
|
||||
// The history's commit graph: one fixed-height row per commit so its lanes
|
||||
// join up row to row. Mid-tone hues that read on dark and light themes alike.
|
||||
const GRAPH_ROW = 34;
|
||||
const GRAPH_COL = 12;
|
||||
const GRAPH_COLORS = [
|
||||
"#3b82f6",
|
||||
"#22c55e",
|
||||
"#f59e0b",
|
||||
"#a855f7",
|
||||
"#ef4444",
|
||||
"#14b8a6",
|
||||
"#ec4899",
|
||||
"#84cc16",
|
||||
];
|
||||
|
||||
function GraphCell({ row, width }: { row: GraphRow; width: number }) {
|
||||
const x = (col: number) => col * GRAPH_COL + GRAPH_COL / 2;
|
||||
const y = (f: number) => f * GRAPH_ROW;
|
||||
const hue = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length];
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
width={width * GRAPH_COL}
|
||||
height={GRAPH_ROW}
|
||||
className="shrink-0"
|
||||
data-testid="lane-changes-graph"
|
||||
>
|
||||
{row.segs.map((g, i) => {
|
||||
const ym = (y(g.y1) + y(g.y2)) / 2;
|
||||
return (
|
||||
<path
|
||||
key={i}
|
||||
d={`M${x(g.x1)} ${y(g.y1)} C${x(g.x1)} ${ym} ${x(g.x2)} ${ym} ${x(g.x2)} ${y(g.y2)}`}
|
||||
fill="none"
|
||||
stroke={hue(g.color)}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<circle cx={x(row.col)} cy={y(0.5)} r={3.5} fill={hue(row.color)} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaneChangesPanel({
|
||||
laneId,
|
||||
session,
|
||||
onNewLane,
|
||||
full = false,
|
||||
wide = full,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
laneId: number;
|
||||
@@ -578,6 +684,8 @@ export function LaneChangesPanel({
|
||||
/** The panel has the whole width: the file tree becomes a sidebar and a
|
||||
* whole file opens straight into the editor. */
|
||||
full?: boolean;
|
||||
/** Half the width or more: diffs and the editor open side by side. */
|
||||
wide?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
// One read of the working copy, shared by the header and every write below:
|
||||
@@ -607,7 +715,10 @@ export function LaneChangesPanel({
|
||||
const openImage = openFile && IMAGE_FILE.test(openFile) ? openFile : null;
|
||||
const [sha, setSha] = useState<string | null>(null);
|
||||
const [context, setContext] = useState(CONTEXT_STEPS[0]);
|
||||
const [layout, setLayout] = useState<DiffLayout>("unified");
|
||||
// Side by side wherever there is room for two columns; a resize starts over
|
||||
// from that default, a click on the toggle overrides it until then.
|
||||
const [layout, setLayout] = useState<DiffLayout>(wide ? "split" : "unified");
|
||||
useEffect(() => setLayout(wide ? "split" : "unified"), [wide]);
|
||||
const [whole, setWhole] = useState(false);
|
||||
// A whole markdown file shown rendered instead of as source; per file.
|
||||
const [mdPreview, setMdPreview] = useState(false);
|
||||
@@ -630,33 +741,17 @@ export function LaneChangesPanel({
|
||||
// The whole-file editor's text while it is open; null = viewing.
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const editWanted = useRef(false);
|
||||
// Where the editor opens: the line clicked in the read view, and that view's
|
||||
// scroll offset, so the text does not jump when the textarea replaces it.
|
||||
const editAt = useRef<{ line: number; scrollTop: number } | null>(null);
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
const editing = draft !== null;
|
||||
useEffect(() => {
|
||||
const at = editAt.current;
|
||||
const el = editorRef.current;
|
||||
if (!editing || !at || !el) return;
|
||||
editAt.current = null;
|
||||
const offset = el.value
|
||||
.split("\n")
|
||||
.slice(0, at.line)
|
||||
.reduce((n, l) => n + l.length + 1, 0);
|
||||
el.setSelectionRange(offset, offset);
|
||||
el.focus({ preventScroll: true });
|
||||
el.scrollTop = at.scrollTop;
|
||||
}, [editing]);
|
||||
const startEdit = (line: number, scrollTop: number) => {
|
||||
// The text the editor diffs against: the file at HEAD (`work`) or at the
|
||||
// branch's merge base; "" for a file that is new there.
|
||||
const [baseText, setBaseText] = useState<string | null>(null);
|
||||
const startEdit = () => {
|
||||
if (!text?.sha) return;
|
||||
editAt.current = { line, scrollTop };
|
||||
setDraft(text.lines.join("\n"));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!editWanted.current || !text) return;
|
||||
editWanted.current = false;
|
||||
if (text.sha) startEdit(0, 0);
|
||||
if (text.sha) startEdit();
|
||||
// Asked for from the diff, refused once the file arrived: say why here,
|
||||
// not with an Edit button that silently does nothing.
|
||||
else setError(t(noEditReason(text)));
|
||||
@@ -692,6 +787,32 @@ export function LaneChangesPanel({
|
||||
if (!next.delete(key)) next.add(key);
|
||||
return next;
|
||||
});
|
||||
// Files the user never wants staged (a local note, a scratch folder): Stage
|
||||
// all and a folder's stage skip them; git is never told — nothing is written
|
||||
// to the repo. A viewer preference per workspace, like the panel's size.
|
||||
const neverStageKey = `agenthub.changes.neverStage.${laneId}`;
|
||||
const [neverStage, setNeverStage] = useState<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = JSON.parse(window.localStorage.getItem(neverStageKey) || "[]");
|
||||
setNeverStage(
|
||||
new Set(Array.isArray(saved) ? saved.filter((p) => typeof p === "string") : [])
|
||||
);
|
||||
} catch {
|
||||
setNeverStage(new Set());
|
||||
}
|
||||
}, [neverStageKey]);
|
||||
const toggleNeverStage = (path: string) =>
|
||||
setNeverStage((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(path)) next.add(path);
|
||||
try {
|
||||
window.localStorage.setItem(neverStageKey, JSON.stringify([...next]));
|
||||
} catch {
|
||||
/* kept for this visit only */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// Where opening a file came FROM: its mode and how far the list was scrolled.
|
||||
// Reviewing a change-set is a walk down a long list, and landing back at the
|
||||
// top of it after every file is the whole cost of opening one.
|
||||
@@ -846,10 +967,15 @@ export function LaneChangesPanel({
|
||||
};
|
||||
}, [mode, laneId, query, full]);
|
||||
|
||||
// The open file's entry in the change list, and whether it is edited in
|
||||
// place: a working file with text opens as the editor in both views.
|
||||
const openEntry =
|
||||
data?.available && openFile ? data.files.find((f) => f.path === openFile) : undefined;
|
||||
const editable = mode === "file" && !!openFile && editableFile(range, openFile, openEntry);
|
||||
// The whole-file read is a different endpoint from the diff, so it is its
|
||||
// own fetch — and only while the viewer is actually showing it.
|
||||
useEffect(() => {
|
||||
if (mode !== "file" || !whole || !openFile) return;
|
||||
if (mode !== "file" || !(whole || editable) || !openFile) return;
|
||||
void api.lanes
|
||||
.gitFile(target, openFile, range === "commit" && sha ? sha : "work")
|
||||
.then(
|
||||
@@ -868,17 +994,22 @@ export function LaneChangesPanel({
|
||||
)
|
||||
)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, [mode, whole, openFile, laneId, range, sha]);
|
||||
}, [mode, whole, editable, openFile, laneId, range, sha]);
|
||||
// The editor's original side, read beside the working file. A file that is
|
||||
// not there (new since) diffs against nothing: every line is added.
|
||||
const mergeBase = data?.available ? (data.mergeBase ?? null) : null;
|
||||
useEffect(() => {
|
||||
if (mode !== "file" || !(whole || editable) || !openFile || range === "commit") return;
|
||||
const rev = range === "branch" && mergeBase ? mergeBase : "HEAD";
|
||||
void api.lanes.gitFile(target, openFile, rev).then(
|
||||
forLane(laneId, (r) => setBaseText(r.available && !r.binary ? r.lines.join("\n") : "")),
|
||||
forLane(laneId, () => setBaseText(""))
|
||||
);
|
||||
}, [mode, whole, editable, openFile, laneId, range, mergeBase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (full && mode === "browse") setMode("overview");
|
||||
}, [full, mode]);
|
||||
// Going full width with a working file already open: straight to the editor.
|
||||
useEffect(() => {
|
||||
if (full && mode === "file" && range !== "commit") setWhole(true);
|
||||
// Only on the switch itself — Whole file must still toggle afterwards.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [full]);
|
||||
|
||||
// A lane switch must not leave the previous lane's file open — its path
|
||||
// almost certainly does not exist in the new one.
|
||||
@@ -939,10 +1070,14 @@ export function LaneChangesPanel({
|
||||
}
|
||||
setOpenFile(path);
|
||||
setContext(CONTEXT_STEPS[0]);
|
||||
// Full width edits by default: a working file opens as the editor, and
|
||||
// Whole file toggles back to its diff. A commit is history, so a diff.
|
||||
setWhole(full && range !== "commit");
|
||||
// A file opens on its Changes. A working file is the editor folded to its
|
||||
// changes (each block with a Revert); Whole file unfolds it. Anything else
|
||||
// — a commit, a binary, a deleted file — is the read-only diff.
|
||||
const f = data?.available ? data.files.find((x) => x.path === path) : undefined;
|
||||
editWanted.current = editableFile(range, path, f);
|
||||
setWhole(false);
|
||||
setText(null);
|
||||
setBaseText(null);
|
||||
setDraft(null);
|
||||
setRawEdit(false);
|
||||
setMode("file");
|
||||
@@ -978,7 +1113,8 @@ export function LaneChangesPanel({
|
||||
if (r.available) {
|
||||
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
|
||||
}
|
||||
setDraft(null);
|
||||
// Saved, still editing: the editor stays on the text just written.
|
||||
setDraft(r.available ? r.lines.join("\n") : null);
|
||||
refreshGit();
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -1105,6 +1241,9 @@ export function LaneChangesPanel({
|
||||
{t("changes.unstage")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onSelect={pick(fileCtx.close, () => toggleNeverStage(menuFile.path))}>
|
||||
{t(neverStage.has(menuFile.path) ? "changes.allowStage" : "changes.neverStage")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={busy}
|
||||
@@ -1303,7 +1442,9 @@ export function LaneChangesPanel({
|
||||
// What "Stage all" sends: every listed file with a change the index lacks.
|
||||
// Never a conflicted one — staging it would mark it resolved.
|
||||
const toStage =
|
||||
range === "work" ? files.filter((f) => (f.untracked || f.unstaged) && !f.conflicted) : [];
|
||||
range === "work"
|
||||
? files.filter((f) => (f.untracked || f.unstaged) && !f.conflicted && !neverStage.has(f.path))
|
||||
: [];
|
||||
const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined;
|
||||
|
||||
// The armed path left the list (committed, reverted, lane switched): disarm,
|
||||
@@ -1314,11 +1455,17 @@ export function LaneChangesPanel({
|
||||
}
|
||||
}, [data, files, confirmDiscard]);
|
||||
|
||||
// A tree or quick-open pick opens the whole file — and, with an edit open,
|
||||
// would drop it, so a dirty draft asks first.
|
||||
// A tree or quick-open pick opens a changed file on its Changes like any
|
||||
// other, and an unchanged one — no diff to show — as the whole file, in the
|
||||
// editor. With an edit open it would drop it, so a dirty draft asks first.
|
||||
const openWhole = async (p: string) => {
|
||||
if (!(await confirmDrop())) return;
|
||||
showNow(p);
|
||||
// The overview's list, or — with one file open, the list is that file —
|
||||
// the working-copy facts' (capped) list of changed paths.
|
||||
const changed = (git?.available ? git.files : []).map((f) => f.path);
|
||||
if ([...files.map((f) => f.path), ...changed].includes(p)) return;
|
||||
editWanted.current = editableFile(range, p);
|
||||
setWhole(true);
|
||||
};
|
||||
|
||||
@@ -1384,6 +1531,7 @@ export function LaneChangesPanel({
|
||||
onOpenFile={(p) => void openWhole(p)}
|
||||
onMenu={treeCtx.open}
|
||||
active={full && mode === "file" ? openFile : null}
|
||||
kept={neverStage}
|
||||
/>
|
||||
</ul>
|
||||
)}
|
||||
@@ -1531,7 +1679,7 @@ export function LaneChangesPanel({
|
||||
file to get the tight view back. */}
|
||||
<IconButton
|
||||
label={t("changes.collapseContext")}
|
||||
disabled={whole || context <= CONTEXT_STEPS[0]}
|
||||
disabled={whole || draft !== null || context <= CONTEXT_STEPS[0]}
|
||||
onClick={() =>
|
||||
setContext(
|
||||
[...CONTEXT_STEPS].reverse().find((step) => step < context) ??
|
||||
@@ -1543,7 +1691,7 @@ export function LaneChangesPanel({
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={t("changes.expandContext")}
|
||||
disabled={whole || context >= LAST_CONTEXT}
|
||||
disabled={whole || draft !== null || context >= LAST_CONTEXT}
|
||||
onClick={() =>
|
||||
setContext(CONTEXT_STEPS.find((s) => s > context) ?? context)
|
||||
}
|
||||
@@ -1556,17 +1704,35 @@ export function LaneChangesPanel({
|
||||
)}
|
||||
|
||||
{mode === "file" && (
|
||||
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
|
||||
{([false, true] as const).map((w) => (
|
||||
<button
|
||||
key={String(w)}
|
||||
type="button"
|
||||
aria-pressed={whole}
|
||||
disabled={dirty}
|
||||
onClick={() => setWhole(!whole)}
|
||||
className={`rounded border border-border-light px-1.5 py-0.5 text-[11px] ${
|
||||
whole ? "bg-surface-4 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"
|
||||
aria-pressed={whole === w}
|
||||
onClick={() => {
|
||||
if (whole === w) return;
|
||||
// Both views of a working file are the same editor: the
|
||||
// draft goes along, unsaved or not.
|
||||
if (editable && draft !== null) return setWhole(w);
|
||||
// Back to Changes drops an open edit, so unsaved text asks first.
|
||||
void confirmDrop().then((ok) => {
|
||||
if (!ok) return;
|
||||
if (w) editWanted.current = editableFile(range, openFile ?? "", current);
|
||||
else setDraft(null);
|
||||
setWhole(w);
|
||||
});
|
||||
}}
|
||||
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
|
||||
whole === w
|
||||
? "bg-surface-4 text-fg-primary"
|
||||
: "text-fg-secondary hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{t("changes.wholeFile")}
|
||||
{t(w ? "changes.wholeFile" : "changes.changesView")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1622,15 +1788,21 @@ export function LaneChangesPanel({
|
||||
}`}
|
||||
>
|
||||
{mode === "history" ? (
|
||||
<ul data-testid="lane-changes-history" className="space-y-0.5">
|
||||
{(commits ?? []).map((c) => (
|
||||
<ul data-testid="lane-changes-history">
|
||||
{(() => {
|
||||
const list = commits ?? [];
|
||||
const graph = commitGraph(list);
|
||||
return list.map((c, i) => (
|
||||
<li key={c.sha}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => viewCommit(c.sha)}
|
||||
onContextMenu={(e) => commitCtx.open(e, c)}
|
||||
className="w-full rounded px-1 py-0.5 text-left hover:bg-surface-2"
|
||||
style={{ height: GRAPH_ROW }}
|
||||
className="flex w-full items-center gap-1.5 rounded px-1 text-left hover:bg-surface-2"
|
||||
>
|
||||
<GraphCell row={graph.rows[i]!} width={graph.width} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className="shrink-0 font-mono text-[10px] text-fg-muted">
|
||||
{c.short}
|
||||
@@ -1643,9 +1815,11 @@ export function LaneChangesPanel({
|
||||
<span className="truncate">{c.author}</span>
|
||||
<span>{new Date(c.date).toLocaleString()}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
));
|
||||
})()}
|
||||
{commits === null && (
|
||||
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
|
||||
)}
|
||||
@@ -1831,6 +2005,8 @@ export function LaneChangesPanel({
|
||||
onArm={setConfirmDiscard}
|
||||
onResolve={resolve}
|
||||
onMenu={fileCtx.open}
|
||||
kept={neverStage.has(f.path)}
|
||||
onKeep={toggleNeverStage}
|
||||
/>
|
||||
)}
|
||||
folderAction={
|
||||
@@ -1846,7 +2022,11 @@ export function LaneChangesPanel({
|
||||
onClick={() =>
|
||||
void act(
|
||||
g.key === "staged" ? "unstage" : "stage",
|
||||
rows.map((f) => f.path)
|
||||
rows
|
||||
.filter(
|
||||
(f) => g.key === "staged" || !neverStage.has(f.path)
|
||||
)
|
||||
.map((f) => f.path)
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -1898,7 +2078,7 @@ export function LaneChangesPanel({
|
||||
binary={current.binary}
|
||||
/>
|
||||
)}
|
||||
{whole && text && draft === null && isMarkdown(openFile) && (
|
||||
{whole && text && !dirty && isMarkdown(openFile) && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-file-preview"
|
||||
@@ -1911,9 +2091,8 @@ export function LaneChangesPanel({
|
||||
</button>
|
||||
)}
|
||||
{/* Only the working file is editable: a commit is history. */}
|
||||
{/* The whole file enters the editor by a click on its text;
|
||||
only the diff, which has no whole text to click, keeps
|
||||
an Edit button. */}
|
||||
{/* A working file opens in the editor; the read-only diff
|
||||
(Whole file off) keeps an Edit button back into it. */}
|
||||
{range !== "commit" &&
|
||||
!openImage &&
|
||||
(draft === null && whole && text && !text.sha ? (
|
||||
@@ -1923,14 +2102,7 @@ export function LaneChangesPanel({
|
||||
>
|
||||
{t(noEditReason(text))}
|
||||
</span>
|
||||
) : draft === null && whole ? (
|
||||
text &&
|
||||
!mdPreview && (
|
||||
<span className="shrink-0 self-center text-[10px] text-fg-muted">
|
||||
{t("changes.clickToEdit")}
|
||||
</span>
|
||||
)
|
||||
) : draft === null ? (
|
||||
) : draft === null && whole ? null : draft === null ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-file-edit"
|
||||
@@ -1959,8 +2131,13 @@ export function LaneChangesPanel({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void confirmDrop().then((ok) => ok && setDraft(null))}
|
||||
data-testid="lane-file-cancel"
|
||||
disabled={busy || !dirty}
|
||||
onClick={() =>
|
||||
void confirmDrop().then(
|
||||
(ok) => ok && setDraft(text ? text.lines.join("\n") : null)
|
||||
)
|
||||
}
|
||||
className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
|
||||
>
|
||||
{t("changes.cancelEdit")}
|
||||
@@ -1977,23 +2154,32 @@ export function LaneChangesPanel({
|
||||
range === "commit" && sha ? sha : "work"
|
||||
)}
|
||||
/>
|
||||
) : whole && draft !== null ? (
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
data-testid="lane-file-editor"
|
||||
aria-label={t("changes.editorLabel", { path: openFile })}
|
||||
) : !mdPreview &&
|
||||
(whole || editable) &&
|
||||
(draft !== null || (editable && text === null)) ? (
|
||||
// The editor, once both texts are in: not the diff first.
|
||||
draft === null || (baseText === null && range !== "commit") ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||||
) : (
|
||||
<Suspense
|
||||
fallback={<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>}
|
||||
>
|
||||
<DiffEditor
|
||||
key={`${openFile}:${layout}:${whole}`}
|
||||
layout={layout}
|
||||
collapse={!whole}
|
||||
revertLabel={t("changes.revertBlock")}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
original={baseText ?? draft}
|
||||
lang={langFor(openFile ?? "")}
|
||||
label={t("changes.editorLabel", { path: openFile })}
|
||||
onChange={setDraft}
|
||||
onSave={() => {
|
||||
if (!busy) void saveDraft();
|
||||
}
|
||||
}}
|
||||
spellCheck={false}
|
||||
wrap="off"
|
||||
className="min-h-0 flex-1 resize-none rounded border border-border-light bg-surface-0 p-2 font-mono text-[11px] leading-[1.45] text-fg-primary focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
) : whole ? (
|
||||
text && mdPreview ? (
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0 p-4">
|
||||
@@ -2005,7 +2191,6 @@ export function LaneChangesPanel({
|
||||
lines={text.lines}
|
||||
truncated={text.truncated}
|
||||
fill
|
||||
onEditAt={range !== "commit" && text.sha && !busy ? startEdit : undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||||
|
||||
@@ -76,6 +76,7 @@ export const WINDOW_THRESHOLD = 500;
|
||||
export const ROW_PX = 16;
|
||||
const OVERSCAN = 20;
|
||||
const FIXED: CSSProperties = { height: ROW_PX };
|
||||
const MIN_ROW: CSSProperties = { minHeight: ROW_PX };
|
||||
|
||||
/**
|
||||
* The `[start, end)` item range to render for a scroll position. `scrollTop`
|
||||
@@ -165,7 +166,7 @@ function Windowed({
|
||||
}
|
||||
|
||||
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */
|
||||
function langFor(filePath: string) {
|
||||
export function langFor(filePath: string) {
|
||||
const ext = filePath.split(".").pop() || "";
|
||||
return canonicalLang(ext === filePath ? "plain" : ext);
|
||||
}
|
||||
@@ -177,7 +178,7 @@ function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number
|
||||
return (
|
||||
<>
|
||||
<Code text={text.slice(0, from)} lang={lang} />
|
||||
<span className="rounded-sm bg-fg-primary/15">
|
||||
<span className="rounded-sm bg-diff-mod/40">
|
||||
<Code text={text.slice(from, to)} lang={lang} />
|
||||
</span>
|
||||
<Code text={text.slice(to)} lang={lang} />
|
||||
@@ -209,9 +210,10 @@ function emphasis(
|
||||
while (start < max && del[start] === add[start]) start += 1;
|
||||
let end = 0;
|
||||
while (end < max - start && del[del.length - 1 - end] === add[add.length - 1 - end]) end += 1;
|
||||
// Nothing in common at either edge: the whole line is the change, and
|
||||
// highlighting all of it adds noise on top of the row tint.
|
||||
if (start === 0 && end === 0) return null;
|
||||
// No letter or digit in common at either edge (blanks, a `;`, a `}` do not
|
||||
// count): the whole line is the change — a removed line and an added one,
|
||||
// not an edit — and highlighting all of it adds noise on top of the row tint.
|
||||
if (!/\w/.test(del.slice(0, start) + del.slice(del.length - end))) return null;
|
||||
return {
|
||||
del: [start, del.length - end],
|
||||
add: [start, add.length - end],
|
||||
@@ -294,7 +296,8 @@ function Gutter({ n, commentable = false }: { n: number | null; commentable?: bo
|
||||
}
|
||||
|
||||
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row.
|
||||
* `style` pins a windowed row's height; unwindowed rows carry none. */
|
||||
* `style` pins a windowed row's height; an unwindowed row is at least one
|
||||
* line tall, so a split view's empty filler row keeps its column in step. */
|
||||
function Row({
|
||||
tint,
|
||||
style,
|
||||
@@ -310,7 +313,7 @@ function Row({
|
||||
return (
|
||||
<div
|
||||
className={`flex w-max min-w-full ${tint}`}
|
||||
style={style}
|
||||
style={style ?? MIN_ROW}
|
||||
data-diff-item={style ? "" : undefined}
|
||||
data-change-index={anchor}
|
||||
>
|
||||
@@ -320,15 +323,19 @@ function Row({
|
||||
}
|
||||
|
||||
/** A /20 fill plus a 3px status bar at the row's left edge, so +/- rows still
|
||||
* separate from context at a glance on every theme (the /10 fill alone did not). */
|
||||
function rowTint(type: LaneDiffLine["type"] | null) {
|
||||
* separate from context at a glance on every theme (the /10 fill alone did not).
|
||||
* Green/red mark a line added or removed whole; blue (`mod`) a line edited in
|
||||
* place — a -/+ pair that keeps part of its text, its edit emphasised. */
|
||||
function rowTint(type: LaneDiffLine["type"] | null, mod = false) {
|
||||
if (mod && type !== "ctx") return "bg-diff-mod/20 shadow-[inset_3px_0_0_rgb(var(--diff-mod))]";
|
||||
if (type === "add")
|
||||
return "bg-status-success/20 shadow-[inset_3px_0_0_rgb(var(--status-success))]";
|
||||
if (type === "del") return "bg-status-danger/20 shadow-[inset_3px_0_0_rgb(var(--status-danger))]";
|
||||
return "";
|
||||
}
|
||||
|
||||
function markerClass(type: LaneDiffLine["type"]) {
|
||||
function markerClass(type: LaneDiffLine["type"], mod = false) {
|
||||
if (mod && type !== "ctx") return "font-bold text-diff-mod";
|
||||
if (type === "add") return "font-bold text-status-success";
|
||||
if (type === "del") return "font-bold text-status-danger";
|
||||
return "text-fg-muted";
|
||||
@@ -485,11 +492,11 @@ function UnifiedLine({
|
||||
}) {
|
||||
const anchor = useAnchor(start);
|
||||
return (
|
||||
<Row tint={rowTint(line.type)} style={style} anchor={anchor}>
|
||||
<Row tint={rowTint(line.type, !!emph)} style={style} anchor={anchor}>
|
||||
<Gutter n={line.oldLine} />
|
||||
<Gutter n={line.newLine} commentable />
|
||||
<span className="whitespace-pre px-2">
|
||||
<span className={`select-none ${markerClass(line.type)}`}>
|
||||
<span className={`select-none ${markerClass(line.type, !!emph)}`}>
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph} />
|
||||
@@ -558,7 +565,7 @@ function SplitLine({
|
||||
const line = side === "left" ? row.left : row.right;
|
||||
const anchor = useAnchor(start);
|
||||
return (
|
||||
<Row tint={rowTint(line?.type ?? null)} style={style} anchor={anchor}>
|
||||
<Row tint={rowTint(line?.type ?? null, !!row.emph)} style={style} anchor={anchor}>
|
||||
<Gutter
|
||||
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
|
||||
commentable={side === "right"}
|
||||
@@ -941,16 +948,12 @@ export function LaneFileView({
|
||||
lines,
|
||||
truncated,
|
||||
fill = false,
|
||||
onEditAt,
|
||||
}: {
|
||||
path: string;
|
||||
lines: string[];
|
||||
truncated?: boolean;
|
||||
/** Take the parent's height instead of the 60vh cap. */
|
||||
fill?: boolean;
|
||||
/** Set when the file is editable: a click on the text (0-based line, and
|
||||
* where the view was scrolled) asks to open the editor there. */
|
||||
onEditAt?: (line: number, scrollTop: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const lang = langFor(path);
|
||||
@@ -959,40 +962,17 @@ export function LaneFileView({
|
||||
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
|
||||
const row = (text: string, i: number, style?: CSSProperties) => (
|
||||
<Row key={i} tint="" style={style}>
|
||||
{/* `contents`: a line marker for the click handler, not a layout box. */}
|
||||
<span data-ln={i} className="contents">
|
||||
<Gutter n={i + 1} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<Code text={text} lang={lang} />
|
||||
</span>
|
||||
</span>
|
||||
</Row>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-testid="lane-file-view"
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
tabIndex={onEditAt ? 0 : undefined}
|
||||
onClick={
|
||||
onEditAt &&
|
||||
((e) => {
|
||||
// A click that ends a drag-selection is copying, not asking to edit.
|
||||
if (window.getSelection()?.toString()) return;
|
||||
const ln = (e.target as HTMLElement).closest("[data-ln]")?.getAttribute("data-ln");
|
||||
onEditAt(Number(ln ?? 0), e.currentTarget.scrollTop);
|
||||
})
|
||||
}
|
||||
onKeyDown={
|
||||
onEditAt &&
|
||||
((e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
onEditAt(0, e.currentTarget.scrollTop);
|
||||
})
|
||||
}
|
||||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}${
|
||||
onEditAt ? " cursor-text" : ""
|
||||
}`}
|
||||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
|
||||
>
|
||||
{win.on ? (
|
||||
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* @file The top of the Changes panel: the lane's working-copy line (branch,
|
||||
* head, subject, and the uncommitted counts) and the git action bar under it
|
||||
* (`LaneGitBar`); plus the same counts drawn vertical on the CLOSED Changes
|
||||
* rail (`LaneGitRailCounts`), where hovering them opens the per-file panel —
|
||||
* the open panel lists those files itself, so its header chip opens nothing.
|
||||
* head, subject) and the git action bar under it (`LaneGitBar`); plus the
|
||||
* uncommitted counts drawn vertical on the CLOSED Changes rail
|
||||
* (`LaneGitRailCounts`), where hovering them opens the per-file panel — headed
|
||||
* by the real total, listing the server's first 20, its "… more files" opening
|
||||
* the Changes panel — the open panel lists those files itself, so its header
|
||||
* shows no counts.
|
||||
*
|
||||
* It used to sit in the lane card, a column away from the diff it describes —
|
||||
* a pull there changed files the Changes panel only noticed on its next poll.
|
||||
@@ -102,6 +104,7 @@ const GIT_STATUS_STYLE: Record<string, string> = {
|
||||
function LaneGitFilesPanel({
|
||||
files,
|
||||
hidden,
|
||||
onShowAll,
|
||||
anchor,
|
||||
side = "below",
|
||||
onPointerEnter,
|
||||
@@ -109,6 +112,8 @@ function LaneGitFilesPanel({
|
||||
}: {
|
||||
files: LaneGitFile[];
|
||||
hidden: number;
|
||||
/** Opens the Changes panel, which lists every file the cap left out. */
|
||||
onShowAll?: () => void;
|
||||
/** The counts button this panel hangs off. */
|
||||
anchor: HTMLElement | null;
|
||||
/** `left` beside a chip on the right-edge rail, where below would cover it. */
|
||||
@@ -146,13 +151,14 @@ function LaneGitFilesPanel({
|
||||
// partial one. `hidden` says how many are missing.
|
||||
const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
|
||||
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
|
||||
const total = files.length + hidden;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
data-testid="lane-git-files"
|
||||
role="group"
|
||||
aria-label={t("git.filesHeading", { count: files.length })}
|
||||
aria-label={t("git.filesHeading", { count: total })}
|
||||
// The pointer has to be able to travel INTO the panel to scroll it, so
|
||||
// the panel answers the same enter/leave pair the trigger does.
|
||||
onMouseEnter={onPointerEnter}
|
||||
@@ -169,7 +175,7 @@ function LaneGitFilesPanel({
|
||||
className="max-h-72 w-[22rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-md border border-border bg-surface-1 p-2 shadow-lg"
|
||||
>
|
||||
<div className="mb-1.5 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px]">
|
||||
<span className="text-fg-secondary">{t("git.filesHeading", { count: files.length })}</span>
|
||||
<span className="text-fg-secondary">{t("git.filesHeading", { count: total })}</span>
|
||||
<span className="ml-auto shrink-0 font-mono tabular-nums">
|
||||
<span className="text-status-success">+{added}</span>{" "}
|
||||
<span className="text-status-danger">−{deleted}</span>
|
||||
@@ -206,30 +212,40 @@ function LaneGitFilesPanel({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{hidden > 0 && (
|
||||
{hidden > 0 &&
|
||||
(onShowAll ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-git-files-more"
|
||||
onClick={onShowAll}
|
||||
className="mt-1.5 w-full border-t border-border pt-1.5 text-left text-[10px] text-accent hover:underline"
|
||||
>
|
||||
{t("git.filesMore", { count: hidden })}
|
||||
</button>
|
||||
) : (
|
||||
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
|
||||
{t("git.filesMore", { count: hidden })}
|
||||
</p>
|
||||
)}
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The uncommitted counts as a chip. With `details` it opens the per-file panel
|
||||
* on hover, focus or click — the closed Changes rail, `vertical`, where nothing
|
||||
* else lists the files. In the open panel the list below already does, so the
|
||||
* header shows the chip alone.
|
||||
* The uncommitted counts as a chip that opens the per-file panel on hover,
|
||||
* focus or click — the closed Changes rail, `vertical`, where nothing else
|
||||
* lists the files.
|
||||
*/
|
||||
export function LaneGitCounts({
|
||||
git,
|
||||
vertical = false,
|
||||
details = true,
|
||||
onShowAll,
|
||||
}: {
|
||||
git: Extract<LaneGitFacts, { available: true }>;
|
||||
vertical?: boolean;
|
||||
details?: boolean;
|
||||
/** See `LaneGitFilesPanel`. */
|
||||
onShowAll?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
@@ -253,13 +269,6 @@ export function LaneGitCounts({
|
||||
const chip = `shrink-0 rounded-full border border-status-warning/40 bg-status-warning/10 text-[10.5px] text-status-warning ${
|
||||
vertical ? "px-px py-2 [writing-mode:vertical-rl]" : "px-2 py-px"
|
||||
}`;
|
||||
if (!details) {
|
||||
return (
|
||||
<span data-testid="lane-git-counts" className={chip}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@@ -288,6 +297,13 @@ export function LaneGitCounts({
|
||||
<LaneGitFilesPanel
|
||||
files={git.files}
|
||||
hidden={git.dirty + git.untracked - git.files.length}
|
||||
onShowAll={
|
||||
onShowAll &&
|
||||
(() => {
|
||||
setFilesOpen(false);
|
||||
onShowAll();
|
||||
})
|
||||
}
|
||||
anchor={anchor}
|
||||
side={vertical ? "left" : "below"}
|
||||
onPointerEnter={openFiles}
|
||||
@@ -300,9 +316,18 @@ export function LaneGitCounts({
|
||||
|
||||
/** The counts on the closed Changes rail: its own facts read, polled only
|
||||
* while the rail is shown (the open panel owns the read otherwise). */
|
||||
export function LaneGitRailCounts({ laneId, session }: { laneId: number; session?: string }) {
|
||||
export function LaneGitRailCounts({
|
||||
laneId,
|
||||
session,
|
||||
onShowAll,
|
||||
}: {
|
||||
laneId: number;
|
||||
session?: string;
|
||||
/** Opens the Changes panel from the file list's "… more files". */
|
||||
onShowAll?: () => void;
|
||||
}) {
|
||||
const [git] = useLaneGitFacts(laneId, session);
|
||||
return git?.available ? <LaneGitCounts git={git} vertical /> : null;
|
||||
return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null;
|
||||
}
|
||||
|
||||
export function LaneGitHeader({
|
||||
@@ -339,7 +364,6 @@ export function LaneGitHeader({
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
|
||||
</div>
|
||||
<LaneGitCounts git={git} details={false} />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
|
||||
@@ -160,7 +160,7 @@ export function WorkspaceRow({
|
||||
>
|
||||
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
||||
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
|
||||
<span className="truncate text-[13px] text-fg-primary">{name}</span>
|
||||
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
|
||||
</button>
|
||||
)}
|
||||
{!renaming && (
|
||||
|
||||
@@ -437,7 +437,7 @@ export function WorkspaceSessionList({
|
||||
if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<p className="py-1 pl-8 text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
||||
<p className="py-1 pl-[42px] text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
||||
{failure}
|
||||
{confirmArchive}
|
||||
</>
|
||||
@@ -445,7 +445,11 @@ export function WorkspaceSessionList({
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ul data-testid={`workspace-sessions-${lane.id}`} className="space-y-px">
|
||||
<ul
|
||||
data-testid={`workspace-sessions-${lane.id}`}
|
||||
// A guide line under the workspace's chevron; the rows start past it.
|
||||
className="relative space-y-px pl-5 before:pointer-events-none before:absolute before:inset-y-0 before:left-[13px] before:w-px before:bg-border before:content-['']"
|
||||
>
|
||||
{fresh.map((r) => (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
@@ -460,7 +464,7 @@ export function WorkspaceSessionList({
|
||||
}
|
||||
onContextMenu={(e) => ctx.open(e, r)}
|
||||
title={`${r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}\n${t("tree.splitHint")}`}
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
|
||||
r.id === selectedRunId
|
||||
? "bg-accent/10 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
@@ -472,7 +476,7 @@ export function WorkspaceSessionList({
|
||||
{r.tool === "shell" ? t("tabs.terminal") : t("tree.newSession")}
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-[11px] text-fg-muted">
|
||||
{r.startedAt ? timeAgo(r.startedAt) : null}
|
||||
{r.startedAt ? timeAgo(r.startedAt, true) : null}
|
||||
</span>
|
||||
{r.tool === "shell" ? (
|
||||
<SquareTerminal aria-hidden className="h-3.5 w-3.5 shrink-0 opacity-70" />
|
||||
@@ -490,7 +494,7 @@ export function WorkspaceSessionList({
|
||||
const selected = run ? run.id === selectedRunId : s.id === selectedSessionId;
|
||||
if (renaming === s.id) {
|
||||
return (
|
||||
<li key={s.id} className="py-0.5 pl-7 pr-2">
|
||||
<li key={s.id} className="py-0.5 pr-2">
|
||||
<input
|
||||
data-testid={`workspace-session-rename-${s.id}`}
|
||||
aria-label={t("tree.renameSession")}
|
||||
@@ -544,7 +548,7 @@ export function WorkspaceSessionList({
|
||||
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
|
||||
: ""
|
||||
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
|
||||
selected
|
||||
? "bg-accent/10 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
@@ -564,12 +568,12 @@ export function WorkspaceSessionList({
|
||||
className="h-3 w-3 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 text-[11px] text-fg-muted">
|
||||
{timeAgo(s.last_activity ?? s.started_at)}
|
||||
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
|
||||
{timeAgo(s.last_activity ?? s.started_at, true)}
|
||||
</span>
|
||||
<AiToolIcon
|
||||
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
|
||||
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-70 group-hover:invisible"
|
||||
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-60 group-hover:invisible"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
@@ -595,7 +599,7 @@ export function WorkspaceSessionList({
|
||||
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
|
||||
onContextMenu={(e) => closedCtx.open(e, r)}
|
||||
title={t("tree.closedTerminal")}
|
||||
className="flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
className="flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
@@ -604,7 +608,7 @@ export function WorkspaceSessionList({
|
||||
<span className="sr-only">{t("tree.state.idle")}</span>
|
||||
<span className="truncate">{t("tabs.terminal")}</span>
|
||||
<span className="ml-auto shrink-0 text-[11px]">
|
||||
{timeAgo(r.ended_at ?? r.started_at)}
|
||||
{timeAgo(r.ended_at ?? r.started_at, true)}
|
||||
</span>
|
||||
<SquareTerminal
|
||||
aria-hidden
|
||||
@@ -629,7 +633,7 @@ export function WorkspaceSessionList({
|
||||
type="button"
|
||||
data-testid={`workspace-sessions-more-${lane.id}`}
|
||||
onClick={() => own && setLimit(own.total)}
|
||||
className="w-full py-1 pl-8 pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
|
||||
className="w-full py-1 pl-[42px] pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
{t("tree.seeAll", { count: more })}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @file Tests for the Changes panel's CodeMirror editor: which changed lines
|
||||
* count as edited in place (blue) rather than added or removed whole, and the
|
||||
* editor's own diff classes on a real mount.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import DiffEditor, { keepsText } from "../DiffEditor";
|
||||
|
||||
// jsdom lays nothing out; CodeMirror's measuring asks a Range for its boxes.
|
||||
const EMPTY = Object.assign([], { item: () => null }) as unknown as DOMRectList;
|
||||
Range.prototype.getClientRects ??= () => EMPTY;
|
||||
Range.prototype.getBoundingClientRect ??= () => new DOMRect();
|
||||
|
||||
describe("keepsText", () => {
|
||||
it("is false for a line whose every non-blank character changed", () => {
|
||||
expect(keepsText(" added()", 10, [[12, 19]])).toBe(false);
|
||||
expect(keepsText("", 0, [])).toBe(false);
|
||||
// Only a `;` survived: not the same line.
|
||||
expect(keepsText("export x;", 0, [[0, 8]])).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when some text outside the changes survives", () => {
|
||||
expect(keepsText("const x = 2;", 0, [[10, 11]])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiffEditor", () => {
|
||||
it("marks a whole new line green and a line edited in place blue", async () => {
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
value={"keep\nconst x = 2;\nbrand new"}
|
||||
original={"keep\nconst x = 1;"}
|
||||
lang="ts"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(container.querySelectorAll(".cm-changedLine")).toHaveLength(2));
|
||||
const lines = [...container.querySelectorAll<HTMLElement>(".cm-changedLine")];
|
||||
expect(lines.map((l) => l.classList.contains("cm-ah-mod"))).toEqual([true, false]);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector(".cm-deletedLine")).toHaveAttribute("data-ah", "mod")
|
||||
);
|
||||
});
|
||||
|
||||
it("side by side: the original read-only on the left, its edited line blue", async () => {
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
layout="split"
|
||||
value={"keep\nconst x = 2;"}
|
||||
original={"keep\nconst x = 1;"}
|
||||
lang="ts"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(container.querySelectorAll(".cm-editor")).toHaveLength(2));
|
||||
const [left, right] = [...container.querySelectorAll<HTMLElement>(".cm-content")];
|
||||
expect(left).toHaveAttribute("contenteditable", "false");
|
||||
expect(right).toHaveAttribute("contenteditable", "true");
|
||||
expect(container.querySelector(".cm-merge-a .cm-changedLine.cm-ah-mod")).not.toBeNull();
|
||||
expect(container.querySelector(".cm-merge-b .cm-changedLine.cm-ah-mod")).not.toBeNull();
|
||||
expect(container.querySelector(".cm-merge-revert button")).toHaveAttribute("title", "Revert");
|
||||
});
|
||||
|
||||
it("folds unchanged lines away when collapsed, and offers Revert on a block", async () => {
|
||||
const same = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
collapse
|
||||
value={`${same}\nchanged`}
|
||||
original={`${same}\noriginal`}
|
||||
lang="plain"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => expect(container.querySelector(".cm-collapsedLines")).not.toBeNull());
|
||||
const buttons = [...container.querySelectorAll<HTMLButtonElement>(".cm-chunkButtons button")];
|
||||
expect(buttons.filter((b) => !b.hidden).map((b) => b.textContent)).toEqual(["Revert"]);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,36 @@ import { api } from "../../../lib/api";
|
||||
import type { LaneChanges } from "../../../lib/types";
|
||||
|
||||
// The themed confirm needs its host (App); answer it here.
|
||||
// CodeMirror does not run in jsdom: the editor is a textarea over the same props.
|
||||
vi.mock("../DiffEditor", () => ({
|
||||
default: (p: {
|
||||
value: string;
|
||||
original: string;
|
||||
layout?: string;
|
||||
collapse?: boolean;
|
||||
label: string;
|
||||
onChange: (v: string) => void;
|
||||
onSave: () => void;
|
||||
}) => (
|
||||
<textarea
|
||||
data-testid="lane-file-editor"
|
||||
data-original={p.original}
|
||||
data-layout={p.layout}
|
||||
data-collapse={String(!!p.collapse)}
|
||||
aria-label={p.label}
|
||||
autoFocus
|
||||
value={p.value}
|
||||
onChange={(e) => p.onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
p.onSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({
|
||||
confirmDiscard: vi.fn(async () => true),
|
||||
confirmDialog: vi.fn(async () => true),
|
||||
@@ -90,6 +120,11 @@ function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}):
|
||||
};
|
||||
}
|
||||
|
||||
/** A changed file opens in the editor; Whole file toggles to its read-only diff. */
|
||||
async function toDiff(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(await screen.findByRole("button", { name: "Changes", pressed: true }));
|
||||
}
|
||||
|
||||
/** The overview row's discard button for one path. */
|
||||
function discardOf(path: string) {
|
||||
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
|
||||
@@ -98,6 +133,7 @@ function discardOf(path: string) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.removeItem("agenthub.changes.neverStage.7");
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.changes).mockReset();
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||
@@ -113,6 +149,7 @@ beforeEach(() => {
|
||||
author: "t",
|
||||
date: "2026-09-22T01:00:00Z",
|
||||
refs: ["HEAD -> feat/thing"],
|
||||
parents: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -192,6 +229,7 @@ describe("LaneChangesPanel", () => {
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
await toDiff(user);
|
||||
// The highlighter splits a line into one span per token, so assert on the
|
||||
// rendered text of the whole view rather than on a single node.
|
||||
const view = await screen.findByTestId("lane-diff-view");
|
||||
@@ -247,6 +285,45 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a never-stage file out of Stage all, without telling git", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
const [keep] = screen.getAllByRole("button", { name: /Never stage/ });
|
||||
await user.click(keep!); // the first row: server/lib/git-ops.js
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([
|
||||
"server/lib/git-ops.js",
|
||||
]);
|
||||
await user.click(screen.getByRole("button", { name: "Stage all (1)" }));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["notes.txt"] });
|
||||
// And back: the row's button now offers the way out.
|
||||
await user.click(screen.getByRole("button", { name: "Include in Stage all again" }));
|
||||
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([]);
|
||||
});
|
||||
|
||||
it("draws a never-stage file orange in the Files tree", async () => {
|
||||
window.localStorage.setItem("agenthub.changes.neverStage.7", JSON.stringify(["notes.txt"]));
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [
|
||||
{ name: "notes.txt", path: "notes.txt", dir: false, status: "??" },
|
||||
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<Tabbed laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
|
||||
expect(await row("notes.txt")).toHaveAttribute("data-tree-kept", "true");
|
||||
expect(await row("notes.txt")).toHaveClass("text-kept");
|
||||
expect(await row("a.txt")).not.toHaveAttribute("data-tree-kept");
|
||||
expect(await row("a.txt")).toHaveClass("text-status-warning");
|
||||
});
|
||||
|
||||
it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
@@ -393,12 +470,12 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.click(await within(sidebar).findByText("README.md"));
|
||||
await screen.findByTestId("lane-file-view");
|
||||
await screen.findByTestId("lane-file-editor");
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-file-view")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
await user.click(screen.getByTestId("lane-file-preview"));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("# Hello");
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("# Hello\n\n- one");
|
||||
});
|
||||
|
||||
it("shows an image file as the picture, with no edit control", async () => {
|
||||
@@ -427,7 +504,7 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
});
|
||||
|
||||
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => {
|
||||
it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
@@ -452,29 +529,29 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
|
||||
await user.click(await within(sidebar).findByText("main.ts"));
|
||||
|
||||
// Opening a file only shows it: no editor until the text itself is clicked.
|
||||
const view = await screen.findByTestId("lane-file-view");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
await user.click(view);
|
||||
// Opening a file is editing it, diffed against its HEAD text.
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("const whole = 1;");
|
||||
expect(editor).toHaveFocus();
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(7, "src/main.ts", "HEAD");
|
||||
expect(editor).toHaveAttribute("data-original", "const whole = 1;");
|
||||
expect(screen.queryByTestId("lane-file-view")).toBeNull();
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
expect(screen.getByTestId("lane-file-cancel")).toBeDisabled();
|
||||
await user.type(editor, "x");
|
||||
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
||||
// Cancel asks before dropping an edit: "keep" keeps it…
|
||||
vi.mocked(confirmDiscard).mockClear().mockResolvedValueOnce(false);
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await user.click(screen.getByTestId("lane-file-cancel"));
|
||||
expect(confirmDiscard).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 1;x");
|
||||
// …and "discard" drops it and goes back to reading.
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
// …and "discard" reverts it, still editing.
|
||||
await user.click(screen.getByTestId("lane-file-cancel"));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("at full width opens a changed file from the overview for reading, not editing", async () => {
|
||||
it("edits a changed file on its Changes, side by side when wide, and keeps the edit on Whole file", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
@@ -488,31 +565,40 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("a");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
await screen.findByRole("button", { name: "Changes", pressed: true });
|
||||
// Changes is the editor folded to its changes, side by side at full width.
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("a");
|
||||
expect(editor).toHaveAttribute("data-collapse", "true");
|
||||
expect(editor).toHaveAttribute("data-layout", "split");
|
||||
await user.type(editor, "b");
|
||||
// Whole file unfolds the same edit — no prompt, nothing dropped.
|
||||
vi.mocked(confirmDiscard).mockClear();
|
||||
await user.click(screen.getByRole("button", { name: "Whole file", pressed: false }));
|
||||
const whole = await screen.findByTestId("lane-file-editor");
|
||||
expect(whole).toHaveAttribute("data-collapse", "false");
|
||||
expect(whole).toHaveValue("ab");
|
||||
await user.click(screen.getByRole("button", { name: "Changes", pressed: false }));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("ab");
|
||||
expect(confirmDiscard).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
||||
});
|
||||
|
||||
it("does not enter the editor when a click only ends a text selection", async () => {
|
||||
it("opens diffs unified at the narrow width", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["copy me"],
|
||||
lines: ["a"],
|
||||
sha: "b".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
|
||||
const view = await screen.findByTestId("lane-file-view");
|
||||
const spy = vi.spyOn(window, "getSelection").mockReturnValue({
|
||||
toString: () => "copy",
|
||||
} as unknown as Selection);
|
||||
await user.click(view);
|
||||
spy.mockRestore();
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveAttribute("data-layout", "unified");
|
||||
});
|
||||
|
||||
it("needs two clicks to discard, and only the second one reaches the server", async () => {
|
||||
@@ -640,6 +726,7 @@ describe("LaneChangesPanel", () => {
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
expect(await screen.findAllByTestId("lane-changes-graph")).toHaveLength(1);
|
||||
await user.click(await screen.findByText("feat: the commit"));
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -785,7 +872,6 @@ describe("LaneChangesPanel", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await user.click(await screen.findByText("src/main.ts"));
|
||||
await user.click(await screen.findByTestId("lane-file-view"));
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("const whole = 1;");
|
||||
await user.clear(editor);
|
||||
@@ -798,9 +884,9 @@ describe("LaneChangesPanel", () => {
|
||||
"const whole = 2;",
|
||||
"b".repeat(40)
|
||||
);
|
||||
// Back to reading, showing what was saved.
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 2;");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
// Still editing, on what was saved: nothing left to save.
|
||||
await waitFor(() => expect(screen.getByTestId("lane-file-save")).toBeDisabled());
|
||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 2;");
|
||||
});
|
||||
|
||||
it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
|
||||
@@ -844,7 +930,6 @@ describe("LaneChangesPanel", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await user.click(await screen.findByText("src/main.ts"));
|
||||
await user.click(await screen.findByTestId("lane-file-view"));
|
||||
await user.type(await screen.findByTestId("lane-file-editor"), "y");
|
||||
await user.click(screen.getByTestId("lane-file-save"));
|
||||
|
||||
@@ -885,6 +970,7 @@ describe("LaneChangesPanel", () => {
|
||||
})
|
||||
);
|
||||
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||
await toDiff(user);
|
||||
expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Side-by-side" }));
|
||||
@@ -896,6 +982,7 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||
await toDiff(user);
|
||||
await screen.findByRole("button", { name: "Show more surrounding lines" });
|
||||
|
||||
// Collapse is dead at the tightest step, and live once expanded — an
|
||||
@@ -926,7 +1013,7 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
||||
await screen.findByText(/server\/lib\/git-ops\.js/);
|
||||
await toDiff(user);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -59,6 +59,22 @@ describe("LaneDiffView — side by side", () => {
|
||||
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
|
||||
});
|
||||
|
||||
it("keeps an empty filler row one line tall, so the columns stay in step", () => {
|
||||
// Two lines removed, none added: the right column's two rows are fillers.
|
||||
const file = fileWith([
|
||||
{ type: "del", oldLine: 1, newLine: null, text: "gone one" },
|
||||
{ type: "del", oldLine: 2, newLine: null, text: "gone two" },
|
||||
]);
|
||||
render(<LaneDiffView file={file} layout="split" />);
|
||||
const fillers = [
|
||||
...screen
|
||||
.getByTestId("lane-diff-split-right")
|
||||
.querySelectorAll<HTMLElement>("div.flex.w-max"),
|
||||
];
|
||||
expect(fillers).toHaveLength(2);
|
||||
for (const row of fillers) expect(row.style.minHeight).toBe("16px");
|
||||
});
|
||||
|
||||
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
|
||||
// Unwindowed, a review-list diff grows so the PANEL is the one vertical
|
||||
// scroller (nested 60vh boxes made hunk navigation land nowhere); a
|
||||
@@ -180,24 +196,40 @@ function rowOf(container: HTMLElement, needle: string): HTMLElement {
|
||||
}
|
||||
|
||||
describe("LaneDiffView — row styling", () => {
|
||||
it("tints add/del rows with their status fill and left bar; context stays plain", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const add = rowOf(container, "x = 2");
|
||||
const del = rowOf(container, "x = 1");
|
||||
// A line added or removed whole: nothing in common with its pair.
|
||||
const WHOLE = fileWith([
|
||||
{ type: "ctx", oldLine: 1, newLine: 1, text: "keep()" },
|
||||
{ type: "del", oldLine: 2, newLine: null, text: "abc" },
|
||||
{ type: "add", oldLine: null, newLine: 2, text: "xyz" },
|
||||
]);
|
||||
|
||||
it("tints whole-line add/del rows green/red with a left bar; context stays plain", () => {
|
||||
const { container } = render(<LaneDiffView file={WHOLE} layout="unified" />);
|
||||
const add = rowOf(container, "xyz");
|
||||
const del = rowOf(container, "abc");
|
||||
const ctx = rowOf(container, "keep()");
|
||||
expect(add.className).toContain("bg-status-success/20");
|
||||
expect(add.className).toContain("--status-success");
|
||||
expect(del.className).toContain("bg-status-danger/20");
|
||||
expect(del.className).toContain("--status-danger");
|
||||
expect(ctx.className).not.toMatch(/status-(success|danger)/);
|
||||
expect(ctx.className).not.toMatch(/status-(success|danger)|diff-mod/);
|
||||
});
|
||||
|
||||
it("tints a line edited in place blue on both sides", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
for (const text of ["x = 1", "x = 2"]) {
|
||||
const row = rowOf(container, text);
|
||||
expect(row.className).toContain("bg-diff-mod/20");
|
||||
expect(row.className).not.toMatch(/status-(success|danger)/);
|
||||
}
|
||||
});
|
||||
|
||||
it("colours the +/- marker bold in its status colour, context marker muted", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const { container } = render(<LaneDiffView file={WHOLE} layout="unified" />);
|
||||
const marker = (row: HTMLElement) =>
|
||||
row.querySelector<HTMLElement>("span.select-none:not(.w-10)")!;
|
||||
const plus = marker(rowOf(container, "x = 2"));
|
||||
const minus = marker(rowOf(container, "x = 1"));
|
||||
const plus = marker(rowOf(container, "xyz"));
|
||||
const minus = marker(rowOf(container, "abc"));
|
||||
const ctx = marker(rowOf(container, "keep()"));
|
||||
expect(plus.textContent).toBe("+");
|
||||
expect(plus.className).toContain("font-bold");
|
||||
@@ -210,7 +242,7 @@ describe("LaneDiffView — row styling", () => {
|
||||
|
||||
it("emphasises only the changed span of a paired del/add", () => {
|
||||
const { container } = render(<LaneDiffView file={ROWS} layout="unified" />);
|
||||
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-fg-primary\\/15")].map(
|
||||
const emph = [...container.querySelectorAll<HTMLElement>("span.bg-diff-mod\\/40")].map(
|
||||
(e) => e.textContent
|
||||
);
|
||||
expect(emph).toEqual(["1", "2"]);
|
||||
@@ -226,7 +258,7 @@ describe("LaneDiffView — row styling", () => {
|
||||
layout="unified"
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector("span.bg-fg-primary\\/15")).toBeNull();
|
||||
expect(container.querySelector("span.bg-diff-mod\\/40")).toBeNull();
|
||||
});
|
||||
|
||||
it("split view tints each side, and a filler row opposite an unpaired add stays plain", () => {
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("LaneGitHeader", () => {
|
||||
behind: null,
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
it("renders the live branch, head and subject", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
@@ -57,7 +57,6 @@ describe("LaneGitHeader", () => {
|
||||
expect(block.textContent).toContain("feat/rename-metric");
|
||||
expect(block.textContent).toContain("9b3e74a");
|
||||
expect(block.textContent).toContain("free-text rule mode in the form");
|
||||
expect(screen.getByTestId("lane-git-counts").textContent).toContain("2");
|
||||
});
|
||||
|
||||
it("drops the previous lane's facts the moment the lane changes", async () => {
|
||||
@@ -119,16 +118,26 @@ describe("LaneGitHeader", () => {
|
||||
render(<LaneGitRailCounts laneId={1} />);
|
||||
|
||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("40 files changed");
|
||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
||||
});
|
||||
|
||||
it("shows the counts in the open panel's header without the hover list", async () => {
|
||||
it("opens the Changes panel from the file list's “more files”", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
|
||||
const onShowAll = vi.fn();
|
||||
render(<LaneGitRailCounts laneId={1} onShowAll={onShowAll} />);
|
||||
|
||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||
fireEvent.click(screen.getByTestId("lane-git-files-more"));
|
||||
expect(onShowAll).toHaveBeenCalledOnce();
|
||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no counts in the open panel's header: the list below already does", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
fireEvent.mouseEnter(counts);
|
||||
fireEvent.focus(counts);
|
||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||
await screen.findByTestId("lane-git");
|
||||
expect(screen.queryByTestId("lane-git-counts")).toBeNull();
|
||||
});
|
||||
|
||||
it("draws the rail's counts vertical", async () => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* @file AppearanceSettings.tsx
|
||||
* @description Settings → Appearance (`/settings/appearance`): the Dark / Light / System mode switch and
|
||||
* one named-theme picker per mode (state in `hooks/useTheme.ts`, palettes in
|
||||
* `index.css`). Each card's swatch re-scopes the theme's CSS variables on its
|
||||
* @description Settings → Appearance (`/settings/appearance`): the interface language, the
|
||||
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
||||
* `index.css`. Each card's swatch re-scopes the theme's CSS variables on its
|
||||
* own element (`dark` class or `data-mode`, plus `data-theme`), so the preview
|
||||
* is painted by the same tokens as the app — no hex is duplicated here.
|
||||
* **Reset to defaults** (after a confirm) returns to Dark with both stock themes.
|
||||
@@ -11,7 +13,8 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
||||
import { SettingsRow, SettingsSegmented } from "./SettingsRow";
|
||||
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
|
||||
import { PetSettings } from "./PetSettings";
|
||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -61,7 +64,7 @@ function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
||||
}
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { t, i18n } = useTranslation("settings");
|
||||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||||
|
||||
const reset = async () => {
|
||||
@@ -78,7 +81,30 @@ export function AppearanceSettings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card p-5 space-y-5">
|
||||
<div className="space-y-6">
|
||||
<SettingsSection
|
||||
id="language"
|
||||
title={t("language.title")}
|
||||
description={t("language.description")}
|
||||
>
|
||||
<SettingsRow
|
||||
label={t("language.label")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("language.label")}
|
||||
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
|
||||
onChange={(v) => void i18n.changeLanguage(v)}
|
||||
options={[
|
||||
{ value: "en" as const, label: t("language.names.en") },
|
||||
{ value: "vi" as const, label: t("language.names.vi") },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
|
||||
<SettingsRow
|
||||
label={t("appearance.mode")}
|
||||
control={
|
||||
@@ -139,6 +165,15 @@ export function AppearanceSettings() {
|
||||
{t("appearance.reset")}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="pet" className="space-y-3 scroll-mt-24">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.pet")}</h3>
|
||||
<p className="mt-0.5 text-xs text-fg-muted">{t("appearance.petDescription")}</p>
|
||||
</div>
|
||||
<PetSettings />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @file PetSettings — Settings → Pet: the mascot's own page. Show it or not,
|
||||
* @file PetSettings — the pet block of Settings → Appearance (`#pet`). Show it or not,
|
||||
* its speech bubbles, its size (with a live preview), and which pet it wears
|
||||
* from a grid — Sagi, the featured codex-pets.net pets (downloaded into this
|
||||
* install on the first pick, never bundled), and your own (deletable) — plus
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
/**
|
||||
* @file AppearanceSettings.test.tsx
|
||||
* @description Tests for the Settings → Appearance section: the mode switch and the per-mode theme cards write through useTheme to `<html>`.
|
||||
* @description Tests for the Settings → Appearance section: the language switch, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { act, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import i18n from "../../../i18n";
|
||||
import { AppearanceSettings } from "../AppearanceSettings";
|
||||
|
||||
vi.mock("../PetSettings", () => ({ PetSettings: () => <div data-testid="pet-settings" /> }));
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
describe("AppearanceSettings", () => {
|
||||
@@ -19,6 +22,22 @@ describe("AppearanceSettings", () => {
|
||||
root.removeAttribute("data-mode");
|
||||
});
|
||||
|
||||
it("switches the interface language and holds the pet block", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
expect(screen.getByTestId("pet-settings").closest("section")).toHaveAttribute("id", "pet");
|
||||
const group = screen.getByRole("radiogroup", { name: "Interface language" });
|
||||
expect(within(group).getByRole("radio", { name: "English" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true"
|
||||
);
|
||||
await user.click(within(group).getByRole("radio", { name: "Tiếng Việt" }));
|
||||
expect(i18n.language).toBe("vi");
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders every theme of both modes, defaults selected", () => {
|
||||
render(<AppearanceSettings />);
|
||||
const pressed = screen
|
||||
|
||||
@@ -68,7 +68,11 @@
|
||||
"justNow": "just now",
|
||||
"mAgo": "{{count}}m ago",
|
||||
"hAgo": "{{count}}h ago",
|
||||
"dAgo": "{{count}}d ago"
|
||||
"dAgo": "{{count}}d ago",
|
||||
"justNowShort": "now",
|
||||
"mAgoShort": "{{count}}m",
|
||||
"hAgoShort": "{{count}}h",
|
||||
"dAgoShort": "{{count}}d"
|
||||
},
|
||||
"pagination": {
|
||||
"previous": "Previous",
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"changes.commitPlaceholder": "Commit message",
|
||||
"changes.commitStaged": "Commit {{count}} staged",
|
||||
"changes.suggestMessage": "Suggest a commit message with AI (Claude, staged changes)",
|
||||
"changes.neverStage": "Never stage — Stage all skips this file",
|
||||
"changes.allowStage": "Include in Stage all again",
|
||||
"changes.keptHint": "Never staged by Stage all (this browser)",
|
||||
"changes.conflicted": "conflict",
|
||||
"changes.discard": "Discard this file's changes",
|
||||
"changes.discardConfirm": "Click again to discard — this cannot be undone",
|
||||
@@ -113,6 +116,7 @@
|
||||
"changes.truncated": "Some changes were left out to keep this panel responsive.",
|
||||
"changes.unavailable": "This workspace's directory is not a readable git repository.",
|
||||
"changes.unstage": "Unstage this file",
|
||||
"changes.changesView": "Changes",
|
||||
"changes.wholeFile": "Whole file",
|
||||
"confirmRemoveCancel": "Cancel",
|
||||
"confirmRemoveConfirm": "Remove",
|
||||
@@ -239,8 +243,8 @@
|
||||
"changes.undoHunkShortConfirm": "Confirm undo",
|
||||
"changes.edit": "Edit",
|
||||
"changes.save": "Save",
|
||||
"changes.revertBlock": "Revert",
|
||||
"changes.cancelEdit": "Cancel",
|
||||
"changes.clickToEdit": "Click the text to edit",
|
||||
"changes.preview": "Preview",
|
||||
"changes.raw": "Raw",
|
||||
"changes.editorLabel": "Edit {{path}}",
|
||||
|
||||
@@ -67,7 +67,20 @@
|
||||
"today": "Today {{cost}}",
|
||||
"workspace": "Workspace {{cost}}",
|
||||
"session": "Session {{cost}}",
|
||||
"tokens": "({{tokens}} Tokens)"
|
||||
"tokens": "({{tokens}} Tokens)",
|
||||
"quotaUsed": "{{pct}}% used",
|
||||
"quotaTitle": "Usage",
|
||||
"quotaResets": "Resets in {{time}}",
|
||||
"quotaAsOf": "as of {{ago}}",
|
||||
"quotaRefresh": "Refresh",
|
||||
"quotaRefreshing": "Refreshing…",
|
||||
"quotaExpired": "Token expired: use Claude Code once so it refreshes",
|
||||
"quotaFailed": "Could not read the quota",
|
||||
"quotaActive": "in use",
|
||||
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
|
||||
"quotaSignInExpires": "Sign-in expires in {{time}}",
|
||||
"quotaSignInExpired": "Sign-in expired",
|
||||
"quotaWindow": { "wk": "Week", "mo": "Month" }
|
||||
},
|
||||
"version": "Version",
|
||||
"openMenu": "Open menu",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"import": "Import .codex-pet bundle…",
|
||||
"browse": "Find more on codex-pets.net",
|
||||
"settings": "Pet settings…",
|
||||
"hiddenToast": "Pet hidden. Show it again in Settings → Pet.",
|
||||
"hiddenToast": "Pet hidden. Show it again in Settings → Appearance.",
|
||||
"deleteTitle": "Delete {{name}}?",
|
||||
"deleteMessage": "The pet's files are removed from this install. A featured pet can be downloaded again; an upload is gone for good.",
|
||||
"deleteConfirm": "Delete pet",
|
||||
@@ -653,7 +653,10 @@
|
||||
"reset": "Reset to defaults",
|
||||
"resetTitle": "Reset appearance?",
|
||||
"resetBody": "Mode goes back to Dark and both modes to their default theme.",
|
||||
"resetDone": "Appearance reset."
|
||||
"resetDone": "Appearance reset.",
|
||||
"theme": "Theme",
|
||||
"pet": "Pet",
|
||||
"petDescription": "The corner mascot: show it, its size, speech bubbles, and which pet it wears."
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
@@ -753,17 +756,9 @@
|
||||
"cost": "Cost"
|
||||
},
|
||||
"section": {
|
||||
"general": {
|
||||
"title": "General",
|
||||
"description": "The dashboard's display language."
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"description": "Colour mode and theme. Saved in this browser only."
|
||||
},
|
||||
"pet": {
|
||||
"title": "Pet",
|
||||
"description": "The corner mascot: show it, its size, speech bubbles, and which pet it wears."
|
||||
"description": "Language, colour mode and theme, and the corner pet."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
|
||||
@@ -68,7 +68,11 @@
|
||||
"justNow": "vừa xong",
|
||||
"mAgo": "{{count}} phút trước",
|
||||
"hAgo": "{{count}} giờ trước",
|
||||
"dAgo": "{{count}} ngày trước"
|
||||
"dAgo": "{{count}} ngày trước",
|
||||
"justNowShort": "vừa xong",
|
||||
"mAgoShort": "{{count}} phút",
|
||||
"hAgoShort": "{{count}} giờ",
|
||||
"dAgoShort": "{{count}} ngày"
|
||||
},
|
||||
"pagination": {
|
||||
"previous": "Trước",
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"changes.commitPlaceholder": "Nội dung commit",
|
||||
"changes.commitStaged": "Commit {{count}} file đã stage",
|
||||
"changes.suggestMessage": "Gợi ý nội dung commit bằng AI (Claude, các thay đổi đã stage)",
|
||||
"changes.neverStage": "Không bao giờ stage — Stage all bỏ qua file này",
|
||||
"changes.allowStage": "Cho Stage all stage lại file này",
|
||||
"changes.keptHint": "Stage all không bao giờ stage file này (trên trình duyệt này)",
|
||||
"changes.conflicted": "xung đột",
|
||||
"changes.discard": "Bỏ thay đổi của file này",
|
||||
"changes.discardConfirm": "Bấm lần nữa để bỏ — không thể hoàn tác",
|
||||
@@ -113,6 +116,7 @@
|
||||
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
|
||||
"changes.unavailable": "Thư mục của workspace này không phải repo git đọc được.",
|
||||
"changes.unstage": "Bỏ file này khỏi index",
|
||||
"changes.changesView": "Thay đổi",
|
||||
"changes.wholeFile": "Toàn bộ file",
|
||||
"confirmRemoveCancel": "Hủy",
|
||||
"confirmRemoveConfirm": "Xóa",
|
||||
@@ -239,8 +243,8 @@
|
||||
"changes.undoHunkShortConfirm": "Xác nhận",
|
||||
"changes.edit": "Sửa",
|
||||
"changes.save": "Lưu",
|
||||
"changes.revertBlock": "Hoàn tác",
|
||||
"changes.cancelEdit": "Huỷ",
|
||||
"changes.clickToEdit": "Bấm vào nội dung để sửa",
|
||||
"changes.preview": "Xem trước",
|
||||
"changes.raw": "Mã nguồn",
|
||||
"changes.editorLabel": "Sửa {{path}}",
|
||||
|
||||
@@ -67,7 +67,20 @@
|
||||
"today": "Hôm nay {{cost}}",
|
||||
"workspace": "Workspace {{cost}}",
|
||||
"session": "Session {{cost}}",
|
||||
"tokens": "({{tokens}} Tokens)"
|
||||
"tokens": "({{tokens}} Tokens)",
|
||||
"quotaUsed": "đã dùng {{pct}}%",
|
||||
"quotaTitle": "Mức dùng",
|
||||
"quotaResets": "Đặt lại sau {{time}}",
|
||||
"quotaAsOf": "lúc {{ago}}",
|
||||
"quotaRefresh": "Làm mới",
|
||||
"quotaRefreshing": "Đang làm mới…",
|
||||
"quotaExpired": "Token đã hết hạn: dùng Claude Code một lần để nó tự làm mới",
|
||||
"quotaFailed": "Không đọc được quota",
|
||||
"quotaActive": "đang dùng",
|
||||
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
|
||||
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
|
||||
"quotaSignInExpired": "Đăng nhập đã hết hạn",
|
||||
"quotaWindow": { "wk": "Tuần", "mo": "Tháng" }
|
||||
},
|
||||
"version": "Phiên bản",
|
||||
"openMenu": "Mở menu",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"import": "Nhập bundle .codex-pet…",
|
||||
"browse": "Tìm thêm trên codex-pets.net",
|
||||
"settings": "Cài đặt pet…",
|
||||
"hiddenToast": "Đã ẩn pet. Bật lại trong Cài đặt → Pet.",
|
||||
"hiddenToast": "Đã ẩn pet. Bật lại trong Cài đặt → Giao diện.",
|
||||
"deleteTitle": "Xoá {{name}}?",
|
||||
"deleteMessage": "Các tệp của pet sẽ bị xoá khỏi bản cài này. Pet nổi bật có thể tải lại; ảnh bạn tải lên sẽ mất hẳn.",
|
||||
"deleteConfirm": "Xoá pet",
|
||||
@@ -653,7 +653,10 @@
|
||||
"reset": "Khôi phục mặc định",
|
||||
"resetTitle": "Khôi phục giao diện?",
|
||||
"resetBody": "Chế độ trở về Tối và cả hai chế độ trở về giao diện mặc định.",
|
||||
"resetDone": "Đã khôi phục giao diện."
|
||||
"resetDone": "Đã khôi phục giao diện.",
|
||||
"theme": "Theme",
|
||||
"pet": "Pet",
|
||||
"petDescription": "Linh vật ở góc màn hình: hiện/ẩn, kích thước, bong bóng lời thoại và pet đang dùng."
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
@@ -753,17 +756,9 @@
|
||||
"cost": "Chi phí"
|
||||
},
|
||||
"section": {
|
||||
"general": {
|
||||
"title": "Chung",
|
||||
"description": "Ngôn ngữ hiển thị của bảng điều khiển."
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Giao diện",
|
||||
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này."
|
||||
},
|
||||
"pet": {
|
||||
"title": "Pet",
|
||||
"description": "Linh vật ở góc màn hình: hiện/ẩn, kích thước, bong bóng lời thoại và pet đang dùng."
|
||||
"description": "Ngôn ngữ, chế độ màu và theme, và linh vật ở góc màn hình."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Thông báo",
|
||||
|
||||
@@ -105,6 +105,10 @@
|
||||
--syn-plain: var(--fg-primary);
|
||||
--syn-add: 4 120 87; /* emerald-700 */
|
||||
--syn-del: 185 28 28; /* red-700 #b91c1c */
|
||||
/* A diff line edited in place (not wholly added or removed). */
|
||||
--diff-mod: 29 78 216; /* blue-700 #1d4ed8 */
|
||||
/* A file on the Changes panel's never-stage list (orange, apart from amber M). */
|
||||
--kept: 194 65 12; /* orange-700 #c2410c */
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -154,6 +158,8 @@
|
||||
--syn-plain: 229 231 235; /* gray-200 #e5e7eb — kept off fg-primary so Default is unchanged */
|
||||
--syn-add: 110 231 183; /* emerald-300 */
|
||||
--syn-del: 252 165 165; /* red-300 #fca5a5 */
|
||||
--diff-mod: 96 165 250; /* blue-400 #60a5fa */
|
||||
--kept: 251 146 60; /* orange-400 #fb923c */
|
||||
}
|
||||
|
||||
/* Named themes (`data-theme` on <html>, set by hooks/useTheme.ts, picked 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. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Settings.tsx
|
||||
* @description The install-wide settings, as four pages of the Settings nav (`SettingsHub.tsx`): `section="general"` — language —, `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s (Sagi has its own page, `components/settings/PetSettings.tsx`). Appearance is its own page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -205,12 +205,12 @@ function useCountUp(end: number | null, durationMs = 1000) {
|
||||
// ─── Main component ───
|
||||
|
||||
export function Settings({
|
||||
section = "general",
|
||||
section = "notifications",
|
||||
}: {
|
||||
/** Which Settings page this renders (`/settings/<section>`). */
|
||||
section?: "general" | "data" | "about" | "notifications";
|
||||
section?: "data" | "about" | "notifications";
|
||||
} = {}) {
|
||||
const { t, i18n } = useTranslation("settings");
|
||||
const { t } = useTranslation("settings");
|
||||
// Only Data and About show server info; General and Notifications never read it.
|
||||
const needsInfo = section === "data" || section === "about";
|
||||
const [loading, setLoading] = useState(needsInfo);
|
||||
@@ -541,27 +541,6 @@ export function Settings({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "general" && (
|
||||
<>
|
||||
<SettingsSection title={t("language.title")} description={t("language.description")}>
|
||||
<SettingsRow
|
||||
label={t("language.label")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("language.label")}
|
||||
value={(i18n.resolvedLanguage ?? i18n.language).startsWith("vi") ? "vi" : "en"}
|
||||
onChange={(v) => void i18n.changeLanguage(v)}
|
||||
options={[
|
||||
{ value: "en" as const, label: t("language.names.en") },
|
||||
{ value: "vi" as const, label: t("language.names.vi") },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{section === "notifications" && (
|
||||
<>
|
||||
<p className="text-xs text-fg-muted">{t("notifications.scopeNote")}</p>
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* element with that `id` or `data-settings-anchor` and briefly highlights it.
|
||||
* Back returns to the page before Settings when there is one, else /workspace;
|
||||
* an unknown section shows a skeleton until the tool list loads, then toasts
|
||||
* and falls back to General. A tool's own settings live under `agents/<id>` — Claude Code and
|
||||
* and falls back to Appearance (which also holds the language and the pet;
|
||||
* `general` and `pet` redirect there). A tool's own settings live under `agents/<id>` — Claude Code and
|
||||
* Codex have their own pages, every other installed, enabled catalog tool the
|
||||
* generic `AiToolConfig` — and a tool's page is hidden while that tool is
|
||||
* disabled. A workspace's own settings live on its home, not here.
|
||||
@@ -32,18 +33,12 @@ import {
|
||||
Database,
|
||||
Info,
|
||||
Bot,
|
||||
Boxes,
|
||||
Coins,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
Orbit,
|
||||
Palette,
|
||||
PawPrint,
|
||||
Puzzle,
|
||||
Router,
|
||||
Search,
|
||||
Settings as SettingsIcon,
|
||||
SquareTerminal,
|
||||
} from "lucide-react";
|
||||
import { Settings } from "./Settings";
|
||||
import { CcConfig } from "./CcConfig";
|
||||
@@ -52,7 +47,6 @@ import { AiToolConfig } from "./AiToolConfig";
|
||||
import { AiRouterConfig } from "./AiRouterConfig";
|
||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||
import { PetSettings } from "../components/settings/PetSettings";
|
||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
@@ -64,6 +58,7 @@ import {
|
||||
type SettingsEntry,
|
||||
} from "../lib/settingsSearch";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import { AiToolIcon } from "../components/AiToolIcon";
|
||||
import { toast } from "../lib/toast";
|
||||
|
||||
const GROUPS = ["general", "agents", "routing", "cost", "git"] as const;
|
||||
@@ -80,7 +75,7 @@ interface Section {
|
||||
|
||||
/** The pre-1.9 `?tool=` ids, as section ids. Anything else was a catalog tool. */
|
||||
const LEGACY_TOOL: Record<string, string> = {
|
||||
dashboard: "general",
|
||||
dashboard: "appearance",
|
||||
"claude-code": "agents/claude-code",
|
||||
codex: "agents/codex",
|
||||
"ai-router": "ai-router",
|
||||
@@ -104,13 +99,29 @@ const historyIdx = (): number => {
|
||||
export const backDelta = (current: number, entry: number): number =>
|
||||
-Math.max(1, current - entry + 1);
|
||||
|
||||
/** An unknown section: say so, then show General. */
|
||||
/** Sections folded into Appearance: old links and bookmarks still land. */
|
||||
/** A tool's own mark as a nav icon, one stable component per tool id. */
|
||||
const navIcons = new Map<string, ComponentType<{ className?: string }>>();
|
||||
function toolNavIcon(id: string, label: string) {
|
||||
let Icon = navIcons.get(id);
|
||||
if (!Icon) {
|
||||
Icon = ({ className }) => (
|
||||
<AiToolIcon tool={{ id, label }} className={`${className ?? ""} !bg-transparent !p-0`} />
|
||||
);
|
||||
navIcons.set(id, Icon);
|
||||
}
|
||||
return Icon;
|
||||
}
|
||||
|
||||
const MOVED: Record<string, string> = { general: "appearance", pet: "appearance#pet" };
|
||||
|
||||
/** An unknown section: say so, then show Appearance. */
|
||||
function UnknownSection({ name }: { name: string }) {
|
||||
const { t } = useTranslation("settings");
|
||||
useEffect(() => {
|
||||
toast.info(t("settingsNav.unknownSection", { section: name }));
|
||||
}, [name, t]);
|
||||
return <Navigate to="/settings/general" replace />;
|
||||
return <Navigate to="/settings/appearance" replace />;
|
||||
}
|
||||
|
||||
export function SettingsHub() {
|
||||
@@ -191,13 +202,6 @@ export function SettingsHub() {
|
||||
description: t(`settingsNav.section.${key}.description`),
|
||||
});
|
||||
const sections: Section[] = [
|
||||
{
|
||||
id: "general",
|
||||
group: "general",
|
||||
Icon: SettingsIcon,
|
||||
...s("general"),
|
||||
render: () => <Settings section="general" />,
|
||||
},
|
||||
{
|
||||
id: "data",
|
||||
group: "general",
|
||||
@@ -219,13 +223,6 @@ export function SettingsHub() {
|
||||
...s("appearance"),
|
||||
render: () => <AppearanceSettings />,
|
||||
},
|
||||
{
|
||||
id: "pet",
|
||||
group: "general",
|
||||
Icon: PawPrint,
|
||||
...s("pet"),
|
||||
render: () => <PetSettings />,
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
group: "general",
|
||||
@@ -245,7 +242,7 @@ export function SettingsHub() {
|
||||
{
|
||||
id: "agents/claude-code",
|
||||
group: "agents" as const,
|
||||
Icon: Boxes,
|
||||
Icon: toolNavIcon("claude", "Claude Code"),
|
||||
...s("claudeCode"),
|
||||
render: () => <CcConfig />,
|
||||
},
|
||||
@@ -256,7 +253,7 @@ export function SettingsHub() {
|
||||
{
|
||||
id: "agents/codex",
|
||||
group: "agents" as const,
|
||||
Icon: SquareTerminal,
|
||||
Icon: toolNavIcon("codex", "Codex"),
|
||||
...s("codex"),
|
||||
render: () => <CodexConfig />,
|
||||
},
|
||||
@@ -269,7 +266,7 @@ export function SettingsHub() {
|
||||
.map((x) => ({
|
||||
id: `agents/${x.id}`,
|
||||
group: "agents" as const,
|
||||
Icon: x.id === "antigravity" ? Orbit : Puzzle,
|
||||
Icon: toolNavIcon(x.id, x.label),
|
||||
title: x.label,
|
||||
description: t("settingsNav.section.tool.description", { tool: x.label }),
|
||||
render: () => <AiToolConfig key={x.id} toolId={x.id} />,
|
||||
@@ -349,9 +346,10 @@ export function SettingsHub() {
|
||||
? "agents"
|
||||
: tool
|
||||
? (LEGACY_TOOL[tool] ?? `agents/${tool}`)
|
||||
: "general";
|
||||
: "appearance";
|
||||
return <Navigate to={`/settings/${to}`} replace />;
|
||||
}
|
||||
if (MOVED[rest]) return <Navigate to={`/settings/${MOVED[rest]}`} replace />;
|
||||
const active = sections.find((x) => x.id === rest);
|
||||
if (!active) {
|
||||
// A catalog tool page exists only once the tool list has loaded.
|
||||
|
||||
@@ -1275,6 +1275,7 @@ export function Workspace() {
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
full={changesFull}
|
||||
wide={changesPreset !== "compact"}
|
||||
onDirtyChange={setChangesDirty}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
@@ -1310,7 +1311,13 @@ export function Workspace() {
|
||||
{tLanes("changes.title")}
|
||||
</span>
|
||||
</button>
|
||||
{panelAt && <LaneGitRailCounts laneId={panelAt.laneId} session={panelAt.session} />}
|
||||
{panelAt && (
|
||||
<LaneGitRailCounts
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
onShowAll={() => setChangesOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -66,12 +66,8 @@ const burst = () => {
|
||||
};
|
||||
|
||||
describe("Settings", () => {
|
||||
it("never reads server info on Notifications or General, even on activity", async () => {
|
||||
const { unmount } = render(<Settings section="notifications" />);
|
||||
burst();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
unmount();
|
||||
render(<Settings section="general" />);
|
||||
it("never reads server info on Notifications, even on activity", async () => {
|
||||
render(<Settings section="notifications" />);
|
||||
burst();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(info).not.toHaveBeenCalled();
|
||||
|
||||
@@ -22,6 +22,9 @@ vi.mock("../Settings", () => ({
|
||||
<div data-testid={`settings-${section}`} data-settings-anchor={`${section}-row`} />
|
||||
),
|
||||
}));
|
||||
vi.mock("../../components/settings/PetSettings", () => ({
|
||||
PetSettings: () => <div data-testid="pet-settings" />,
|
||||
}));
|
||||
vi.mock("../CcConfig", () => ({ CcConfig: () => <div data-testid="cc-config" /> }));
|
||||
vi.mock("../CodexConfig", () => ({ CodexConfig: () => <div data-testid="codex-config" /> }));
|
||||
vi.mock("../AiToolConfig", () => ({
|
||||
@@ -114,7 +117,9 @@ describe("SettingsHub", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/settings", "/settings/general"],
|
||||
["/settings", "/settings/appearance"],
|
||||
["/settings/general", "/settings/appearance"],
|
||||
["/settings/pet", "/settings/appearance"],
|
||||
["/settings?tool=codex", "/settings/agents/codex"],
|
||||
["/settings?tool=claude-code", "/settings/agents/claude-code"],
|
||||
["/settings?tool=gemini", "/settings/agents/gemini"],
|
||||
@@ -125,10 +130,12 @@ describe("SettingsHub", () => {
|
||||
expect(screen.getByTestId("where").textContent).toBe(to);
|
||||
});
|
||||
|
||||
it("sends an unknown section to General once the lists have loaded", async () => {
|
||||
it("sends an unknown section to Appearance once the lists have loaded", async () => {
|
||||
renderAt("/settings/nope");
|
||||
expect(await screen.findByTestId("settings-general")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("where").textContent).toBe("/settings/general");
|
||||
expect(
|
||||
await screen.findByRole("heading", { level: 1, name: "Appearance" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("where").textContent).toBe("/settings/appearance");
|
||||
});
|
||||
|
||||
it("hides a disabled tool's page and gives an installed, enabled catalog tool one", async () => {
|
||||
@@ -145,8 +152,8 @@ describe("SettingsHub", () => {
|
||||
});
|
||||
|
||||
it("navigates from the mobile select", async () => {
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
renderAt("/settings/data");
|
||||
await screen.findByTestId("settings-data");
|
||||
fireEvent.change(screen.getByRole("combobox", { name: "Settings sections" }), {
|
||||
target: { value: "pricing" },
|
||||
});
|
||||
@@ -156,8 +163,8 @@ describe("SettingsHub", () => {
|
||||
|
||||
it("filters the nav by search, opens the first match on Enter and clears on Escape", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
renderAt("/settings/data");
|
||||
await screen.findByTestId("settings-data");
|
||||
const box = screen.getByRole("searchbox", { name: "Search settings" });
|
||||
await user.type(box, "pricing");
|
||||
const links = within(nav())
|
||||
@@ -177,8 +184,8 @@ describe("SettingsHub", () => {
|
||||
|
||||
it("says when nothing matches, and Ctrl+F focuses the search", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
renderAt("/settings/data");
|
||||
await screen.findByTestId("settings-data");
|
||||
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
|
||||
const box = screen.getByRole("searchbox", { name: "Search settings" });
|
||||
expect(box).toHaveFocus();
|
||||
@@ -187,8 +194,8 @@ describe("SettingsHub", () => {
|
||||
});
|
||||
|
||||
it("Ctrl+F leaves the search alone while a modal dialog is open", async () => {
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
renderAt("/settings/data");
|
||||
await screen.findByTestId("settings-data");
|
||||
const dialog = document.createElement("div");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
@@ -204,8 +211,8 @@ describe("SettingsHub", () => {
|
||||
|
||||
it("a second Ctrl+F with the search focused selects its text instead of reaching the browser", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
renderAt("/settings/data");
|
||||
await screen.findByTestId("settings-data");
|
||||
const box = screen.getByRole<HTMLInputElement>("searchbox", { name: "Search settings" });
|
||||
fireEvent.keyDown(window, { key: "f", ctrlKey: true });
|
||||
await user.type(box, "pet");
|
||||
@@ -217,8 +224,8 @@ describe("SettingsHub", () => {
|
||||
|
||||
it("lists matching rows and deep-links a Claude Code sub-tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAt("/settings/general");
|
||||
await screen.findByTestId("settings-general");
|
||||
renderAt("/settings/data");
|
||||
await screen.findByTestId("settings-data");
|
||||
await user.type(screen.getByRole("searchbox", { name: "Search settings" }), "marketplaces");
|
||||
const list = screen.getByRole("list", { name: "Search results" });
|
||||
await user.click(within(list).getAllByRole("link")[0]!);
|
||||
@@ -227,14 +234,16 @@ describe("SettingsHub", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("puts Pricing in its own Cost group and splits General into General, Data and About", async () => {
|
||||
it("puts Pricing in its own Cost group and folds language and pet into Appearance", async () => {
|
||||
renderAt("/settings/data");
|
||||
expect(await screen.findByTestId("settings-data")).toBeInTheDocument();
|
||||
expect(within(nav()).getByText("Cost")).toBeInTheDocument();
|
||||
const links = within(nav())
|
||||
.getAllByRole("link")
|
||||
.map((a) => a.textContent);
|
||||
expect(links.slice(0, 3)).toEqual(["General", "Data", "About"]);
|
||||
expect(links.slice(0, 3)).toEqual(["Data", "About", "Appearance"]);
|
||||
expect(links).not.toContain("General");
|
||||
expect(links).not.toContain("Pet");
|
||||
});
|
||||
|
||||
it("scrolls to and highlights the anchor the URL names", async () => {
|
||||
@@ -258,11 +267,11 @@ describe("SettingsHub", () => {
|
||||
expect(backDelta(0, 2)).toBe(-1);
|
||||
});
|
||||
|
||||
it("toasts when an unknown section falls back to General", async () => {
|
||||
it("toasts when an unknown section falls back to Appearance", async () => {
|
||||
let seen: ToastItem[] = [];
|
||||
const off = subscribeToasts((i) => (seen = i));
|
||||
renderAt("/settings/nope");
|
||||
await screen.findByTestId("settings-general");
|
||||
await screen.findByTestId("pet-settings");
|
||||
expect(seen.some((x) => x.message.includes("nope"))).toBe(true);
|
||||
off();
|
||||
});
|
||||
|
||||
@@ -6832,74 +6832,6 @@ exports[`screen snapshots > Sessions 1`] = `
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`screen snapshots > Settings 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="animate-fade-in space-y-8"
|
||||
>
|
||||
<section
|
||||
class="card p-5"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Language
|
||||
</h3>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
The language of the whole dashboard, remembered in this browser.
|
||||
</p>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Interface language
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<div
|
||||
aria-label="Interface language"
|
||||
class="inline-flex rounded-lg border border-border bg-surface-2 p-0.5"
|
||||
role="radiogroup"
|
||||
>
|
||||
<button
|
||||
aria-checked="true"
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors bg-accent/20 text-accent"
|
||||
role="radio"
|
||||
tabindex="0"
|
||||
type="button"
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
aria-checked="false"
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
role="radio"
|
||||
tabindex="-1"
|
||||
type="button"
|
||||
>
|
||||
Tiếng Việt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`screen snapshots > Settings — about 1`] = `
|
||||
<div>
|
||||
<div
|
||||
@@ -6981,7 +6913,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v2.2.0 (rebuild client to match)
|
||||
UI build v2.2.1 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -18,7 +18,7 @@ if (nodeProcess) nodeProcess.env.TZ = "UTC";
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { render, act, screen, fireEvent, within } from "@testing-library/react";
|
||||
import { render, act, screen, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter, Routes, Route } from "react-router-dom";
|
||||
import i18n from "i18next";
|
||||
|
||||
@@ -593,13 +593,9 @@ describe("screen snapshots", () => {
|
||||
it("Run", async () => {
|
||||
await snapshot(<Workspace />, "/run");
|
||||
});
|
||||
it("Settings", async () => {
|
||||
await snapshot(<Settings />, "/settings");
|
||||
});
|
||||
it("Settings — notifications", async () => {
|
||||
await snapshot(<Settings section="notifications" />, "/settings/notifications");
|
||||
});
|
||||
// General split into General / Data / About: keep the moved content covered.
|
||||
it("Settings — data", async () => {
|
||||
await snapshot(<Settings section="data" />, "/settings/data");
|
||||
});
|
||||
@@ -611,29 +607,6 @@ describe("screen snapshots", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The language picker moved off the sidebar into Settings → General.
|
||||
describe("Settings — language", () => {
|
||||
it("switches the interface language from General", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/settings/general"]}>
|
||||
<Settings section="general" />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
const group = screen.getByRole("radiogroup", { name: "Interface language" });
|
||||
expect(within(group).getByRole("radio", { name: "English" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true"
|
||||
);
|
||||
fireEvent.click(within(group).getByRole("radio", { name: "Tiếng Việt" }));
|
||||
await settle();
|
||||
expect(i18n.language).toBe("vi");
|
||||
await act(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Pricing, hook status and CLAUDE_HOME belong to Claude Code (#40 part B):
|
||||
// they sit under the Claude Code tab's Overview, not on Dashboard or on the
|
||||
// other sub-tabs.
|
||||
|
||||
@@ -49,6 +49,8 @@ const tinted = {
|
||||
danger: tint("--status-danger"),
|
||||
warning: tint("--status-warning"),
|
||||
},
|
||||
diff: { mod: tint("--diff-mod") },
|
||||
kept: tint("--kept"),
|
||||
};
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
@@ -104,6 +106,8 @@ export default {
|
||||
danger: "rgb(var(--status-danger) / <alpha-value>)",
|
||||
warning: "rgb(var(--status-warning) / <alpha-value>)",
|
||||
},
|
||||
diff: { mod: "rgb(var(--diff-mod) / <alpha-value>)" },
|
||||
kept: "rgb(var(--kept) / <alpha-value>)",
|
||||
},
|
||||
backgroundColor: tinted,
|
||||
borderColor: tinted,
|
||||
|
||||
+16
-3
@@ -491,7 +491,10 @@ One route, three depths — the shapes the Workspace's Changes panel asks for:
|
||||
`range` picks the comparison: `work` (default) is HEAD vs the working tree with
|
||||
untracked files included, `branch` is `<base>...HEAD` for the workspace's
|
||||
`base_branch`, and `commit` shows the single commit named by `sha` (an
|
||||
unresolvable one is `400 EBADREV`, never an empty diff).
|
||||
unresolvable one is `400 EBADREV`, never an empty diff). A `branch` answer also
|
||||
carries `mergeBase` — the full sha of `git merge-base <baseRef> HEAD`, the
|
||||
commit the diff is taken from (`null` on the other ranges or when git cannot
|
||||
find one); the panel's editor reads a file's original text there.
|
||||
|
||||
`context` defaults to **3** and is clamped to **5000** — at the top of that
|
||||
range the diff has effectively become the whole file, which is what "expand
|
||||
@@ -509,6 +512,7 @@ that file.
|
||||
"range": "work",
|
||||
"base": "development",
|
||||
"baseRef": "origin/development",
|
||||
"mergeBase": null,
|
||||
"sha": null,
|
||||
"context": 3,
|
||||
"truncated": false,
|
||||
@@ -570,7 +574,8 @@ and file explorer. The GETs are read-only and unguarded, and the JSON ones
|
||||
answer `{available:false}` with HTTP **200** for a cwd git cannot read.
|
||||
|
||||
- **`/git/log`** → `{available, commits:[{sha, short, subject, author, date,
|
||||
refs}]}`, newest first, `limit` clamped to 200. `file=` narrows it to the
|
||||
refs, parents}]}`, newest first in `--date-order` (no parent before its
|
||||
children; `parents` = parent shas, first parent first), `limit` clamped to 200. `file=` narrows it to the
|
||||
commits touching that path — relative, no `..` segment, else
|
||||
`400 EBADPATH`. Reading one commit's diff is
|
||||
`/git/changes?range=commit&sha=`.
|
||||
@@ -2007,6 +2012,14 @@ The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]`
|
||||
curl "http://localhost:4820/api/pricing/cost?tz_offset=-420"
|
||||
```
|
||||
|
||||
#### Get Plan Quota
|
||||
|
||||
```
|
||||
GET /api/pricing/quota[?force=1]
|
||||
```
|
||||
|
||||
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex", active, label?, account?, expiresAt?, windows: [{ label, usedPercent, resetsAt }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's `auth.json` access-token `exp` (JWT claims read unverified; no token leaves the server). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time.
|
||||
|
||||
---
|
||||
|
||||
### Notifications
|
||||
@@ -2423,7 +2436,7 @@ POST /api/pets/featured/:id Download a FEATURED pet from codex-pets.ne
|
||||
DELETE /api/pets/:id Delete a pet (its folder under the data dir)
|
||||
```
|
||||
|
||||
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens **Settings → Pet** (`/settings/pet`, `client/src/components/settings/PetSettings.tsx`): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
|
||||
The mascot (Sagi) can wear a pet: right-click it for **Hide pet**, **Size** (48–250 px), **Choose pet** (Sagi, the featured pets, your own — deletable), **Upload your own…**, **Import .codex-pet bundle…**, **Find more on codex-pets.net** and **Pet settings…**, which opens the pet block of **Settings → Appearance** (`/settings/appearance#pet`, `client/src/components/settings/PetSettings.tsx`; the old `/settings/pet` redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share `components/Sagi/usePets.ts`. Which pet is worn and its size are viewer preferences in `localStorage` (`agenthub.pet`, `agenthub.pet.size`); the pets live on the server, one folder each under `<data dir>/pets/<id>/` with a `meta.json` AgentHub writes.
|
||||
|
||||
A **Codex pet** is the `.codex-pet.zip` format Codex Desktop uses (the bundles shared on codex-pets.net and in `mileson/codex-pets`): `pet.json` (`id`, `displayName`, `description`, `spritesheetPath`) and `spritesheet.webp`, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, idle/asleep → idle). Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly that size (`400 EBADPET` otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.
|
||||
|
||||
|
||||
+3
-3
@@ -155,7 +155,7 @@ classDiagram
|
||||
|
||||
## 4) Language Detection and Switching Flow
|
||||
|
||||
The language picker in Settings → General (`client/src/pages/Settings.tsx`, a `SettingsSegmented` of English / Tiếng Việt, found by the settings search too) calls `i18n.changeLanguage()` and UI updates reactively through `useTranslation`. The sidebar has no language control.
|
||||
The language picker in Settings → Appearance (`client/src/components/settings/AppearanceSettings.tsx`, a `SettingsSegmented` of English / Tiếng Việt, found by the settings search too) calls `i18n.changeLanguage()` and UI updates reactively through `useTranslation`. The sidebar has no language control.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -237,7 +237,7 @@ npm run test:client
|
||||
| Locale fallback | Unknown locales fall back to `en` | `vi-VN` resolves to `vi` |
|
||||
| Terminology consistency | Canonical terms stay stable | `Agent`/`Subagent` expectations |
|
||||
| Date/number formatting | Locale-specific output shape | `en-US`, `vi-VN` formatting |
|
||||
| Runtime switching | UI rerenders without reload | Settings → General language picker |
|
||||
| Runtime switching | UI rerenders without reload | Settings → Appearance language picker |
|
||||
|
||||
---
|
||||
|
||||
@@ -282,7 +282,7 @@ gantt
|
||||
### Operational checklist
|
||||
- [ ] Confirm all namespaces exist for `en` and `vi`
|
||||
- [ ] Confirm key parity across all locale JSON files
|
||||
- [ ] Confirm language switching from Settings → General re-renders the whole app
|
||||
- [ ] Confirm language switching from Settings → Appearance re-renders the whole app
|
||||
- [ ] Confirm fallback behavior for region tags (e.g., `vi-VN`, `en-US`)
|
||||
- [ ] Confirm date/time/currency formatting for all supported languages
|
||||
- [ ] Confirm client tests pass before release
|
||||
|
||||
+37
-19
@@ -492,8 +492,9 @@ curl http://localhost:4820/api/lanes/5/git
|
||||
read off disk, since a workspace mid-build can have a 100 MB artifact), a **binary**
|
||||
file, a **rename** (numstat keys those differently), or a repo with no commit
|
||||
to diff against yet.
|
||||
- Hovering (or focusing, or tapping) the `N modified · N untracked` text at the
|
||||
top of the Changes panel opens this list as a panel: one row per file, its status letter
|
||||
- Hovering (or focusing, or tapping) the vertical `N modified · N untracked` chip
|
||||
on the CLOSED Changes rail opens this list as a panel (the open panel shows no
|
||||
counts — its own list below says the same): one row per file, its status letter
|
||||
colour-coded, the directory truncated before the filename ever is, and each
|
||||
row's `+N −M` aligned on the right under a total.
|
||||
- A repo with no commit yet is **not** unavailable: it reports `unborn: true`,
|
||||
@@ -600,10 +601,9 @@ persists per viewer in `localStorage`). Its header holds **one** control, which
|
||||
steps **Narrow** (30%) → **Medium** (50/50) → **Full screen** (the console beside it is hidden, never unmounted; the page is
|
||||
height-locked so the panel fills the viewport even with no live run; the whole
|
||||
Files view (finder, expand-all, tree) becomes a left sidebar and the Files tab
|
||||
is dropped, and every working-copy file opened there, from the sidebar, Overview or Review, IS
|
||||
the editor (Whole file toggles back to its diff; a commit still opens as a diff) — no
|
||||
Edit click, **Save**/**Cancel** stay and wake up once the text differs, Cancel
|
||||
restores the saved text) → closed (`agenthub.workspace.changesSize`
|
||||
is dropped; a file opens on its **Changes** like at any width, and
|
||||
**Whole file** is the editor — **Save**/**Cancel** wake up once the text
|
||||
differs, Cancel restores the saved text) → closed (`agenthub.workspace.changesSize`
|
||||
alongside `agenthub.workspace.changesOpen`); the collapsed rail reopens it
|
||||
narrow. Between the console and the open (not full-screen) panel sits a **splitter**: drag it, or move it with the arrow keys, to any width between 15% and 85%; that fraction is stored as a NUMBER in the same `agenthub.workspace.changesSize` key, and the header button still steps through the presets. A stored size from an older build that no longer exists (`wide`) falls back to narrow. Reading a diff and watching a console want opposite amounts of room and
|
||||
which one is "work" changes several times an hour, so the width is a step, not
|
||||
@@ -627,19 +627,36 @@ reads the workspace's git surface in five modes:
|
||||
one green with `U` (`A` added, `D` deleted and struck through, `R`
|
||||
renamed), and a folder holding any of them gets an amber dot. The colours
|
||||
come with the tree read, so **Refresh** re-reads them.
|
||||
Opening a file entry shows the whole file (`/git/file`) for reading, at every
|
||||
panel width; an image file (png, jpg, gif, webp, avif, bmp, ico, svg) is shown
|
||||
as the picture (`/git/raw`), with no editor. **Clicking its text** (or Enter on it; any range but a commit)
|
||||
turns that view into a text editor with the caret on the clicked line and
|
||||
the scroll position kept — a click that only ends a drag-selection does not,
|
||||
so copying never drops you into the editor. **Cancel** returns to reading.
|
||||
From a diff, which has no whole text to click, an **Edit** button does the
|
||||
same once the file has loaded. **Save** (or
|
||||
A changed file opens on its **Changes**, also when picked in Files; an
|
||||
unchanged file picked in Files opens as the whole file. Any working file
|
||||
(not a commit's, a binary, a deleted or a conflicted one) is the **editor**
|
||||
in both views (`client/src/components/lanes/DiffEditor.tsx`, CodeMirror 6
|
||||
with `@codemirror/merge`, lazy-loaded), editable, with its diff against
|
||||
HEAD (`work`) or the branch's merge base (`branch`, `mergeBase` on
|
||||
`/git/changes`): **Changes** folds the unchanged stretches away (a click on
|
||||
a fold opens it), **Whole file** shows them, and the toggle keeps the draft.
|
||||
Unified puts the removed lines read-only above their replacement; side by
|
||||
side puts the original, read-only, left of the file. Every changed block
|
||||
carries a **Revert** (`→` between split columns) that puts the original back
|
||||
in the editor — nothing is written until Save. The panel opens side by side
|
||||
at 50/50 and full width (Workspace's `wide`) and unified when narrower; the
|
||||
layout button overrides it until the next resize. The colours follow every
|
||||
keystroke: **green** is a line added whole, **red** a line removed whole,
|
||||
**blue** a line edited in place (it keeps a letter or digit of its old
|
||||
text), its edit in a stronger blue; the read-only diffs (Review, a commit, a
|
||||
file the editor cannot take) use the same scheme, and only they carry the
|
||||
per-block Stage/Undo review bars and the line-comment buttons — Review
|
||||
keeps both for every file. In a read-only side-by-side diff, every row is at
|
||||
least one line tall (`MIN_ROW`), so an empty filler row keeps the two
|
||||
columns aligned. An image file (png, jpg, gif, webp, avif, bmp, ico, svg) is
|
||||
shown as the picture (`/git/raw`), and a conflicted file opens in the
|
||||
conflict view. **Cancel** reverts unsaved edits. **Save** (or
|
||||
Ctrl/Cmd+S) is `PUT /api/lanes/:id/git/file`, which writes only over the
|
||||
exact version that was opened — a file the agent changed meanwhile answers
|
||||
`409 ESTALE` and the draft stays for a retry — and keeps the file's CRLF
|
||||
line endings and final newline. A file the editor cannot take (binary, or
|
||||
over the 1 MB view cap) says so in the file's header.
|
||||
line endings and final newline; the editor stays open on what was saved. A
|
||||
file the editor cannot take (binary, or over the 1 MB view cap) says so in
|
||||
the file's header.
|
||||
- **Overview** — every changed file with its `+N −M`. Polls every 15s, because
|
||||
it is the cheap shape and the one you leave open while an agent works. Each
|
||||
row carries its own **stage** / **unstage** / **discard** buttons.
|
||||
@@ -673,8 +690,9 @@ reads the workspace's git surface in five modes:
|
||||
**All files** returns to the list it was opened from — the same mode, at the
|
||||
same scroll offset — because reading a change-set is a walk down a long list
|
||||
and landing back at its top is the whole cost of opening one file.
|
||||
- **History** — the workspace's recent commits (`GET /:id/git/log`); opening one
|
||||
shows that commit's diff (`?range=commit&sha=`).
|
||||
- **History** — the workspace's recent commits (`GET /:id/git/log`) drawn as a
|
||||
commit graph, one coloured lane per branch line (`client/src/lib/commitGraph.ts`);
|
||||
opening one shows that commit's diff (`?range=commit&sha=`).
|
||||
|
||||
A range toggle switches the comparison between **Uncommitted** (HEAD vs the
|
||||
working tree, untracked files included as an all-added patch) and **vs base**
|
||||
@@ -710,7 +728,7 @@ overview grows a commit box the moment anything is staged — above the list and
|
||||
pinned while it scrolls, on ONE row with a **Stage all** that stages every listed file
|
||||
the index lacks in one call and an **Unstage all** that unstages every staged one — it sends
|
||||
`only: "staged"`, never `add -A`, and it is absent with a clean index because
|
||||
there is nothing it could mean. It is the dashboard's ONLY commit: the git bar
|
||||
there is nothing it could mean. A file you never want committed (a local note, a scratch folder) can be marked **Never stage** (its row's eye-off button, or right-click): **Stage all** and a folder's stage button then skip it, the row is struck through, and the file is drawn orange in the Files tree (`--kept`, apart from the amber of a modified file). The list is kept in this browser per workspace (`agenthub.changes.neverStage.<lane id>`) and git is never told — nothing is written to the repo, and the file's own Stage still works. It is the dashboard's ONLY commit: the git bar
|
||||
above it has none, so no click can sweep the rest of the tree in with `add -A`. **Discard** is the one
|
||||
irreversible action in the panel: it asks twice in the UI, and the server
|
||||
refuses it without `confirm: true` and accepts only paths `git status` is
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.2.0
|
||||
version: 2.2.1
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.2.0",
|
||||
"version": "2.2.1",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -61,6 +61,27 @@ describe("dashboard-runs", () => {
|
||||
assert.equal(runs.getRun("agenthub-lane-9-1"), null);
|
||||
});
|
||||
|
||||
it("keeps only the newest closed Terminal tabs of a lane, live ones untouched", () => {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
start(`agenthub-lane-11-${i}`, {
|
||||
laneId: 11,
|
||||
tool: "shell",
|
||||
startedAt: Date.parse(`2026-10-0${i}T10:00:00Z`),
|
||||
});
|
||||
}
|
||||
start("agenthub-lane-11", { laneId: 11, tool: "claude", startedAt: Date.parse("2026-09-01") });
|
||||
// Pane 5 still runs; every other one has closed.
|
||||
runs.reconcileOrphans((id) => id === "agenthub-lane-11-5");
|
||||
const shells = runs.listRuns({ laneId: 11, tool: "shell" }).map((r) => r.id);
|
||||
assert.deepEqual(shells, [
|
||||
"agenthub-lane-11-5",
|
||||
"agenthub-lane-11-4",
|
||||
"agenthub-lane-11-3",
|
||||
"agenthub-lane-11-2",
|
||||
]);
|
||||
assert.ok(runs.getRun("agenthub-lane-11"), "another tool's row stays");
|
||||
});
|
||||
|
||||
it("flags as abandoned only the running rows whose pane is gone", () => {
|
||||
start("agenthub-lane-5", { laneId: 5 });
|
||||
start("agenthub-lane-6", { laneId: 6 });
|
||||
|
||||
@@ -195,6 +195,7 @@ describe("collectChanges — branch range", () => {
|
||||
|
||||
const out = await collectChanges(LANE, { range: "branch", base: "development", full: true });
|
||||
assert.equal(out.baseRef, "origin/development");
|
||||
assert.equal(out.mergeBase, g(LANE, "merge-base", "origin/development", "HEAD").trim());
|
||||
assert.deepEqual(
|
||||
out.files.map((f) => f.path),
|
||||
["a.txt"]
|
||||
@@ -401,6 +402,8 @@ describe("listCommits / readFileAt / listPaths / listTree", () => {
|
||||
["touch keep", "touch a", "init"]
|
||||
);
|
||||
assert.ok(all[0].refs.includes("HEAD -> feat/thing"));
|
||||
assert.deepEqual(all[0].parents, [all[1].sha]);
|
||||
assert.deepEqual(all[2].parents, []);
|
||||
|
||||
const forA = await listCommits(LANE, { file: "a.txt" });
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @file Tests server/lib/quota.js: a rollout's `rate_limits`
|
||||
* window mapped to the status bar's label/percent/reset, and a window that has
|
||||
* reset since Codex last wrote reading as empty, and the Codex login's
|
||||
* account + expiry read from `auth.json`; and a saved Claude profile asked
|
||||
* with its own token only while it is valid, never refreshed.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { codexWindow, windowLabel } = require("../lib/quota");
|
||||
|
||||
test("windowLabel names the plan windows", () => {
|
||||
assert.strictEqual(windowLabel(300), "5h");
|
||||
assert.strictEqual(windowLabel(10080), "wk");
|
||||
assert.strictEqual(windowLabel(43200), "mo");
|
||||
assert.strictEqual(windowLabel(2880), "2d");
|
||||
});
|
||||
|
||||
test("codexWindow keeps a live window and empties a reset one", () => {
|
||||
const future = Math.floor(Date.now() / 1000) + 3600;
|
||||
assert.deepStrictEqual(
|
||||
codexWindow({ used_percent: 27, window_minutes: 300, resets_at: future }, Date.now()),
|
||||
{ label: "5h", usedPercent: 27, resetsAt: new Date(future * 1000).toISOString() }
|
||||
);
|
||||
const past = codexWindow(
|
||||
{ used_percent: 92, window_minutes: 10080, resets_in_seconds: 60 },
|
||||
Date.now() - 120_000
|
||||
);
|
||||
assert.strictEqual(past.usedPercent, 0);
|
||||
assert.strictEqual(codexWindow(null, 0), null);
|
||||
});
|
||||
|
||||
test("codexAccount reads the email and access-token expiry, never a token", () => {
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { codexAccount } = require("../lib/quota");
|
||||
const jwt = (claims) => `h.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.s`;
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "quota-codex-"));
|
||||
const prev = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = home;
|
||||
try {
|
||||
assert.deepStrictEqual(codexAccount(), {});
|
||||
fs.writeFileSync(
|
||||
path.join(home, "auth.json"),
|
||||
JSON.stringify({
|
||||
tokens: { id_token: jwt({ email: "a@b.c" }), access_token: jwt({ exp: 1_800_000_000 }) },
|
||||
})
|
||||
);
|
||||
assert.deepStrictEqual(codexAccount(), {
|
||||
account: "a@b.c",
|
||||
expiresAt: new Date(1_800_000_000_000).toISOString(),
|
||||
});
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prev;
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a saved profile is asked with its own token only while valid, never refreshed", async () => {
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "quota-claude-"));
|
||||
const dir = path.join(home, "agenthub-profiles");
|
||||
fs.mkdirSync(dir);
|
||||
const profile = (name, token, expiresAt) =>
|
||||
fs.writeFileSync(
|
||||
path.join(dir, `${name}.json`),
|
||||
JSON.stringify({
|
||||
label: name,
|
||||
account: { accountUuid: name, emailAddress: `${name}@x.y` },
|
||||
credentials: { accessToken: token, refreshToken: "r", expiresAt },
|
||||
})
|
||||
);
|
||||
profile("fresh", "tok-fresh", Date.now() + 3600_000);
|
||||
profile("stale", "tok-stale", Date.now() - 1000);
|
||||
const prev = { home: process.env.CLAUDE_HOME, fetch: global.fetch };
|
||||
const asked = [];
|
||||
process.env.CLAUDE_HOME = home;
|
||||
global.fetch = async (url, opts) => {
|
||||
asked.push(`${url} ${opts.headers.Authorization}`);
|
||||
return {
|
||||
status: 200,
|
||||
ok: true,
|
||||
json: async () => ({ five_hour: { utilization: 40, resets_at: null } }),
|
||||
};
|
||||
};
|
||||
try {
|
||||
__resetCache();
|
||||
const claude = (await quotas()).tools.filter((t) => t.id === "claude");
|
||||
assert.deepStrictEqual(asked, ["https://api.anthropic.com/api/oauth/usage Bearer tok-fresh"]);
|
||||
const by = Object.fromEntries(claude.map((t) => [t.label, t]));
|
||||
assert.strictEqual(by.fresh.active, false);
|
||||
assert.strictEqual(by.fresh.windows[0].usedPercent, 40);
|
||||
assert.strictEqual(by.stale.error, "EPROFILEEXPIRED");
|
||||
assert.strictEqual(by.stale.account, "stale@x.y");
|
||||
} finally {
|
||||
if (prev.home === undefined) delete process.env.CLAUDE_HOME;
|
||||
else process.env.CLAUDE_HOME = prev.home;
|
||||
global.fetch = prev.fetch;
|
||||
__resetCache();
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -491,6 +491,7 @@ module.exports = {
|
||||
readProfile,
|
||||
writeProfile,
|
||||
liveCredentialsFor,
|
||||
readActiveAuth,
|
||||
// exported for tests
|
||||
profilesDir,
|
||||
configPaths,
|
||||
|
||||
@@ -263,12 +263,28 @@ const abandonStmt = db.prepare(`
|
||||
WHERE id = ? AND status IN ('running', 'spawning')
|
||||
`);
|
||||
|
||||
/** Closed Terminal tabs kept per lane — what the sidebar lists
|
||||
* (`CLOSED_TERMINALS` in WorkspaceSessionList.tsx). */
|
||||
const CLOSED_SHELLS_KEPT = 3;
|
||||
// A shell has no session, so its row is only that list's entry: one the list
|
||||
// cannot show is dropped, or removing the shown ones uncovers older ones.
|
||||
const pruneShellsStmt = db.prepare(`
|
||||
DELETE FROM dashboard_runs WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY lane_id ORDER BY started_at DESC) AS n
|
||||
FROM dashboard_runs
|
||||
WHERE tool = 'shell' AND status NOT IN ('running', 'spawning')
|
||||
) WHERE n > ${CLOSED_SHELLS_KEPT}
|
||||
)
|
||||
`);
|
||||
|
||||
/**
|
||||
* Flags `abandoned` every row still marked running/spawning whose pane is
|
||||
* gone — the pane's process quit (Ctrl+C, `exit`), or the machine rebooted.
|
||||
* A pane is a tmux session, which outlives a server restart, so liveness is
|
||||
* asked of `isAlive(id)` (tmux), never assumed from the restart. Run at boot
|
||||
* and on the server's run-heal beat.
|
||||
* and on the server's run-heal beat, which also drops the closed Terminal
|
||||
* tabs past the newest {@link CLOSED_SHELLS_KEPT} of each lane.
|
||||
*
|
||||
* Returns the number of rows updated.
|
||||
*/
|
||||
@@ -278,6 +294,7 @@ function reconcileOrphans(isAlive = () => false) {
|
||||
for (const { id } of runningStmt.all()) {
|
||||
if (!isAlive(id)) changes += abandonStmt.run(id).changes;
|
||||
}
|
||||
pruneShellsStmt.run();
|
||||
return changes;
|
||||
} catch {
|
||||
return 0;
|
||||
|
||||
+14
-5
@@ -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;
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @file quota.js
|
||||
* @description How much of each native AI tool's plan quota is spent, for the
|
||||
* status bar's quota chips (`GET /api/pricing/quota`).
|
||||
*
|
||||
* - **Claude**: the logged-in account's `GET /api/oauth/usage` (`cc-usage.js`),
|
||||
* with the access token the CLI holds RIGHT NOW. It is never refreshed here —
|
||||
* a refresh rotates the CLI's credentials — so an expired token answers
|
||||
* `ELIVEEXPIRED` until Claude Code next refreshes it itself. Every OTHER
|
||||
* saved auth profile (`cc-profiles.js`) is asked with its own stored token
|
||||
* while that token is valid and never refreshed (a refresh rotates the
|
||||
* saved credentials); past expiry it shows its last figure, `asOf`. Cached
|
||||
* `CLAUDE_TTL_MS` per account, so a poll from every open tab is one
|
||||
* upstream call each. The live entry is `active: true`.
|
||||
* - **Codex**: no network. Every `token_count` event Codex writes into its
|
||||
* rollout journal carries the account's `rate_limits`; the newest one wins.
|
||||
*
|
||||
* Each tool also carries its signed-in `account` (an email) and `expiresAt`,
|
||||
* when its stored access token runs out — Claude's `claudeAiOauth.expiresAt`,
|
||||
* Codex's `auth.json` access-token `exp` (claims read, never verified).
|
||||
*
|
||||
* Only percentages, window lengths, reset/expiry times and the account's
|
||||
* email leave this module — never a token.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const { readActiveAuth, readProfile, listProfiles } = require("./cc-profiles");
|
||||
const { fetchUsage } = require("./cc-usage");
|
||||
const { allRolloutFiles } = require("./codex-ingest");
|
||||
|
||||
const CLAUDE_TTL_MS = 5 * 60_000;
|
||||
const FORCE_MIN_MS = 30_000; // a Refresh click still cannot hammer the endpoint
|
||||
const EXPIRY_MARGIN_MS = 60_000;
|
||||
const TAIL_BYTES = 512 * 1024;
|
||||
const CODEX_FILES = 5;
|
||||
|
||||
const cache = new Map(); // "live" | "profile:<name>" → { at, value }
|
||||
const lastGood = new Map(); // profile name → { windows, at }
|
||||
|
||||
/** One upstream call per key per `CLAUDE_TTL_MS` (`FORCE_MIN_MS` on a click). */
|
||||
async function cached(key, force, fn) {
|
||||
const hit = cache.get(key);
|
||||
const age = hit ? Date.now() - hit.at : Infinity;
|
||||
if (age < (force ? FORCE_MIN_MS : CLAUDE_TTL_MS)) return hit.value;
|
||||
const value = await fn();
|
||||
cache.set(key, { at: Date.now(), value });
|
||||
return value;
|
||||
}
|
||||
|
||||
function windowLabel(minutes) {
|
||||
if (minutes === 300) return "5h";
|
||||
if (minutes === 10080) return "wk";
|
||||
if (minutes >= 40000 && minutes <= 45000) return "mo";
|
||||
return minutes % 1440 === 0 ? `${minutes / 1440}d` : `${Math.round(minutes / 60)}h`;
|
||||
}
|
||||
|
||||
const iso = (ms) => (Number(ms) ? new Date(Number(ms)).toISOString() : null);
|
||||
const fresh = (ms) => Number(ms) > Date.now() + EXPIRY_MARGIN_MS;
|
||||
|
||||
function pct(w) {
|
||||
return { usedPercent: w.utilization, resetsAt: w.resetsAt };
|
||||
}
|
||||
|
||||
function claudeWindows(u) {
|
||||
const windows = [];
|
||||
if (u.fiveHour) windows.push({ label: "5h", ...pct(u.fiveHour) });
|
||||
if (u.sevenDay) windows.push({ label: "wk", ...pct(u.sevenDay) });
|
||||
return windows;
|
||||
}
|
||||
|
||||
/** The account the CLI is logged into, with the token it holds right now. */
|
||||
async function liveClaude() {
|
||||
let who = {};
|
||||
try {
|
||||
const { oauth, account } = readActiveAuth();
|
||||
if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in
|
||||
who = {
|
||||
account: account?.emailAddress || account?.displayName || null,
|
||||
expiresAt: iso(oauth.expiresAt),
|
||||
};
|
||||
if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" };
|
||||
return { ...who, windows: claudeWindows(await fetchUsage(oauth.accessToken)) };
|
||||
} catch (e) {
|
||||
return { ...who, windows: [], error: e.code || "EUPSTREAM" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A saved profile that is not the live account, asked with its OWN stored
|
||||
* token only while that token is still valid. Never refreshed here: a refresh
|
||||
* rotates the profile's credentials on disk, so it happens only on the
|
||||
* Profiles modal's Check quota click (`cc-usage.profileUsage`). Past expiry
|
||||
* the last figure this process read is shown with its `asOf`.
|
||||
*/
|
||||
async function profileClaude(item) {
|
||||
const who = { account: item.email || item.displayName || null, expiresAt: iso(item.expiresAt) };
|
||||
let creds = null;
|
||||
try {
|
||||
creds = readProfile(item.name)?.credentials || null;
|
||||
} catch {
|
||||
/* a corrupt profile file reads as signed out */
|
||||
}
|
||||
if (creds?.accessToken && fresh(creds.expiresAt)) {
|
||||
try {
|
||||
const windows = claudeWindows(await fetchUsage(creds.accessToken));
|
||||
lastGood.set(item.name, { windows, at: Date.now() });
|
||||
return { ...who, windows };
|
||||
} catch (e) {
|
||||
return { ...who, windows: [], error: e.code || "EUPSTREAM" };
|
||||
}
|
||||
}
|
||||
const prev = lastGood.get(item.name);
|
||||
if (!prev) return { ...who, windows: [], error: "EPROFILEEXPIRED" };
|
||||
return { ...who, windows: prev.windows.map(expireReset), asOf: iso(prev.at) };
|
||||
}
|
||||
|
||||
/** A window whose reset has passed is empty again. */
|
||||
function expireReset(w) {
|
||||
return w.resetsAt && Date.parse(w.resetsAt) < Date.now() ? { ...w, usedPercent: 0 } : w;
|
||||
}
|
||||
|
||||
/** The live account first, then every other saved profile (by label). */
|
||||
async function claudeQuotas(force) {
|
||||
let items = [];
|
||||
try {
|
||||
items = listProfiles().items;
|
||||
} catch {
|
||||
/* no profiles dir */
|
||||
}
|
||||
const [live, ...others] = await Promise.all([
|
||||
cached("live", force, liveClaude),
|
||||
...items
|
||||
.filter((it) => !it.active)
|
||||
.map(async (it) => ({
|
||||
id: "claude",
|
||||
active: false,
|
||||
label: it.label,
|
||||
...(await cached(`profile:${it.name}`, force, () => profileClaude(it))),
|
||||
})),
|
||||
]);
|
||||
const mine = items.find((it) => it.active);
|
||||
return [live && { id: "claude", active: true, label: mine?.label || null, ...live }, ...others];
|
||||
}
|
||||
|
||||
/** The newest `rate_limits` in the last `TAIL_BYTES` of a rollout, or null. */
|
||||
function lastRateLimits(file) {
|
||||
let text;
|
||||
try {
|
||||
const fd = fs.openSync(file.path, "r");
|
||||
try {
|
||||
const len = Math.min(file.size, TAIL_BYTES);
|
||||
const buf = Buffer.alloc(len);
|
||||
fs.readSync(fd, buf, 0, len, file.size - len);
|
||||
text = buf.toString("utf8");
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const lines = text.split("\n");
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (!lines[i].includes('"rate_limits"')) continue;
|
||||
try {
|
||||
const rl = JSON.parse(lines[i])?.payload?.rate_limits;
|
||||
if (rl && (rl.primary || rl.secondary)) return { rl, mtime: file.mtime };
|
||||
} catch {
|
||||
/* a cut first line or a partial write */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function codexWindow(w, mtime) {
|
||||
if (!w || typeof w.used_percent !== "number") return null;
|
||||
const resetsMs = Number(w.resets_at)
|
||||
? Number(w.resets_at) * 1000
|
||||
: Number(w.resets_in_seconds)
|
||||
? mtime + Number(w.resets_in_seconds) * 1000
|
||||
: null;
|
||||
// A window that has reset since Codex last wrote is empty again.
|
||||
const used = resetsMs && resetsMs < Date.now() ? 0 : w.used_percent;
|
||||
return {
|
||||
label: windowLabel(Number(w.window_minutes)),
|
||||
usedPercent: used,
|
||||
resetsAt: resetsMs ? new Date(resetsMs).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A JWT's claims, unverified — only for showing who is signed in. */
|
||||
function jwtClaims(token) {
|
||||
return JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString("utf8"));
|
||||
}
|
||||
|
||||
/** Codex's ChatGPT login from `auth.json`: email + access-token expiry. */
|
||||
function codexAccount() {
|
||||
try {
|
||||
const { tokens } = JSON.parse(fs.readFileSync(path.join(getCodexHome(), "auth.json"), "utf8"));
|
||||
const { exp } = jwtClaims(tokens.access_token);
|
||||
return {
|
||||
account: jwtClaims(tokens.id_token).email || null,
|
||||
expiresAt: exp ? new Date(exp * 1000).toISOString() : null,
|
||||
};
|
||||
} catch {
|
||||
return {}; // API-key login, no file, or a token that is not a JWT
|
||||
}
|
||||
}
|
||||
|
||||
function codexQuota() {
|
||||
const files = allRolloutFiles()
|
||||
.sort((a, b) => b.mtime - a.mtime)
|
||||
.slice(0, CODEX_FILES);
|
||||
for (const f of files) {
|
||||
const found = lastRateLimits(f);
|
||||
if (!found) continue;
|
||||
const windows = [found.rl.primary, found.rl.secondary]
|
||||
.map((w) => codexWindow(w, found.mtime))
|
||||
.filter(Boolean);
|
||||
return {
|
||||
id: "codex",
|
||||
active: true,
|
||||
...codexAccount(),
|
||||
windows,
|
||||
asOf: new Date(found.mtime).toISOString(),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function quotas({ force = false } = {}) {
|
||||
const tools = [...(await claudeQuotas(force)), codexQuota()].filter(Boolean);
|
||||
return { tools, checkedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
function __resetCache() {
|
||||
cache.clear();
|
||||
lastGood.clear();
|
||||
}
|
||||
|
||||
module.exports = { quotas, codexWindow, codexAccount, windowLabel, __resetCache };
|
||||
@@ -6,6 +6,7 @@
|
||||
const { Router } = require("express");
|
||||
const { stmts, db } = require("../db");
|
||||
const { parseSources, sourceColumnClause } = require("../lib/source-filter");
|
||||
const { quotas } = require("../lib/quota");
|
||||
const {
|
||||
WEB_SEARCH_PER_1K_SEARCHES,
|
||||
CODE_EXEC_PER_HOUR,
|
||||
@@ -508,6 +509,12 @@ router.get("/cost", (req, res) => {
|
||||
res.json({ ...result, daily_costs });
|
||||
});
|
||||
|
||||
// GET /api/pricing/quota - plan quota spent per native tool (Claude, Codex):
|
||||
// percentages and reset times only, `?force=1` for a Refresh click (server/lib/quota.js).
|
||||
router.get("/quota", async (req, res) => {
|
||||
res.json(await quotas({ force: req.query.force === "1" }));
|
||||
});
|
||||
|
||||
// GET /api/pricing/cost/:sessionId - Get cost for a specific session
|
||||
router.get("/cost/:sessionId", (req, res) => {
|
||||
const rawOffset = parseInt(req.query.tz_offset, 10);
|
||||
|
||||
Reference in New Issue
Block a user