Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52392962fb | |||
| 40fe41bb77 | |||
| 99a2a8277c | |||
| 04b6b98d97 | |||
| 478418f602 | |||
| aff79a787c | |||
| f514602697 | |||
| 605ca9d65e | |||
| 158783d5d5 | |||
| 804b193ac7 | |||
| d397d3a1f6 | |||
| 67210bcd61 | |||
| 4e0b869dd3 | |||
| cfe832383c | |||
| 0737786faf | |||
| 428519335d | |||
| faa56f5c75 | |||
| 7bc4594b94 |
+3
-2
File diff suppressed because one or more lines are too long
+48
@@ -86,6 +86,54 @@ Detaching is `Ctrl-b d` — it leaves the run alive, which is the whole point:
|
||||
the browser terminal and your local terminal are two clients on one pane, and
|
||||
restarting the dashboard never stops a run.
|
||||
|
||||
### macOS
|
||||
|
||||
Status: **code-audited, untested on a Mac.** The Mac-specific paths below are
|
||||
covered by Linux-side unit tests only; the checklist at the end of this section
|
||||
is what a Mac user runs to confirm them.
|
||||
|
||||
```bash
|
||||
brew install node git tmux # Node >= 22.5 for the plugin install (nvm works too)
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin puts `agenthub`
|
||||
```
|
||||
|
||||
- **Node:** a plugin install needs **>= 22.5** (it stores data through
|
||||
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
|
||||
pins the node that installed it and falls back to `node` on `PATH` when a
|
||||
`brew upgrade` or `nvm` switch deletes that binary.
|
||||
- **No systemd, so no auto-start on boot.** The server is a detached process
|
||||
that runs until logout or reboot. It comes back at the next Claude Code
|
||||
session start (within about 10 s), or by hand with `agenthub start`. A
|
||||
discovery entry left over from before the reboot is ignored, so a recycled
|
||||
pid can never pass for a live dashboard.
|
||||
- **Started from a Claude Code session.** The server drops the variables that
|
||||
describe that session (`CLAUDECODE`, `CLAUDE_CODE_SESSION_ID`,
|
||||
`CLAUDE_PLUGIN_ROOT`, …) before anything else runs, so lane panes do not
|
||||
inherit them. Your own configuration (`CLAUDE_CONFIG_DIR`,
|
||||
`CLAUDE_CODE_USE_BEDROCK`, `ANTHROPIC_*`) passes through. A tmux server that
|
||||
an older build started keeps its dirty global env until it exits
|
||||
(`tmux kill-server` once, with no run you care about open).
|
||||
- **Paths.** A lane's directory is stored by its real path, so a typed `/tmp/x`
|
||||
becomes `/private/tmp/x` and a differently-cased path on a case-insensitive
|
||||
volume becomes the one on disk — that is what sessions report, so they bind.
|
||||
Lanes created before this change keep the string they were created with.
|
||||
- **Account switching** uses the login Keychain item `Claude Code-credentials`;
|
||||
macOS may ask once for permission the first time.
|
||||
- **Option key** in the browser terminal types special characters (it is not
|
||||
sent as Meta).
|
||||
|
||||
**Mac checklist** — please run it and report the result on issue #22:
|
||||
|
||||
1. Fresh plugin install from a Terminal `claude` session: `/agenthub-doctor` is all OK (the systemd row says not available) and the dashboard answers on `http://localhost:4820`.
|
||||
2. `tmux show-environment -g | grep -i claude` prints nothing.
|
||||
3. Add a Repo lane by typing its path in a different case: sessions bind to it, and `agenthub stage plan` inside it moves the map.
|
||||
4. **Start Run** in the browser: the terminal attaches, typing works, an image paste into `claude` works, and `exit` returns to the run form.
|
||||
5. Changes panel: diff, stage and commit on a lane.
|
||||
6. Switch profile and check `claude /status` shows the new account. Repeat with a non-default `CLAUDE_HOME`, which also checks the Keychain service name.
|
||||
7. `brew upgrade node && brew cleanup`, then `agenthub status` still works.
|
||||
8. Reboot, open `claude`, and the dashboard comes back within about 10 s.
|
||||
9. `/agenthub-update` completes and the server restarts on the new version.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Clone the repository
|
||||
|
||||
@@ -104,6 +104,12 @@ sudo pacman -S tmux # Arch
|
||||
|
||||
Windows has no native tmux — run AgentHub under WSL2 and install it there.
|
||||
|
||||
macOS (code-audited, untested on a Mac): `brew install node git tmux` (Node
|
||||
>= 22.5 for the plugin), add `~/.local/bin` to `PATH` in `~/.zshrc`. There is
|
||||
no systemd, so the dashboard runs until logout/reboot and comes back at the
|
||||
next Claude Code session, or by hand with `agenthub start`. Details and a
|
||||
checklist to verify it: [INSTALL.md § macOS](INSTALL.md#macos).
|
||||
|
||||
Verify, both from your shell and from the server's point of view:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -92,6 +92,12 @@ sudo pacman -S tmux # Arch
|
||||
|
||||
Windows không có tmux native — chạy AgentHub trong WSL2 và cài tmux ở trong đó.
|
||||
|
||||
macOS (đã rà soát code, chưa kiểm thử trên máy Mac): `brew install node git tmux`
|
||||
(Node >= 22.5 cho bản plugin), thêm `~/.local/bin` vào `PATH` trong `~/.zshrc`.
|
||||
Không có systemd, nên dashboard chạy đến khi đăng xuất/khởi động lại và tự lên
|
||||
lại ở phiên Claude Code kế tiếp, hoặc chạy tay bằng `agenthub start`. Chi tiết
|
||||
và checklist kiểm tra: [INSTALL.md § macOS](INSTALL.md#macos).
|
||||
|
||||
Kiểm tra, cả từ shell của bạn lẫn từ góc nhìn của server:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -9,10 +9,13 @@
|
||||
* touching every one of those call sites to get a designed bubble, and the
|
||||
* next `title=` someone writes would be back to the browser's grey box.
|
||||
* {@link GlobalTooltip} instead listens once at the document: on hover or
|
||||
* keyboard focus it takes the element's `title`, suppresses the native tooltip
|
||||
* keyboard focus (`:focus-visible`, not the focus a click gives) it takes the element's `title`, suppresses the native tooltip
|
||||
* by removing the attribute for as long as the bubble is up, and restores it
|
||||
* on the way out. Nothing else in the app has to know it exists, and a control
|
||||
* keeps its native tooltip (and its accessible name) when JS has not run.
|
||||
* While the title is parked, a control with no other name (icon-only) carries
|
||||
* it as a temporary `aria-label`, so focusing it never leaves it nameless; the
|
||||
* bubble goes away with its control if that is removed from the page.
|
||||
*
|
||||
* ## The look
|
||||
* Anchored to the CONTROL, not the cursor — a bubble that chases the mouse is
|
||||
@@ -39,8 +42,10 @@ const SHOW_DELAY_MS = 350;
|
||||
/** Gap between the control and the bubble, and the minimum viewport margin. */
|
||||
const GAP = 8;
|
||||
|
||||
/** Opt an element (or a subtree) out: `data-tip-off` keeps the native title. */
|
||||
const SELECTOR = "[title]:not([data-tip-off])";
|
||||
/** Opt an element (or a subtree) out: `data-tip-off` keeps the native title.
|
||||
* `[data-tip]` matches the control whose title is parked, so moving between
|
||||
* its own children still resolves to it instead of re-opening the bubble. */
|
||||
const SELECTOR = "[title]:not([data-tip-off]), [data-tip]";
|
||||
|
||||
type Anchored = { text: string; rect: DOMRect };
|
||||
type Placed = { left: number; top: number; arrow: number; above: boolean };
|
||||
@@ -58,16 +63,29 @@ export function GlobalTooltip() {
|
||||
// that unmounts it, Escape).
|
||||
const parked = useRef<HTMLElement | null>(null);
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
// The element whose delay is running, so re-entering it does not restart it.
|
||||
const pending = useRef<HTMLElement | null>(null);
|
||||
|
||||
const restore = useCallback(() => {
|
||||
window.clearTimeout(timer.current);
|
||||
pending.current = null;
|
||||
const el = parked.current;
|
||||
if (el) {
|
||||
// Drop the instance shim `open` installed; the prototype method is back.
|
||||
delete (el as { removeAttribute?: unknown }).removeAttribute;
|
||||
const text = el.dataset.tip;
|
||||
if (text !== undefined) {
|
||||
el.setAttribute("title", text);
|
||||
// React re-set `title` while it was parked (a poll flipped the hint):
|
||||
// that value is current, the parked copy is stale. React never
|
||||
// re-applies an unchanged prop, so writing the old one back would win
|
||||
// for good.
|
||||
if (!el.hasAttribute("title")) el.setAttribute("title", text);
|
||||
delete el.dataset.tip;
|
||||
}
|
||||
if (el.dataset.tipAria !== undefined) {
|
||||
el.removeAttribute("aria-label");
|
||||
delete el.dataset.tipAria;
|
||||
}
|
||||
parked.current = null;
|
||||
}
|
||||
setTip(null);
|
||||
@@ -79,7 +97,9 @@ export function GlobalTooltip() {
|
||||
const text = (el.getAttribute("title") || "").trim();
|
||||
if (!text) return;
|
||||
restore();
|
||||
pending.current = el;
|
||||
timer.current = window.setTimeout(() => {
|
||||
pending.current = null;
|
||||
// Re-read at fire time: the control may have been re-rendered, removed
|
||||
// or had its title changed while the delay was running.
|
||||
if (!el.isConnected) return;
|
||||
@@ -87,6 +107,29 @@ export function GlobalTooltip() {
|
||||
if (!current) return;
|
||||
el.dataset.tip = current;
|
||||
el.removeAttribute("title");
|
||||
// React removing `title` while it is parked calls removeAttribute on an
|
||||
// attribute that is already gone: no MutationRecord, nothing to see —
|
||||
// and `restore` would write the stale text back for good. So, on THIS
|
||||
// element and only while parked, that call is taken as React's intent:
|
||||
// the parked copy is dropped and the bubble hides. (React DOM calls
|
||||
// `node.removeAttribute`, the instance method; `restore` deletes the
|
||||
// shim.) No global prototype is touched.
|
||||
el.removeAttribute = (name: string) => {
|
||||
if (name.toLowerCase() === "title" && el.dataset.tip !== undefined) {
|
||||
delete el.dataset.tip;
|
||||
restore();
|
||||
}
|
||||
Element.prototype.removeAttribute.call(el, name);
|
||||
};
|
||||
// The title was an icon-only control's ONLY name; keep it named.
|
||||
if (
|
||||
!el.hasAttribute("aria-label") &&
|
||||
!el.hasAttribute("aria-labelledby") &&
|
||||
!el.textContent?.trim()
|
||||
) {
|
||||
el.setAttribute("aria-label", current);
|
||||
el.dataset.tipAria = "";
|
||||
}
|
||||
parked.current = el;
|
||||
setTip({ text: current, rect: el.getBoundingClientRect() });
|
||||
}, delay);
|
||||
@@ -101,7 +144,10 @@ export function GlobalTooltip() {
|
||||
};
|
||||
const onOver = (e: PointerEvent) => {
|
||||
const el = target(e);
|
||||
if (el) open(el, SHOW_DELAY_MS);
|
||||
// Already showing or already counting down for this control: a pointer
|
||||
// crossing its children must neither hide nor restart it.
|
||||
if (!el || el === parked.current || el === pending.current) return;
|
||||
open(el, SHOW_DELAY_MS);
|
||||
};
|
||||
const onOut = (e: PointerEvent) => {
|
||||
// Moving between children of the same control must not flicker.
|
||||
@@ -112,9 +158,14 @@ export function GlobalTooltip() {
|
||||
};
|
||||
// Keyboard users get the same tooltip, immediately — a focus ring that
|
||||
// reveals nothing is the whole reason icon-only buttons are hard to learn.
|
||||
// Only KEYBOARD focus, though: a click focuses too, and without the
|
||||
// `:focus-visible` check it reopened the bubble the pointerdown just
|
||||
// closed, with no delay. Hover keeps its own path.
|
||||
const onFocus = (e: FocusEvent) => {
|
||||
const el = target(e);
|
||||
if (el) open(el, 0);
|
||||
if (!el || el === parked.current) return;
|
||||
if (!(e.target instanceof Element) || !e.target.matches(":focus-visible")) return;
|
||||
open(el, 0);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") restore();
|
||||
@@ -126,9 +177,17 @@ export function GlobalTooltip() {
|
||||
document.addEventListener("focusin", onFocus);
|
||||
document.addEventListener("focusout", restore);
|
||||
document.addEventListener("keydown", onKey);
|
||||
// A bubble anchored to a rect is wrong the moment anything moves.
|
||||
window.addEventListener("scroll", restore, true);
|
||||
// A bubble anchored to a rect is wrong the moment its anchor moves — but
|
||||
// only a scroll of the document or of a box containing the anchor moves
|
||||
// it. A log tailing in another panel must not close the bubble.
|
||||
const onScroll = (e: Event) => {
|
||||
const anchor = parked.current || pending.current;
|
||||
const t = e.target;
|
||||
if (!anchor || !(t instanceof Node) || t === document || t.contains(anchor)) restore();
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", restore);
|
||||
window.addEventListener("blur", restore);
|
||||
return () => {
|
||||
document.removeEventListener("pointerover", onOver);
|
||||
document.removeEventListener("pointerout", onOut);
|
||||
@@ -136,12 +195,33 @@ export function GlobalTooltip() {
|
||||
document.removeEventListener("focusin", onFocus);
|
||||
document.removeEventListener("focusout", restore);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("scroll", restore, true);
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", restore);
|
||||
window.removeEventListener("blur", restore);
|
||||
restore();
|
||||
};
|
||||
}, [open, restore]);
|
||||
|
||||
// A control unmounted under a still cursor fires no pointerout: without this
|
||||
// its bubble stays up over whatever replaced it. The same goes for a title
|
||||
// re-rendered under it — the bubble would keep saying the old text, so it
|
||||
// hides and `restore` keeps the new attribute.
|
||||
useEffect(() => {
|
||||
if (!tip) return;
|
||||
const watch = new MutationObserver((records) => {
|
||||
const el = parked.current;
|
||||
if (!el) return;
|
||||
if (
|
||||
!el.isConnected ||
|
||||
records.some((r) => r.type === "attributes" && el.hasAttribute("title"))
|
||||
)
|
||||
restore();
|
||||
});
|
||||
watch.observe(document.body, { childList: true, subtree: true });
|
||||
if (parked.current) watch.observe(parked.current, { attributeFilter: ["title"] });
|
||||
return () => watch.disconnect();
|
||||
}, [tip, restore]);
|
||||
|
||||
// Placed after the first paint, when the bubble's real size is known —
|
||||
// measuring it is the only way to flip and clamp without guessing.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { GlobalTooltip } from "../Tooltip";
|
||||
|
||||
function setup(extra?: React.ReactNode) {
|
||||
@@ -24,6 +25,24 @@ function setup(extra?: React.ReactNode) {
|
||||
|
||||
const hover = (el: Element) => fireEvent.pointerOver(el, { bubbles: true });
|
||||
const unhover = (el: Element) => fireEvent.pointerOut(el, { bubbles: true });
|
||||
// jsdom's :focus-visible is a heuristic over the previous events and leaks
|
||||
// between tests, so the tests say which kind of focus they mean: a spy answers
|
||||
// `:focus-visible` from this flag and passes every other selector through.
|
||||
let keyboardFocus = false;
|
||||
const realMatches = Element.prototype.matches;
|
||||
beforeEach(() => {
|
||||
keyboardFocus = false;
|
||||
vi.spyOn(Element.prototype, "matches").mockImplementation(function (this: Element, sel) {
|
||||
return sel === ":focus-visible"
|
||||
? keyboardFocus && document.activeElement === this
|
||||
: realMatches.call(this, sel);
|
||||
});
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
const tabTo = (el: HTMLElement) => {
|
||||
keyboardFocus = true;
|
||||
act(() => el.focus());
|
||||
};
|
||||
|
||||
beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
|
||||
afterEach(() => vi.useRealTimers());
|
||||
@@ -63,7 +82,7 @@ describe("GlobalTooltip", () => {
|
||||
setup();
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
|
||||
fireEvent.focusIn(button);
|
||||
tabTo(button);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
@@ -105,4 +124,165 @@ describe("GlobalTooltip", () => {
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
|
||||
});
|
||||
it("keeps an icon-only control's accessible name while its title is parked", async () => {
|
||||
setup(
|
||||
<button title="Stage this file">
|
||||
<svg aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
const icon = screen.getByRole("button", { name: "Stage this file" });
|
||||
tabTo(icon);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
|
||||
expect(icon).not.toHaveAttribute("title");
|
||||
expect(screen.getByRole("button", { name: "Stage this file" })).toBe(icon);
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
// Put back exactly as found: no aria-label left behind.
|
||||
expect(icon).toHaveAttribute("title", "Stage this file");
|
||||
expect(icon).not.toHaveAttribute("aria-label");
|
||||
});
|
||||
|
||||
it("does not flicker while the pointer crosses the control's own children", async () => {
|
||||
setup(
|
||||
<button title="Two parts">
|
||||
<span data-testid="part-a">a</span>
|
||||
<span data-testid="part-b">b</span>
|
||||
</button>
|
||||
);
|
||||
const a = screen.getByTestId("part-a");
|
||||
const b = screen.getByTestId("part-b");
|
||||
hover(a);
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
|
||||
|
||||
fireEvent.pointerOut(a, { bubbles: true, relatedTarget: b });
|
||||
fireEvent.pointerOver(b, { bubbles: true, relatedTarget: a });
|
||||
// Still up, with no fresh delay.
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
|
||||
});
|
||||
|
||||
it("hides when its control is removed from the page", async () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Going away">x</button>
|
||||
</>
|
||||
);
|
||||
hover(screen.getByRole("button", { name: "x" }));
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
</>
|
||||
);
|
||||
await settle();
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a title React changed while the bubble was up, instead of writing the stale one back", async () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Pull 2 commits">pull</button>
|
||||
</>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "pull" });
|
||||
hover(button);
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
|
||||
|
||||
// A poll flips the hint while the pointer rests on the control.
|
||||
rerender(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Up to date">pull</button>
|
||||
</>
|
||||
);
|
||||
await settle();
|
||||
// The bubble must not keep advertising the old text.
|
||||
expect(screen.queryByText("Pull 2 commits")).toBeNull();
|
||||
|
||||
unhover(button);
|
||||
expect(button).toHaveAttribute("title", "Up to date");
|
||||
});
|
||||
|
||||
it("leaves no title behind when React removed it while the bubble was up", async () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Pull 2 commits">pull</button>
|
||||
</>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "pull" });
|
||||
hover(button);
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
|
||||
|
||||
// React "removes" an attribute that is already parked: no MutationRecord.
|
||||
rerender(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button>pull</button>
|
||||
</>
|
||||
);
|
||||
await settle();
|
||||
unhover(button);
|
||||
expect(button).not.toHaveAttribute("title");
|
||||
expect(button).not.toHaveAttribute("data-tip");
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not open on the focus a mouse click gives, only on keyboard focus", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
setup(<button title="Second control">second</button>);
|
||||
// A click is pointer focus: :focus-visible does not match.
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
|
||||
await user.click(button);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
|
||||
tabTo(screen.getByRole("button", { name: "second" }));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Second control");
|
||||
});
|
||||
|
||||
it("stays up when an unrelated panel scrolls, and hides when the anchor's own scroller moves", async () => {
|
||||
render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<div data-testid="scroller">
|
||||
<button title="Inside the scroller">inside</button>
|
||||
</div>
|
||||
<div data-testid="elsewhere" />
|
||||
</>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "inside" });
|
||||
hover(button);
|
||||
await settle();
|
||||
|
||||
fireEvent.scroll(screen.getByTestId("elsewhere"));
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Inside the scroller");
|
||||
|
||||
fireEvent.scroll(screen.getByTestId("scroller"));
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
expect(button).toHaveAttribute("title", "Inside the scroller");
|
||||
});
|
||||
|
||||
it("hides when the document itself scrolls", async () => {
|
||||
setup();
|
||||
hover(screen.getByRole("button", { name: "refresh" }));
|
||||
await settle();
|
||||
fireEvent.scroll(document);
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,18 +27,6 @@ import { api } from "../../lib/api";
|
||||
import type { AiRouter, CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneTool, LaneWorktree } from "../../lib/types";
|
||||
|
||||
/** Mirrors server/lib/worktree.js `slugify`, for the branch-name PREVIEW only.
|
||||
* Nothing is sent from it: an empty branch field means "let the server name
|
||||
* it", which is how `LANE_BRANCH_PREFIX` keeps working. */
|
||||
function slugPreview(title: string) {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
}
|
||||
|
||||
/** Polls the lane until the background `git worktree add` finishes (status
|
||||
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
|
||||
* lane record, or `null` on timeout. */
|
||||
@@ -391,7 +379,7 @@ export function AddLaneModal({
|
||||
!done &&
|
||||
(!sourceRepo.trim() ||
|
||||
(mode === "worktree" &&
|
||||
(!title.trim() || !branches || (branchMode === "existing" && !branch.trim()))));
|
||||
(!title.trim() || !branches?.length || (branchMode === "existing" && !branch.trim()))));
|
||||
|
||||
/** branch name -> the worktree directory already holding it. git allows one
|
||||
* checkout per branch, so these are the branches this form cannot use. */
|
||||
@@ -565,7 +553,18 @@ export function AddLaneModal({
|
||||
{t("addLaneBaseLabel")}
|
||||
</label>
|
||||
{branches.length === 0 ? (
|
||||
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||||
// No commit, nothing to fork: the server refuses this with
|
||||
// EEMPTYREPO, so the one way forward is offered instead.
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("repo")}
|
||||
className="rounded-md border border-border-light px-2 py-0.5 text-xs text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{t("addLaneNoBranchesUseRepo")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
id="add-lane-base"
|
||||
@@ -589,12 +588,10 @@ export function AddLaneModal({
|
||||
id="add-lane-branch"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
// The placeholder is what the SERVER will name the branch
|
||||
// if this is left empty, so the default is visible without
|
||||
// the form having to duplicate LANE_BRANCH_PREFIX.
|
||||
placeholder={
|
||||
title.trim() ? `feat/${slugPreview(title)}` : t("addLaneBranchPlaceholder")
|
||||
}
|
||||
// Neutral on purpose: an empty field lets the SERVER name
|
||||
// the branch, under a LANE_BRANCH_PREFIX this form cannot
|
||||
// see, so any `feat/…` preview here could be wrong.
|
||||
placeholder={t("addLaneBranchPlaceholder")}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHintAuto")}</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @file One lane's card: branch/CI/PR facts, the "needs you"
|
||||
* @file One lane's card: CI/PR facts, the "needs you"
|
||||
* banner sourced from Claude Code's Notification hook, and (for managed
|
||||
* lanes only) a ⋯ menu holding the rare `reset worktree` action behind
|
||||
* DestructiveLaneModal. A dead lane (its driving session went silent while
|
||||
@@ -16,74 +16,33 @@
|
||||
* install / MCP sync / dev-sync check / create-update readme) and the
|
||||
* remove-lane button live in Workspace's page header / pipeline-picker row
|
||||
* instead of here, so they stay reachable and un-clipped regardless of this
|
||||
* card's own scroll area.
|
||||
* card's own scroll area. The working copy — branch, uncommitted counts, the
|
||||
* git action bar — is not here either: it heads the Changes panel
|
||||
* (`LaneGitHeader`), beside the diff it describes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
||||
import { LaneGitBar } from "./LaneGitBar";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import type { AiRouter } from "../../lib/api";
|
||||
import type { Lane, LaneGitFacts, LaneGitFile, LaneRuntime, NamedLock } from "../../lib/types";
|
||||
import type { Lane, LaneRuntime, NamedLock } from "../../lib/types";
|
||||
|
||||
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
|
||||
* each call is five git subprocesses server-side, and a branch name does not
|
||||
* change on the timescale the lane list is polled at. */
|
||||
const GIT_REFRESH_MS = 30_000;
|
||||
|
||||
/** How often a mounted card re-probes its stack. Faster than the git refresh
|
||||
* because a stack dying is exactly what the user needs to see, and slower than
|
||||
/** How often a mounted card re-probes its stack. Faster than the Changes panel's
|
||||
* git refresh because a stack dying is exactly what the user needs to see, and slower than
|
||||
* the lane poll because each call opens a socket per declared port. */
|
||||
const RUNTIME_REFRESH_MS = 10_000;
|
||||
|
||||
/** How often a mounted card re-reads locks held by this lane. Same refresh rate
|
||||
* as the git facts (slow, since lock state changes infrequently). */
|
||||
* as the Changes panel's git facts (slow, since lock state changes infrequently). */
|
||||
const LOCKS_REFRESH_MS = 30_000;
|
||||
|
||||
/**
|
||||
* The lane's own working copy, fetched per card rather than folded into the
|
||||
* polled lane list. Absent facts are not an error state: a lane may point at a
|
||||
* plain directory, and it renders as a card without a git row.
|
||||
*/
|
||||
function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => void] {
|
||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||
// Bumped by the git action bar: a commit or a pull changes every fact on
|
||||
// this row, and waiting out the 30s poll makes the button look inert.
|
||||
const [nonce, setNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const read = () => {
|
||||
api.lanes
|
||||
.git(laneId)
|
||||
.then((f) => {
|
||||
if (alive) setFacts(keepIfSame(f));
|
||||
})
|
||||
.catch(() => {
|
||||
// Silent: a card that cannot read git shows the rest of itself. An
|
||||
// error banner here would fire on every lane on every server blip.
|
||||
if (alive) setFacts(keepIfSame<LaneGitFacts>({ available: false }));
|
||||
});
|
||||
};
|
||||
read();
|
||||
const timer = setInterval(read, GIT_REFRESH_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [laneId, nonce]);
|
||||
|
||||
return [facts, () => setNonce((n) => n + 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane's own application stack. Same shape and same silence as the git
|
||||
* facts above: a lane without a `.agenthub/profile` simply has no runtime row, which
|
||||
* facts in LaneGitHeader: a lane without a `.agenthub/profile` simply has no runtime row, which
|
||||
* is the common case and not an error worth a banner.
|
||||
*
|
||||
* `bump` lets an up/down action re-read immediately instead of waiting out the
|
||||
@@ -191,7 +150,7 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
|
||||
if (alive) setRouters(r.items);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silent, same contract as the git/runtime pollers above: a card that
|
||||
// Silent, same contract as the runtime poller above: a card that
|
||||
// cannot list routers simply shows no router row.
|
||||
if (alive) setRouters([]);
|
||||
});
|
||||
@@ -473,147 +432,11 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Porcelain status code -> the letter shown and the colour it is shown in.
|
||||
* Keyed on the code's first meaningful character, so an index-side change
|
||||
* (`M `), a worktree-side one (` M`) and both (`MM`) read the same. */
|
||||
const GIT_STATUS_STYLE: Record<string, string> = {
|
||||
M: "text-status-warning",
|
||||
A: "text-status-success",
|
||||
D: "text-status-danger",
|
||||
R: "text-accent",
|
||||
C: "text-accent",
|
||||
U: "text-status-danger",
|
||||
"?": "text-fg-muted",
|
||||
};
|
||||
|
||||
/**
|
||||
* The file list behind a lane's uncommitted counts: which files, what happened
|
||||
* to each, and how many lines it gained and lost.
|
||||
*
|
||||
* It replaced a native `title` tooltip, which could show the same paths but
|
||||
* neither colour them nor align the deltas — and a plain porcelain dump is not
|
||||
* something you read at a glance.
|
||||
*
|
||||
* PORTALLED to `document.body` and `position: fixed`, for the same reason this
|
||||
* file's own header says the tools dropdown was moved out of the card: a lane
|
||||
* card renders inside the Workspace's `max-h-[45vh] overflow-y-auto` box, and
|
||||
* an absolutely positioned panel would be clipped by it for every card that is
|
||||
* not at the very top. It flips above its anchor rather than off the bottom of
|
||||
* the window, and clamps to the right edge.
|
||||
*/
|
||||
function LaneGitFilesPanel({
|
||||
files,
|
||||
hidden,
|
||||
anchor,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
}: {
|
||||
files: LaneGitFile[];
|
||||
hidden: number;
|
||||
/** The counts button this panel hangs off. */
|
||||
anchor: HTMLElement | null;
|
||||
onPointerEnter: () => void;
|
||||
onPointerLeave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
// After the first paint, when the panel's real height is known: place it
|
||||
// under the anchor, or above it when that would run off the bottom.
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !panelRef.current) return;
|
||||
const a = anchor.getBoundingClientRect();
|
||||
const p = panelRef.current.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
const top =
|
||||
a.bottom + gap + p.height > window.innerHeight ? a.top - p.height - gap : a.bottom + gap;
|
||||
setPos({
|
||||
top: Math.max(8, top),
|
||||
left: Math.max(8, Math.min(a.left, document.documentElement.clientWidth - p.width - 8)),
|
||||
});
|
||||
}, [anchor, files]);
|
||||
// Totals are over the LISTED files only — the cap means the rest are unknown,
|
||||
// and a total that silently omits them would be a wrong number rather than a
|
||||
// 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);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
data-testid="lane-git-files"
|
||||
role="group"
|
||||
aria-label={t("git.filesHeading", { count: files.length })}
|
||||
// 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}
|
||||
onMouseLeave={onPointerLeave}
|
||||
style={{
|
||||
position: "fixed",
|
||||
zIndex: 60,
|
||||
top: pos?.top ?? 0,
|
||||
left: pos?.left ?? 0,
|
||||
// Hidden for the one frame before it has been measured, so it never
|
||||
// flashes at the top-left corner.
|
||||
visibility: pos ? "visible" : "hidden",
|
||||
}}
|
||||
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="ml-auto shrink-0 font-mono tabular-nums">
|
||||
<span className="text-status-success">+{added}</span>{" "}
|
||||
<span className="text-status-danger">−{deleted}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-0.5 font-mono text-[11px]">
|
||||
{files.map((f) => {
|
||||
const code = f.status.trim()[0] || "?";
|
||||
return (
|
||||
<li key={f.path} className="flex items-baseline gap-2">
|
||||
<span
|
||||
className={`w-3 shrink-0 font-semibold ${GIT_STATUS_STYLE[code] || "text-fg-muted"}`}
|
||||
title={f.status}
|
||||
>
|
||||
{code}
|
||||
</span>
|
||||
{/* The DIRECTORY truncates, never the filename: the name is what
|
||||
you scan for, the path is only there to disambiguate it. */}
|
||||
<span className="flex min-w-0 flex-1 items-baseline" title={f.path}>
|
||||
<span className="truncate text-fg-muted">{f.path.replace(/[^/]+$/, "")}</span>
|
||||
<span className="shrink-0 text-fg-primary">{f.path.split("/").pop()}</span>
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{f.added === null && f.deleted === null ? (
|
||||
<span className="text-fg-muted">{t("git.noDelta")}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-status-success">+{f.added ?? 0}</span>{" "}
|
||||
<span className="text-status-danger">−{f.deleted ?? 0}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{hidden > 0 && (
|
||||
<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
|
||||
);
|
||||
}
|
||||
|
||||
export default function LaneCard({
|
||||
lane,
|
||||
onAction,
|
||||
childWorktrees,
|
||||
onSelectLane,
|
||||
onNewLaneFromRepo,
|
||||
}: {
|
||||
lane: Lane;
|
||||
onAction: (action: string, body?: Record<string, unknown>) => void;
|
||||
@@ -623,31 +446,12 @@ export default function LaneCard({
|
||||
childWorktrees?: Lane[];
|
||||
/** Jumps the Workspace page's selection to another lane's card. */
|
||||
onSelectLane?: (id: number) => void;
|
||||
/** Opens the Add-lane flow prefilled with this lane's repo, for a sibling
|
||||
* worktree on `branch`. Omitted where that flow is not reachable. */
|
||||
onNewLaneFromRepo?: (repo: string, branch?: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [runtimeBump, setRuntimeBump] = useState(0);
|
||||
const [bootLine, setBootLine] = useState<string | null>(null);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [countsAnchor, setCountsAnchor] = useState<HTMLButtonElement | null>(null);
|
||||
// Closing is DELAYED: the panel is portalled away from the trigger, so the
|
||||
// pointer leaves the trigger before it can land on the panel it is heading
|
||||
// for. Any enter — trigger or panel — cancels the pending close.
|
||||
const filesCloseTimer = useRef<number | undefined>(undefined);
|
||||
const openFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
setFilesOpen(true);
|
||||
}, []);
|
||||
const closeFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
|
||||
}, []);
|
||||
useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []);
|
||||
const [git, refreshGit] = useLaneGitFacts(lane.id);
|
||||
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
||||
const locks = useLaneLocks(lane.slot);
|
||||
|
||||
@@ -676,7 +480,9 @@ export default function LaneCard({
|
||||
<>
|
||||
<div
|
||||
data-testid={`lane-card-${lane.id}`}
|
||||
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light"
|
||||
// Every row below is conditional: a lane with nothing to report
|
||||
// renders no frame at all rather than an empty box.
|
||||
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light empty:hidden"
|
||||
>
|
||||
{(lane.ci_status || lane.tool === "codex") && (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
@@ -693,11 +499,8 @@ export default function LaneCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lane.needs_action && (
|
||||
<div className="mb-2 rounded border border-status-warning/50 bg-status-warning/10 px-2 py-1 text-xs text-status-warning">
|
||||
⚠ {lane.needs_action}
|
||||
</div>
|
||||
)}
|
||||
{/* No needs-you banner here: the strip card already flags it, and the
|
||||
console directly below is where the prompt actually waits. */}
|
||||
|
||||
{/* Runtime strip: the lane's own app stack, shown only for lanes that
|
||||
declare a profile. A port that drifted from `base + slot` is called
|
||||
@@ -773,73 +576,6 @@ export default function LaneCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* One line, because every part of it is short and the panel is
|
||||
narrow: branch, head, subject, and the uncommitted counts read as a
|
||||
sentence rather than a four-row block. The cwd is NOT here — the
|
||||
lane-detail header above this card already spells it out. */}
|
||||
{git?.available && (
|
||||
<div className="mb-2">
|
||||
<div
|
||||
data-testid="lane-git"
|
||||
className="flex items-baseline gap-2 truncate font-mono text-[11px] text-fg-secondary"
|
||||
title={git.subject}
|
||||
>
|
||||
<span className="shrink-0">⑂ {git.branch}</span>
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="truncate text-fg-muted">{git.subject}</span>
|
||||
{(git.dirty > 0 || git.untracked > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
ref={setCountsAnchor}
|
||||
data-testid="lane-git-counts"
|
||||
aria-expanded={filesOpen}
|
||||
onMouseEnter={openFiles}
|
||||
onMouseLeave={closeFiles}
|
||||
// Focus and click open it too: hover is not a gesture a
|
||||
// keyboard or a touchscreen has.
|
||||
onFocus={openFiles}
|
||||
onBlur={closeFiles}
|
||||
onClick={() => setFilesOpen((v) => !v)}
|
||||
className="shrink-0 cursor-help text-status-warning/80 underline decoration-dotted underline-offset-2"
|
||||
>
|
||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{filesOpen && git.files?.length > 0 && (
|
||||
<LaneGitFilesPanel
|
||||
files={git.files}
|
||||
hidden={git.dirty + git.untracked - git.files.length}
|
||||
anchor={countsAnchor}
|
||||
onPointerEnter={openFiles}
|
||||
onPointerLeave={closeFiles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{git?.available && (
|
||||
<LaneGitBar
|
||||
laneId={lane.id}
|
||||
git={git}
|
||||
onChanged={refreshGit}
|
||||
/* A sibling worktree forks from the lane's SOURCE repo when it
|
||||
has one (this lane is itself a worktree of it); an adopted lane
|
||||
is its own repo. */
|
||||
onNewLane={
|
||||
onNewLaneFromRepo
|
||||
? (branch) => onNewLaneFromRepo(lane.source_repo || lane.cwd, branch)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* The lane's own recorded branch, shown only when git could not be
|
||||
read — otherwise it duplicates the live branch above. */}
|
||||
{!git?.available && lane.branch && (
|
||||
<div className="mb-2 truncate font-mono text-[11px] text-fg-secondary">
|
||||
⑂ {lane.branch}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{childWorktrees && childWorktrees.length > 0 && (
|
||||
<div
|
||||
data-testid="lane-child-worktrees"
|
||||
@@ -854,7 +590,7 @@ export default function LaneCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectLane?.(w.id)}
|
||||
className="truncate text-left text-blue-400 hover:underline"
|
||||
className="truncate text-left text-blue-700 dark:text-blue-400 hover:underline"
|
||||
title={w.cwd}
|
||||
>
|
||||
#{w.id} {w.title || w.cwd} · {w.status}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,11 +13,25 @@
|
||||
* - Line pairing for split view happens here, on the rows the server already
|
||||
* numbered. The server stays the single source of what changed; this file
|
||||
* only decides how it looks.
|
||||
* Past `WINDOW_THRESHOLD` rows a view renders only the rows on screen (plus an
|
||||
* overscan) between two spacers. Rows never wrap — the box scrolls sideways —
|
||||
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
|
||||
* (`visibleRange`); the content's min-width comes from the longest line, so
|
||||
* the horizontal extent does not jump as rows come and go.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
|
||||
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
|
||||
|
||||
@@ -25,11 +39,125 @@ export type DiffLayout = "unified" | "split";
|
||||
|
||||
/**
|
||||
* How tall a diff box may be. `fill` (the single-file view) takes the height
|
||||
* its parent gives it; otherwise — the review list, which stacks many files —
|
||||
* each file is capped so its own horizontal scrollbar stays on screen.
|
||||
* its parent gives it. In the review list, which stacks many files, a diff
|
||||
* grows to its full height so the PANEL is the one vertical scroller — a
|
||||
* 60vh box per file nested inside it made hunk navigation scroll two boxes
|
||||
* at once and land nowhere predictable. Only a windowed diff (above
|
||||
* WINDOW_THRESHOLD rows) keeps the cap, because windowing needs its own
|
||||
* scroller.
|
||||
*/
|
||||
function boxHeight(fill: boolean) {
|
||||
return fill ? "min-h-0 flex-1" : "max-h-[60vh]";
|
||||
function boxHeight(fill: boolean, windowed: boolean) {
|
||||
return fill ? "min-h-0 flex-1" : windowed ? "max-h-[60vh]" : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring `el` to the top of the scroller that actually scrolls it, below
|
||||
* `pad` px of sticky header. Not `scrollIntoView`: that moves EVERY scrolling
|
||||
* ancestor, the page included.
|
||||
*/
|
||||
function scrollToTop(el: HTMLElement, pad: number) {
|
||||
let s = el.parentElement;
|
||||
while (
|
||||
s &&
|
||||
!(s.scrollHeight > s.clientHeight && /auto|scroll/.test(getComputedStyle(s).overflowY))
|
||||
)
|
||||
s = s.parentElement;
|
||||
if (!s) return;
|
||||
s.scrollTop += el.getBoundingClientRect().top - s.getBoundingClientRect().top - pad;
|
||||
}
|
||||
|
||||
/** Rows above which a view windows; at or below it renders exactly as before. */
|
||||
export const WINDOW_THRESHOLD = 500;
|
||||
/** A windowed row's pinned height — the rows' own 11px × 1.45 line box, rounded. */
|
||||
export const ROW_PX = 16;
|
||||
const OVERSCAN = 20;
|
||||
const FIXED: CSSProperties = { height: ROW_PX };
|
||||
|
||||
/**
|
||||
* The `[start, end)` item range to render for a scroll position. `scrollTop`
|
||||
* is clamped to the content first, so a stale offset (content that shrank, a
|
||||
* layout switch) still lands on real rows instead of an empty window.
|
||||
*/
|
||||
export function visibleRange(
|
||||
scrollTop: number,
|
||||
viewport: number,
|
||||
rowHeight: number,
|
||||
count: number,
|
||||
overscan: number
|
||||
) {
|
||||
const top = Math.min(Math.max(0, scrollTop), Math.max(0, count * rowHeight - viewport));
|
||||
const start = Math.max(0, Math.floor(top / rowHeight) - overscan);
|
||||
const end = Math.min(count, Math.ceil((top + viewport) / rowHeight) + overscan);
|
||||
return { start: Math.min(start, end), end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Windowing state for one scroller of `count` items. Off (the whole range)
|
||||
* at or below the threshold.
|
||||
*/
|
||||
function useWindow(count: number) {
|
||||
const [top, setTop] = useState(0);
|
||||
const on = count > WINDOW_THRESHOLD;
|
||||
// The offset is tracked even while windowing is off, so a diff that GROWS
|
||||
// past the threshold (expanded context) windows where the reader is, not at
|
||||
// row 0. A ref, so an unwindowed diff does not re-render on every scroll.
|
||||
const lastTop = useRef(0);
|
||||
// count 0 = no scroller here (split layout): the browser drops its offset,
|
||||
// so coming back to unified must start from 0, not the pre-split row.
|
||||
if (count === 0) lastTop.current = 0;
|
||||
const [wasOn, setWasOn] = useState(on);
|
||||
if (wasOn !== on) {
|
||||
setWasOn(on);
|
||||
setTop(on ? lastTop.current : 0);
|
||||
}
|
||||
// ponytail: the viewport is taken as the window height — never smaller than
|
||||
// a box that lives inside the window, so it only over-renders a few dozen
|
||||
// rows, and it needs no ResizeObserver to follow a panel resize.
|
||||
const range = on
|
||||
? visibleRange(top, window.innerHeight, ROW_PX, count, OVERSCAN)
|
||||
: { start: 0, end: count };
|
||||
const onScroll = (el: HTMLElement) => {
|
||||
lastTop.current = el.scrollTop;
|
||||
if (on) setTop(el.scrollTop);
|
||||
};
|
||||
return { on, range, onScroll };
|
||||
}
|
||||
|
||||
/** Columns a line occupies under `whitespace-pre` (a tab counted at its widest). */
|
||||
function longest(texts: Iterable<string>) {
|
||||
let max = 0;
|
||||
for (const t of texts) {
|
||||
let n = t.length;
|
||||
for (let i = t.indexOf("\t"); i !== -1; i = t.indexOf("\t", i + 1)) n += 7;
|
||||
if (n > max) max = n;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/** Stands in for `rows` off-screen rows. */
|
||||
function Spacer({ rows }: { rows: number }) {
|
||||
return rows > 0 ? <div data-diff-spacer aria-hidden style={{ height: rows * ROW_PX }} /> : null;
|
||||
}
|
||||
|
||||
/** A windowed list: spacer, the rendered slice, spacer, at a stable width. */
|
||||
function Windowed({
|
||||
count,
|
||||
range,
|
||||
minWidth,
|
||||
children,
|
||||
}: {
|
||||
count: number;
|
||||
range: { start: number; end: number };
|
||||
minWidth: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div data-diff-content style={{ minWidth }}>
|
||||
<Spacer rows={range.start} />
|
||||
{children}
|
||||
<Spacer rows={count - range.end} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */
|
||||
@@ -86,26 +214,51 @@ function emphasis(
|
||||
};
|
||||
}
|
||||
|
||||
/** Pair each run of removed lines with the run of added lines following it. */
|
||||
function pairs(
|
||||
lines: LaneDiffLine[]
|
||||
): Array<{ left: LaneDiffLine | null; right: LaneDiffLine | null }> {
|
||||
const out: Array<{ left: LaneDiffLine | null; right: LaneDiffLine | null }> = [];
|
||||
type PairRow = { left: LaneDiffLine | null; right: LaneDiffLine | null; emph: EmphPair | null };
|
||||
type EmphPair = { del: [number, number]; add: [number, number] };
|
||||
/** A hunk's split-view rows plus each paired line's emphasis, keyed by its
|
||||
* index in `hunk.lines` (what the unified view renders by). */
|
||||
type Analysed = { rows: PairRow[]; emph: Map<number, [number, number]> };
|
||||
|
||||
// Keyed by the hunk object: a hunk is immutable once fetched, so both split
|
||||
// columns and every re-render of the same data share one pass. A refetch
|
||||
// brings new objects and the old entries go with them.
|
||||
const analysed = new WeakMap<LaneDiffHunk, Analysed>();
|
||||
|
||||
/** Pair each run of removed lines with the run of added lines following it,
|
||||
* carrying indices so emphasis lands without searching the hunk again. */
|
||||
export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
||||
const hit = analysed.get(hunk);
|
||||
if (hit) return hit;
|
||||
const lines = hunk.lines;
|
||||
const rows: PairRow[] = [];
|
||||
const emph = new Map<number, [number, number]>();
|
||||
for (let i = 0; i < lines.length; ) {
|
||||
const line = lines[i]!;
|
||||
if (line.type === "ctx") {
|
||||
out.push({ left: line, right: line });
|
||||
rows.push({ left: line, right: line, emph: null });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const dels: LaneDiffLine[] = [];
|
||||
const adds: LaneDiffLine[] = [];
|
||||
while (i < lines.length && lines[i]!.type === "del") dels.push(lines[i++]!);
|
||||
while (i < lines.length && lines[i]!.type === "add") adds.push(lines[i++]!);
|
||||
for (let k = 0; k < Math.max(dels.length, adds.length); k += 1) {
|
||||
out.push({ left: dels[k] ?? null, right: adds[k] ?? null });
|
||||
const delStart = i;
|
||||
while (i < lines.length && lines[i]!.type === "del") i += 1;
|
||||
const addStart = i;
|
||||
while (i < lines.length && lines[i]!.type === "add") i += 1;
|
||||
const dels = addStart - delStart;
|
||||
const adds = i - addStart;
|
||||
for (let k = 0; k < Math.max(dels, adds); k += 1) {
|
||||
const left = k < dels ? lines[delStart + k]! : null;
|
||||
const right = k < adds ? lines[addStart + k]! : null;
|
||||
const e = left && right ? emphasis(left.text, right.text) : null;
|
||||
if (e) {
|
||||
emph.set(delStart + k, e.del);
|
||||
emph.set(addStart + k, e.add);
|
||||
}
|
||||
rows.push({ left, right, emph: e });
|
||||
}
|
||||
}
|
||||
const out = { rows, emph };
|
||||
analysed.set(hunk, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -115,9 +268,26 @@ function Gutter({ n }: { n: number | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row. */
|
||||
function Row({ tint, children }: { tint: string; children: React.ReactNode }) {
|
||||
return <div className={`flex w-max min-w-full ${tint}`}>{children}</div>;
|
||||
/** 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. */
|
||||
function Row({
|
||||
tint,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
tint: string;
|
||||
style?: CSSProperties;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex w-max min-w-full ${tint}`}
|
||||
style={style}
|
||||
data-diff-item={style ? "" : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A /20 fill plus a 3px status bar at the row's left edge, so +/- rows still
|
||||
@@ -135,44 +305,199 @@ function markerClass(type: LaneDiffLine["type"]) {
|
||||
return "text-fg-muted";
|
||||
}
|
||||
|
||||
function HunkHeader({ hunk }: { hunk: LaneDiffHunk }) {
|
||||
const at = hunk.header.split("@@")[1];
|
||||
/** Per-hunk review controls (Undo / Keep), drawn in a bar under each hunk's
|
||||
* last changed line. A context rather than a prop because lines render from
|
||||
* four places; `hunks` is what the bar's "n of N" and its arrows walk. */
|
||||
const HunkActions = createContext<{
|
||||
actions: (hunk: LaneDiffHunk) => ReactNode;
|
||||
hunks: LaneDiffHunk[];
|
||||
go: (index: number) => void;
|
||||
} | null>(null);
|
||||
|
||||
/** Index of a hunk's last +/- line (unified), or last row with one (split). */
|
||||
function lastChangedLine(hunk: LaneDiffHunk) {
|
||||
for (let i = hunk.lines.length - 1; i >= 0; i -= 1) {
|
||||
if (hunk.lines[i]!.type !== "ctx") return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
function lastChangedRow(hunk: LaneDiffHunk) {
|
||||
const rows = analyseHunk(hunk).rows;
|
||||
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
||||
const r = rows[i]!;
|
||||
if ((r.left && r.left.type !== "ctx") || (r.right && r.right.type !== "ctx")) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** The review bar: hangs just BELOW the last changed line, pinned to the
|
||||
* visible right edge, so it sits on the change it acts on — the editor-style
|
||||
* "^ v 2 of 3 · Undo · Keep" instead of glyphs at the end of the `@@` line. */
|
||||
function HunkBar({ hunk }: { hunk: LaneDiffHunk }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const ctx = useContext(HunkActions);
|
||||
if (!ctx) return null;
|
||||
const index = ctx.hunks.indexOf(hunk);
|
||||
const total = ctx.hunks.length;
|
||||
const nav =
|
||||
"rounded px-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-30";
|
||||
return (
|
||||
<div className="bg-surface-2 px-2 py-0.5 text-[10px] text-fg-muted">
|
||||
{at ? `@@${at}@@` : hunk.header}
|
||||
{hunk.context ? ` ${hunk.context}` : ""}
|
||||
<span className="sticky right-2 z-10 ml-auto h-0 w-0 self-start">
|
||||
<span
|
||||
data-testid="lane-hunk-bar"
|
||||
className="absolute right-0 top-[18px] flex items-center gap-1 whitespace-nowrap rounded border border-border-light bg-surface-1 px-1 py-0.5 font-sans text-[10px] leading-[14px] text-fg-secondary shadow-card"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("changes.prevHunk")}
|
||||
title={t("changes.prevHunk")}
|
||||
disabled={index <= 0}
|
||||
onClick={() => ctx.go(index - 1)}
|
||||
className={nav}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("changes.nextHunk")}
|
||||
title={t("changes.nextHunk")}
|
||||
disabled={index >= total - 1}
|
||||
onClick={() => ctx.go(index + 1)}
|
||||
className={nav}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="px-0.5 tabular-nums text-fg-muted">
|
||||
{t("changes.hunkOf", { n: index + 1, total })}
|
||||
</span>
|
||||
{ctx.actions(hunk)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function HunkHeader({ hunk, fixed }: { hunk: LaneDiffHunk; fixed?: boolean }) {
|
||||
const ctx = useContext(HunkActions);
|
||||
const at = hunk.header.split("@@")[1];
|
||||
// Windowed, the header is one ROW_PX row like the lines around it.
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 bg-surface-2 px-2 text-[10px] text-fg-muted ${
|
||||
fixed ? "overflow-hidden whitespace-nowrap leading-4" : "py-0.5"
|
||||
}`}
|
||||
style={fixed ? FIXED : undefined}
|
||||
data-diff-item={fixed ? "" : undefined}
|
||||
data-hunk-index={ctx ? ctx.hunks.indexOf(hunk) : undefined}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{at ? `@@${at}@@` : hunk.header}
|
||||
{hunk.context ? ` ${hunk.context}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnifiedLine({
|
||||
line,
|
||||
lang,
|
||||
emph,
|
||||
style,
|
||||
bar,
|
||||
}: {
|
||||
line: LaneDiffLine;
|
||||
lang: string;
|
||||
emph?: [number, number];
|
||||
style?: CSSProperties;
|
||||
/** This is the hunk's last changed line: hang the review bar under it. */
|
||||
bar?: LaneDiffHunk;
|
||||
}) {
|
||||
return (
|
||||
<Row tint={rowTint(line.type)} style={style}>
|
||||
<Gutter n={line.oldLine} />
|
||||
<Gutter n={line.newLine} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<span className={`select-none ${markerClass(line.type)}`}>
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph} />
|
||||
</span>
|
||||
{bar && <HunkBar hunk={bar} />}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
|
||||
const emph = new Map<number, [number, number]>();
|
||||
for (const { left, right } of pairs(hunk.lines)) {
|
||||
if (!left || !right || left.type !== "del" || right.type !== "add") continue;
|
||||
const e = emphasis(left.text, right.text);
|
||||
if (!e) continue;
|
||||
emph.set(hunk.lines.indexOf(left), e.del);
|
||||
emph.set(hunk.lines.indexOf(right), e.add);
|
||||
}
|
||||
const { emph } = analyseHunk(hunk);
|
||||
const last = lastChangedLine(hunk);
|
||||
return (
|
||||
<div>
|
||||
<HunkHeader hunk={hunk} />
|
||||
{hunk.lines.map((line, i) => (
|
||||
<Row key={i} tint={rowTint(line.type)}>
|
||||
<Gutter n={line.oldLine} />
|
||||
<Gutter n={line.newLine} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<span className={`select-none ${markerClass(line.type)}`}>
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph.get(i)} />
|
||||
</span>
|
||||
</Row>
|
||||
<UnifiedLine
|
||||
key={i}
|
||||
line={line}
|
||||
lang={lang}
|
||||
emph={emph.get(i)}
|
||||
bar={i === last ? hunk : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One windowable item: a hunk's header (`i < 0`) or its i-th row — a line in
|
||||
* unified, a paired row in split. */
|
||||
type Item = { hunk: LaneDiffHunk; i: number };
|
||||
|
||||
function flatten(hunks: LaneDiffHunk[], layout: DiffLayout): Item[] {
|
||||
const out: Item[] = [];
|
||||
for (const hunk of hunks) {
|
||||
out.push({ hunk, i: -1 });
|
||||
const n = layout === "split" ? analyseHunk(hunk).rows.length : hunk.lines.length;
|
||||
for (let i = 0; i < n; i += 1) out.push({ hunk, i });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function longestInHunks(hunks: LaneDiffHunk[]) {
|
||||
return longest(
|
||||
(function* () {
|
||||
for (const h of hunks) for (const l of h.lines) yield l.text;
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
function SplitLine({
|
||||
row,
|
||||
lang,
|
||||
side,
|
||||
style,
|
||||
bar,
|
||||
}: {
|
||||
row: PairRow;
|
||||
lang: string;
|
||||
side: "left" | "right";
|
||||
style?: CSSProperties;
|
||||
bar?: LaneDiffHunk;
|
||||
}) {
|
||||
const line = side === "left" ? row.left : row.right;
|
||||
return (
|
||||
<Row tint={rowTint(line?.type ?? null)} style={style}>
|
||||
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
|
||||
<span className="whitespace-pre px-1">
|
||||
{line ? (
|
||||
<Code
|
||||
text={line.text}
|
||||
lang={lang}
|
||||
emph={side === "left" ? row.emph?.del : row.emph?.add}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
{bar && <HunkBar hunk={bar} />}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
/** One side of the split view: a hunk header, then that side's rows. */
|
||||
function SplitColumn({
|
||||
hunks,
|
||||
@@ -188,52 +513,121 @@ function SplitColumn({
|
||||
{hunks.map((hunk, h) => (
|
||||
<div key={`${hunk.header}:${h}`}>
|
||||
<HunkHeader hunk={hunk} />
|
||||
{pairs(hunk.lines).map(({ left, right }, i) => {
|
||||
const e =
|
||||
left && right && left.type === "del" && right.type === "add"
|
||||
? emphasis(left.text, right.text)
|
||||
: null;
|
||||
const line = side === "left" ? left : right;
|
||||
return (
|
||||
<Row key={i} tint={rowTint(line?.type ?? null)}>
|
||||
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
|
||||
<span className="whitespace-pre px-1">
|
||||
{line ? (
|
||||
<Code text={line.text} lang={lang} emph={side === "left" ? e?.del : e?.add} />
|
||||
) : null}
|
||||
</span>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
{analyseHunk(hunk).rows.map((row, i) => (
|
||||
<SplitLine
|
||||
key={i}
|
||||
row={row}
|
||||
lang={lang}
|
||||
side={side}
|
||||
// One bar per hunk: on the right (new) column only.
|
||||
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Both sides, each scrolling horizontally on its own but kept in step. */
|
||||
/** A windowed split column: the shared range of items, one side of each. */
|
||||
function SplitSlice({
|
||||
items,
|
||||
range,
|
||||
lang,
|
||||
side,
|
||||
}: {
|
||||
items: Item[];
|
||||
range: { start: number; end: number };
|
||||
lang: string;
|
||||
side: "left" | "right";
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{items
|
||||
.slice(range.start, range.end)
|
||||
.map(({ hunk, i }, k) =>
|
||||
i < 0 ? (
|
||||
<HunkHeader key={range.start + k} hunk={hunk} fixed />
|
||||
) : (
|
||||
<SplitLine
|
||||
key={range.start + k}
|
||||
row={analyseHunk(hunk).rows[i]!}
|
||||
lang={lang}
|
||||
side={side}
|
||||
style={FIXED}
|
||||
bar={side === "right" && i === lastChangedRow(hunk) ? hunk : undefined}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** `v` held inside a scroller's own extent. */
|
||||
export function clampScroll(v: number, scrollSize: number, clientSize: number) {
|
||||
return Math.min(v, Math.max(0, scrollSize - clientSize));
|
||||
}
|
||||
|
||||
type Axis =
|
||||
| ["scrollLeft", "scrollWidth", "clientWidth"]
|
||||
| ["scrollTop", "scrollHeight", "clientHeight"];
|
||||
const AXES: Axis[] = [
|
||||
["scrollLeft", "scrollWidth", "clientWidth"],
|
||||
["scrollTop", "scrollHeight", "clientHeight"],
|
||||
];
|
||||
|
||||
/** Mirror `src`'s offset onto `dst`, clamped to what `dst` can show. */
|
||||
function follow(src: HTMLElement, dst: HTMLElement) {
|
||||
for (const [pos, size, client] of AXES) {
|
||||
// `src` pinned at its own end while `dst` sits past it: `src` is the
|
||||
// narrower column echoing a clamp we just caused. Pulling `dst` back to
|
||||
// it would snap the wide column home every time it scrolls past the
|
||||
// narrow one's longest line.
|
||||
if (src[pos] >= clampScroll(Infinity, src[size], src[client]) && dst[pos] > src[pos]) continue;
|
||||
const target = clampScroll(src[pos], dst[size], dst[client]);
|
||||
if (dst[pos] !== target) dst[pos] = target;
|
||||
}
|
||||
}
|
||||
|
||||
/** Both sides, each its own scroller but kept in step on both axes. */
|
||||
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
|
||||
const left = useRef<HTMLDivElement>(null);
|
||||
const right = useRef<HTMLDivElement>(null);
|
||||
const items = useMemo(() => flatten(hunks, "split"), [hunks]);
|
||||
const minWidth = useMemo(() => `calc(${longestInHunks(hunks)}ch + 3rem)`, [hunks]);
|
||||
const win = useWindow(items.length);
|
||||
|
||||
// Mirroring fires the other pane's own scroll handler, which mirrors back —
|
||||
// the equality check is what ends that, so no re-entry flag is needed.
|
||||
const sync = useCallback((from: "left" | "right") => {
|
||||
// the equality check (and `follow`'s edge rule) is what ends that, so no
|
||||
// re-entry flag is needed. Both axes: the columns hold paired rows, so a
|
||||
// vertical drift puts a deletion beside some unrelated line. Both columns
|
||||
// window off the same offset, so they always hold the same row range.
|
||||
const sync = (from: "left" | "right") => {
|
||||
const src = from === "left" ? left.current : right.current;
|
||||
const dst = from === "left" ? right.current : left.current;
|
||||
if (!src || !dst || dst.scrollLeft === src.scrollLeft) return;
|
||||
dst.scrollLeft = src.scrollLeft;
|
||||
}, []);
|
||||
if (!src || !dst) return;
|
||||
follow(src, dst);
|
||||
win.onScroll(src);
|
||||
};
|
||||
|
||||
const column = (side: "left" | "right") =>
|
||||
win.on ? (
|
||||
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
|
||||
<SplitSlice items={items} range={win.range} lang={lang} side={side} />
|
||||
</Windowed>
|
||||
) : (
|
||||
<SplitColumn hunks={hunks} lang={lang} side={side} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex ${boxHeight(fill)}`}>
|
||||
<div className={`flex ${boxHeight(fill, win.on)}`}>
|
||||
<div
|
||||
ref={left}
|
||||
data-testid="lane-diff-split-left"
|
||||
onScroll={() => sync("left")}
|
||||
className="w-1/2 min-w-0 overflow-auto"
|
||||
>
|
||||
<SplitColumn hunks={hunks} lang={lang} side="left" />
|
||||
{column("left")}
|
||||
</div>
|
||||
<div
|
||||
ref={right}
|
||||
@@ -241,7 +635,7 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
|
||||
onScroll={() => sync("right")}
|
||||
className="w-1/2 min-w-0 overflow-auto border-l border-border-light"
|
||||
>
|
||||
<SplitColumn hunks={hunks} lang={lang} side="right" />
|
||||
{column("right")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -252,14 +646,52 @@ export function LaneDiffView({
|
||||
file,
|
||||
layout,
|
||||
fill = false,
|
||||
hunkActions,
|
||||
}: {
|
||||
file: LaneChangeFile;
|
||||
layout: DiffLayout;
|
||||
/** Take the parent's height (single-file view) instead of the 60vh cap. */
|
||||
fill?: boolean;
|
||||
/** Controls drawn in each hunk's review bar. */
|
||||
hunkActions?: (hunk: LaneDiffHunk) => ReactNode;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const lang = langFor(file.path);
|
||||
const hunks = file.hunks;
|
||||
const items = useMemo(() => (hunks ? flatten(hunks, "unified") : []), [hunks]);
|
||||
const minWidth = useMemo(
|
||||
// two gutters + the row's px-2, plus the marker column
|
||||
() => `calc(${hunks ? longestInHunks(hunks) + 1 : 0}ch + 6rem)`,
|
||||
[hunks]
|
||||
);
|
||||
// Unified only; split windows inside its own two scrollers.
|
||||
const win = useWindow(layout === "unified" ? items.length : 0);
|
||||
const splitWindowed = useMemo(
|
||||
() => layout === "split" && !!hunks && flatten(hunks, "split").length > WINDOW_THRESHOLD,
|
||||
[layout, hunks]
|
||||
);
|
||||
|
||||
// The bar's arrows: bring hunk `index`'s header into view. A windowed
|
||||
// unified diff may not have it in the DOM, so there it is found by offset.
|
||||
const go = (index: number) => {
|
||||
const el = root.current;
|
||||
if (!el || !hunks) return;
|
||||
const header = el.querySelector<HTMLElement>(`[data-hunk-index="${index}"]`);
|
||||
// The review list pins each file's header over its own diff.
|
||||
const sticky = el
|
||||
.closest("[data-review-file]")
|
||||
?.querySelector<HTMLElement>("[data-sticky-top]");
|
||||
const pad = sticky?.offsetHeight ?? 0;
|
||||
if (header) {
|
||||
scrollToTop(header, pad);
|
||||
return;
|
||||
}
|
||||
// Windowed: the header is not in the DOM, but its offset is known.
|
||||
const at = items.findIndex((it) => it.i < 0 && it.hunk === hunks[index]);
|
||||
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
|
||||
};
|
||||
const review = hunkActions && hunks ? { actions: hunkActions, hunks, go } : null;
|
||||
|
||||
if (file.binary)
|
||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
|
||||
@@ -267,24 +699,49 @@ export function LaneDiffView({
|
||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.noHunks")}</p>;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="lane-diff-view"
|
||||
data-layout={layout}
|
||||
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
|
||||
layout === "split" ? boxHeight(fill) : `overflow-auto ${boxHeight(fill)}`
|
||||
}`}
|
||||
>
|
||||
{layout === "split" ? (
|
||||
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
|
||||
) : (
|
||||
file.hunks.map((hunk, i) => (
|
||||
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
|
||||
))
|
||||
)}
|
||||
{file.truncated && (
|
||||
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
|
||||
)}
|
||||
</div>
|
||||
<HunkActions.Provider value={review}>
|
||||
<div
|
||||
ref={root}
|
||||
data-testid="lane-diff-view"
|
||||
data-layout={layout}
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
|
||||
layout === "split"
|
||||
? boxHeight(fill, splitWindowed)
|
||||
: `overflow-auto ${boxHeight(fill, win.on)}`
|
||||
}`}
|
||||
>
|
||||
{layout === "split" ? (
|
||||
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
|
||||
) : win.on ? (
|
||||
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
|
||||
{items
|
||||
.slice(win.range.start, win.range.end)
|
||||
.map(({ hunk, i }, k) =>
|
||||
i < 0 ? (
|
||||
<HunkHeader key={win.range.start + k} hunk={hunk} fixed />
|
||||
) : (
|
||||
<UnifiedLine
|
||||
key={win.range.start + k}
|
||||
line={hunk.lines[i]!}
|
||||
lang={lang}
|
||||
emph={analyseHunk(hunk).emph.get(i)}
|
||||
style={FIXED}
|
||||
bar={i === lastChangedLine(hunk) ? hunk : undefined}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Windowed>
|
||||
) : (
|
||||
file.hunks.map((hunk, i) => (
|
||||
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
|
||||
))
|
||||
)}
|
||||
{file.truncated && (
|
||||
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
|
||||
)}
|
||||
</div>
|
||||
</HunkActions.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -303,19 +760,32 @@ export function LaneFileView({
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const lang = langFor(path);
|
||||
const win = useWindow(lines.length);
|
||||
// gutter + the row's px-2
|
||||
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
|
||||
const row = (text: string, i: number, style?: CSSProperties) => (
|
||||
<Row key={i} tint="" style={style}>
|
||||
<Gutter n={i + 1} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<Code text={text} lang={lang} />
|
||||
</span>
|
||||
</Row>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-testid="lane-file-view"
|
||||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill)}`}
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}`}
|
||||
>
|
||||
{lines.map((text, i) => (
|
||||
<Row key={i} tint="">
|
||||
<Gutter n={i + 1} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<Code text={text} lang={lang} />
|
||||
</span>
|
||||
</Row>
|
||||
))}
|
||||
{win.on ? (
|
||||
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
|
||||
{lines
|
||||
.slice(win.range.start, win.range.end)
|
||||
.map((text, k) => row(text, win.range.start + k, FIXED))}
|
||||
</Windowed>
|
||||
) : (
|
||||
lines.map((text, i) => row(text, i))
|
||||
)}
|
||||
{truncated && (
|
||||
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
|
||||
)}
|
||||
|
||||
@@ -1,37 +1,55 @@
|
||||
/**
|
||||
* @file The lane's git action row: ahead/behind against the upstream, and the
|
||||
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
|
||||
* fetch, pull, push, commit (everything, or only what the Changes panel has
|
||||
* staged), switch branch, and create/delete a branch. Per-file staging and
|
||||
* discard live in the Changes panel, beside the file they act on.
|
||||
* fetch, pull, push, switch branch, create/delete a branch, publish a repo
|
||||
* that has no remote yet (a URL, then `origin` + `push -u`), and start a merge
|
||||
* (the picked branch, or the upstream once diverged; resolving it is the
|
||||
* Changes panel's job). Committing is
|
||||
* NOT here: it is the Changes panel's staged-only commit box, beside the
|
||||
* per-file stage/unstage/discard it commits — one way to commit, and it only
|
||||
* ever takes what the user staged.
|
||||
*
|
||||
* It renders beside the read-only git line in LaneCard rather than inside the
|
||||
* ⋯ menu: that menu holds the rare and dangerous (`reset worktree`), and these
|
||||
* are the opposite — the ordinary moves a lane needs several times an hour.
|
||||
* It renders at the top of the Changes panel (`LaneGitHeader`), its actions as
|
||||
* icons on the same row as the branch line, rather than in the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset
|
||||
* worktree`), and these are the ordinary moves a lane needs several times an hour.
|
||||
* Nothing here decides what an action may do; the server does (pull is always
|
||||
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
|
||||
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
|
||||
* the user more than any sentence this file could write.
|
||||
* the user more than any sentence this file could write. The one exception
|
||||
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
|
||||
* in, so a remote wanting a login opens a form here and the SAME action is
|
||||
* resent with the typed credential — the token is never rendered back.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
CloudUpload,
|
||||
GitBranch,
|
||||
GitMerge,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { api, type GitAuthNeeded } from "../../lib/api";
|
||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||
|
||||
type Action = "fetch" | "pull" | "push" | "commit" | "switch" | "branch-create" | "branch-delete";
|
||||
type Action =
|
||||
| "fetch"
|
||||
| "pull"
|
||||
| "push"
|
||||
| "switch"
|
||||
| "branch-create"
|
||||
| "branch-delete"
|
||||
| "merge"
|
||||
| "publish";
|
||||
type RunBody = Parameters<typeof api.lanes.gitAction>[2];
|
||||
|
||||
const LOCAL_HOST = /^(localhost|127\.|\[::1\])/;
|
||||
|
||||
function Btn({
|
||||
label,
|
||||
@@ -39,6 +57,7 @@ function Btn({
|
||||
busy,
|
||||
disabled,
|
||||
title,
|
||||
compact,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
@@ -46,6 +65,8 @@ function Btn({
|
||||
busy?: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
/** Icon only; the label becomes the accessible name, the hint the tooltip. */
|
||||
compact?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -54,10 +75,13 @@ function Btn({
|
||||
onClick={onClick}
|
||||
disabled={disabled || busy}
|
||||
title={title}
|
||||
className="flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary transition-colors hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label={compact ? label : undefined}
|
||||
className={`flex items-center gap-1 rounded text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
compact ? "h-6 w-6 justify-center" : "border border-border-light px-1.5 py-0.5"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{!compact && label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -67,9 +91,13 @@ export function LaneGitBar({
|
||||
git,
|
||||
onChanged,
|
||||
onNewLane,
|
||||
lead,
|
||||
}: {
|
||||
laneId: number;
|
||||
git: LaneGitFacts;
|
||||
/** Rendered at the start of the action row: the header's branch line, so
|
||||
* the facts and the buttons that act on them read as one row. */
|
||||
lead?: React.ReactNode;
|
||||
/** Called after any action that could have moved the working copy. */
|
||||
onChanged: () => void;
|
||||
/** Open the Add-lane flow for a sibling worktree of this repo, optionally on
|
||||
@@ -79,32 +107,73 @@ export function LaneGitBar({
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [busy, setBusy] = useState<Action | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [committing, setCommitting] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||||
const [picked, setPicked] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
// Set when git needed a login for `action`; the form resends that action.
|
||||
const [login, setLogin] = useState<{ action: Action; auth: GitAuthNeeded } | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [remember, setRemember] = useState(true);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [url, setUrl] = useState("");
|
||||
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
|
||||
// All of the state above describes ONE lane — a branch picked for lane A,
|
||||
// applied after switching, would move B's HEAD — so it is reset during
|
||||
// render the moment the id changes, before anything can act on it.
|
||||
const [shownLane, setShownLane] = useState(laneId);
|
||||
// One action at a time, checked synchronously: `busy` is state, and a second
|
||||
// Enter can land before the render that disables anything.
|
||||
const inFlight = useRef(false);
|
||||
if (shownLane !== laneId) {
|
||||
setShownLane(laneId);
|
||||
setBusy(null);
|
||||
setError(null);
|
||||
setSwitching(false);
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
setPicked("");
|
||||
setNewBranch("");
|
||||
setLogin(null);
|
||||
setPassword("");
|
||||
setPublishing(false);
|
||||
setUrl("");
|
||||
inFlight.current = false;
|
||||
}
|
||||
const laneNow = useRef(laneId);
|
||||
laneNow.current = laneId;
|
||||
|
||||
if (!git.available) return null;
|
||||
const dirty = git.dirty + git.untracked;
|
||||
const merging = !!git.merging;
|
||||
|
||||
const run = async (
|
||||
action: Action,
|
||||
body?: { message?: string; only?: "staged"; branch?: string }
|
||||
) => {
|
||||
const run = async (action: Action, body?: RunBody) => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
const lane = laneId;
|
||||
setBusy(action);
|
||||
setError(null);
|
||||
try {
|
||||
await api.lanes.gitAction(laneId, action, body || {});
|
||||
setCommitting(false);
|
||||
setMessage("");
|
||||
const res = await api.lanes.gitAction(lane, action, body || {});
|
||||
// Landed after a lane switch: every write below belongs to the lane
|
||||
// no longer shown.
|
||||
if (laneNow.current !== lane) return;
|
||||
// A merge that stopped on conflicts still succeeded in starting; the
|
||||
// Changes panel below lists the files to resolve.
|
||||
if (res?.conflicts?.length)
|
||||
setError(t("gitBar.mergeConflicts", { count: res.conflicts.length }));
|
||||
setLogin(null);
|
||||
setPassword("");
|
||||
// The branch list this bar is showing is stale the moment a branch is
|
||||
// created, deleted or switched to, so it is re-read rather than patched.
|
||||
if (action === "switch") setSwitching(false);
|
||||
else if (action.startsWith("branch-")) {
|
||||
setNewBranch("");
|
||||
if (action === "publish") {
|
||||
setPublishing(false);
|
||||
setUrl("");
|
||||
} else if (action === "switch" || action.startsWith("branch-")) {
|
||||
if (action === "switch") setSwitching(false);
|
||||
else setNewBranch("");
|
||||
setPicked("");
|
||||
setBranches(null);
|
||||
await refreshBranches();
|
||||
@@ -113,19 +182,37 @@ export function LaneGitBar({
|
||||
} catch (err) {
|
||||
// The server appends git's own stderr to the message, so this is the
|
||||
// rejection text verbatim — hence the <pre> below rather than a line.
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
// ENOREMOTE is the one refusal with no git text to show: the server's
|
||||
// sentence is English and says less than a translated one.
|
||||
const auth = (err as { code?: string; auth?: GitAuthNeeded })?.auth;
|
||||
if (laneNow.current === lane && (err as { code?: string })?.code === "EGITAUTH" && auth) {
|
||||
setLogin({ action, auth });
|
||||
setUsername((u) => u || auth.username || "");
|
||||
setPassword("");
|
||||
} else if (laneNow.current === lane)
|
||||
setError(
|
||||
(err as { code?: string })?.code === "ENOREMOTE"
|
||||
? t("gitBar.errNoRemote")
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
if (laneNow.current === lane) {
|
||||
inFlight.current = false;
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const refreshBranches = async () => {
|
||||
try {
|
||||
const r = await api.lanes.gitBranches(laneId);
|
||||
if (laneNow.current !== laneId) return;
|
||||
setBranches(r.branches);
|
||||
setWorktrees(r.worktrees || []);
|
||||
} catch {
|
||||
setBranches([]);
|
||||
if (laneNow.current === laneId) setBranches([]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -136,20 +223,33 @@ export function LaneGitBar({
|
||||
await refreshBranches();
|
||||
};
|
||||
|
||||
const merge = (branch: string) => {
|
||||
if (!window.confirm(t("gitBar.mergeConfirm", { branch, current: git.branch }))) return;
|
||||
void run("merge", { branch });
|
||||
};
|
||||
|
||||
const heldBy = new Map(
|
||||
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="lane-git-bar" className="mb-2 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<div data-testid="lane-git-bar" className="space-y-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{lead}
|
||||
{/* Ahead/behind first: it is the fact that decides which of the
|
||||
buttons beside it the user actually wants. */}
|
||||
{git.upstream ? (
|
||||
{/* Null counts with an upstream named means git could not compare
|
||||
the two (the upstream ref is gone): claiming "in sync" there
|
||||
would be a lie, so it reads as not pushed. */}
|
||||
{git.unborn ? (
|
||||
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
|
||||
{t("gitBar.noCommits")}
|
||||
</span>
|
||||
) : git.upstream && git.ahead != null && git.behind != null ? (
|
||||
<span
|
||||
data-testid="lane-git-tracking"
|
||||
title={git.upstream}
|
||||
className="flex items-center gap-1 font-mono text-[11px] text-fg-muted"
|
||||
className="flex shrink-0 items-center gap-1 font-mono text-[11px] text-fg-muted"
|
||||
>
|
||||
{(git.ahead ?? 0) > 0 && (
|
||||
<span className="flex items-center text-status-warning">
|
||||
@@ -167,95 +267,95 @@ export function LaneGitBar({
|
||||
</span>
|
||||
) : (
|
||||
git.hasRemote && (
|
||||
<span className="font-mono text-[11px] text-fg-muted">{t("gitBar.noUpstream")}</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
|
||||
{t("gitBar.noUpstream")}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
|
||||
<Btn
|
||||
label={t("gitBar.fetch")}
|
||||
icon={<RefreshCw className="h-3 w-3" />}
|
||||
busy={busy === "fetch"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.fetchHint")}
|
||||
onClick={() => run("fetch")}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.pull")}
|
||||
icon={<ArrowDown className="h-3 w-3" />}
|
||||
busy={busy === "pull"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.pullHint")}
|
||||
onClick={() => run("pull")}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.push")}
|
||||
icon={<Upload className="h-3 w-3" />}
|
||||
busy={busy === "push"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.pushHint")}
|
||||
onClick={() => run("push")}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.commit")}
|
||||
icon={<Check className="h-3 w-3" />}
|
||||
disabled={dirty === 0 || !!busy}
|
||||
title={dirty === 0 ? t("gitBar.commitNothing") : t("gitBar.commitHint")}
|
||||
onClick={() => {
|
||||
setCommitting((v) => !v);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.switch")}
|
||||
icon={<GitBranch className="h-3 w-3" />}
|
||||
disabled={!!busy}
|
||||
// A dirty tree is refused server-side; saying so before the click
|
||||
// beats a red box after it.
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
|
||||
onClick={openSwitch}
|
||||
/>
|
||||
</div>
|
||||
{/* Diverged: a --ff-only pull can only refuse, so offer the merge. */}
|
||||
{git.upstream && (git.ahead ?? 0) > 0 && (git.behind ?? 0) > 0 && !merging && (
|
||||
<Btn
|
||||
label={t("gitBar.mergeUpstream", { upstream: git.upstream })}
|
||||
icon={<GitMerge className="h-3 w-3" />}
|
||||
busy={busy === "merge"}
|
||||
disabled={!!busy || dirty > 0}
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeUpstreamHint")}
|
||||
onClick={() => merge(git.upstream as string)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{committing && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && message.trim()) void run("commit", { message });
|
||||
if (e.key === "Escape") setCommitting(false);
|
||||
}}
|
||||
placeholder={t("gitBar.commitPlaceholder")}
|
||||
aria-label={t("gitBar.commitPlaceholder")}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.commitAll", { count: dirty })}
|
||||
busy={busy === "commit"}
|
||||
disabled={!message.trim()}
|
||||
onClick={() => void run("commit", { message })}
|
||||
/>
|
||||
{/* The Changes panel's per-file staging only means something if a
|
||||
commit can take the index as it stands. The server answers
|
||||
ENOCHANGES when nothing is staged, and that text lands below. */}
|
||||
<Btn
|
||||
label={t("gitBar.commitStaged")}
|
||||
title={t("gitBar.commitStagedHint")}
|
||||
busy={busy === "commit"}
|
||||
disabled={!message.trim()}
|
||||
onClick={() => void run("commit", { message, only: "staged" })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("gitBar.cancel")}
|
||||
onClick={() => setCommitting(false)}
|
||||
className="text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* A repo with no commit has nothing to push, fetch into or branch
|
||||
from — the one next step is the commit box below. */}
|
||||
{git.unborn ? (
|
||||
<span className="ml-auto text-[10px] text-fg-muted">{t("gitBar.unbornHint")}</span>
|
||||
) : (
|
||||
<div className="ml-auto flex shrink-0 items-center">
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.fetch")}
|
||||
icon={<RefreshCw className="h-3.5 w-3.5" />}
|
||||
busy={busy === "fetch"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.fetchHint")}
|
||||
onClick={() => run("fetch")}
|
||||
/>
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.pull")}
|
||||
icon={<ArrowDown className="h-3.5 w-3.5" />}
|
||||
busy={busy === "pull"}
|
||||
disabled={!git.hasRemote || !!busy || merging}
|
||||
title={merging ? t("gitBar.merging") : t("gitBar.pullHint")}
|
||||
onClick={() => run("pull")}
|
||||
/>
|
||||
<Btn
|
||||
compact
|
||||
// Same `push -u <remote> HEAD` either way; a branch with no
|
||||
// upstream is being published, not updated, so it says that.
|
||||
label={git.hasRemote && !git.upstream ? t("gitBar.publishBranch") : t("gitBar.push")}
|
||||
icon={<Upload className="h-3.5 w-3.5" />}
|
||||
busy={busy === "push"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={
|
||||
git.hasRemote && !git.upstream
|
||||
? t("gitBar.publishBranchHint")
|
||||
: t("gitBar.pushHint")
|
||||
}
|
||||
onClick={() => run("push")}
|
||||
/>
|
||||
{!git.hasRemote && (
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.publish")}
|
||||
icon={<CloudUpload className="h-3.5 w-3.5" />}
|
||||
disabled={!!busy}
|
||||
title={t("gitBar.publishHint")}
|
||||
onClick={() => {
|
||||
setPublishing((v) => !v);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.switch")}
|
||||
icon={<GitBranch className="h-3.5 w-3.5" />}
|
||||
disabled={!!busy || merging}
|
||||
// A dirty tree is refused server-side; saying so before the click
|
||||
// beats a red box after it.
|
||||
title={
|
||||
merging
|
||||
? t("gitBar.merging")
|
||||
: dirty > 0
|
||||
? t("gitBar.switchDirty")
|
||||
: t("gitBar.switchHint")
|
||||
}
|
||||
onClick={openSwitch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{switching && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -286,11 +386,27 @@ export function LaneGitBar({
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
|
||||
onClick={() => void run("switch", { branch: picked })}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.merge")}
|
||||
icon={<GitMerge className="h-3 w-3" />}
|
||||
busy={busy === "merge"}
|
||||
disabled={!picked || picked === git.branch || dirty > 0 || !!busy}
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeHint")}
|
||||
onClick={() => merge(picked)}
|
||||
/>
|
||||
{onNewLane && (
|
||||
<Btn
|
||||
label={t("gitBar.newLane")}
|
||||
title={t("gitBar.newLaneHint")}
|
||||
disabled={!picked}
|
||||
// A branch any worktree holds — this lane's own included — is
|
||||
// one `addWorktree` refuses with EBRANCHBUSY; say so up front.
|
||||
title={
|
||||
picked && (heldBy.has(picked) || picked === git.branch)
|
||||
? t("gitBar.newLaneHeld", {
|
||||
path: heldBy.get(picked) ?? t("gitBar.switchCurrent"),
|
||||
})
|
||||
: t("gitBar.newLaneHint")
|
||||
}
|
||||
disabled={!picked || heldBy.has(picked) || picked === git.branch}
|
||||
// The sibling lane is created through the normal Add-lane flow,
|
||||
// prefilled — one provisioning path, not a second one here.
|
||||
onClick={() => onNewLane(picked)}
|
||||
@@ -336,6 +452,102 @@ export function LaneGitBar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{publishing && !git.hasRemote && !git.unborn && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && url.trim()) void run("publish", { url: url.trim() });
|
||||
}}
|
||||
// The server decides which URLs are acceptable (https without a
|
||||
// token, ssh, user@host:path); this only names the two usual ones.
|
||||
placeholder={t("gitBar.publishUrl")}
|
||||
aria-label={t("gitBar.publishUrl")}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.publishApply")}
|
||||
icon={<Upload className="h-3 w-3" />}
|
||||
busy={busy === "publish"}
|
||||
disabled={!url.trim()}
|
||||
title={t("gitBar.publishHint")}
|
||||
onClick={() => void run("publish", { url: url.trim() })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{login && (
|
||||
<form
|
||||
data-testid="lane-git-login"
|
||||
className="space-y-1.5 rounded border border-border-light bg-surface-1 p-2 text-[11px]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const { protocol, host } = login.auth;
|
||||
void run(login.action, {
|
||||
credentials: { protocol, host, username: username.trim(), password, remember },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<p className="font-medium text-fg-primary">
|
||||
{t("gitBar.loginTitle", { host: login.auth.host })}
|
||||
</p>
|
||||
{login.auth.rejected && (
|
||||
<p role="alert" className="text-status-danger">
|
||||
{t("gitBar.loginRejected")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
aria-label={t("gitBar.loginUser")}
|
||||
placeholder={t("gitBar.loginUser")}
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("gitBar.loginPassword")}
|
||||
placeholder={t("gitBar.loginPassword")}
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
/>
|
||||
{t("gitBar.loginRemember")}
|
||||
</label>
|
||||
<p className="text-[10px] text-fg-muted">{t("gitBar.loginTokenHint")}</p>
|
||||
{/* The token crosses the network in the clear on a plain-http LAN address. */}
|
||||
{location.protocol === "http:" && !LOCAL_HOST.test(location.hostname) && (
|
||||
<p className="text-[10px] text-status-warning">{t("gitBar.loginInsecure")}</p>
|
||||
)}
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!username.trim() || !password || !!busy}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-2 py-0.5 text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitBar.loginSubmit")}
|
||||
</button>
|
||||
<Btn
|
||||
label={t("gitBar.loginCancel")}
|
||||
onClick={() => {
|
||||
setLogin(null);
|
||||
setPassword("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<pre
|
||||
role="alert"
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* @file The top of the Changes panel: the lane's working-copy line (branch,
|
||||
* head, subject, and the uncommitted counts that open a per-file hover panel)
|
||||
* and the git action bar under it (`LaneGitBar`).
|
||||
*
|
||||
* 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.
|
||||
* Here the two share one read of `GET /api/lanes/:id/git` (`useLaneGitFacts`,
|
||||
* owned by the panel) and refresh each other after every write.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { LaneGitBar } from "./LaneGitBar";
|
||||
import { api } from "../../lib/api";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
|
||||
|
||||
/** How often the mounted panel re-reads its working-copy facts. Slow on
|
||||
* purpose: each call is five git subprocesses server-side, and a branch name
|
||||
* does not change on the timescale the lane list is polled at. */
|
||||
const GIT_REFRESH_MS = 30_000;
|
||||
|
||||
/**
|
||||
* The lane's own working copy, fetched by the Changes panel rather than folded
|
||||
* into the polled lane list. Absent facts are not an error state: a lane may point at a
|
||||
* plain directory, and it renders with no git header.
|
||||
*/
|
||||
export function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => void] {
|
||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||
// Bumped by the git action bar: a commit or a pull changes every fact on
|
||||
// this row, and waiting out the 30s poll makes the button look inert.
|
||||
const [nonce, setNonce] = useState(0);
|
||||
// A lane switch drops the previous lane's facts during render, so nothing
|
||||
// (the git bar's "current branch" included) shows them until the new read.
|
||||
const [factsLane, setFactsLane] = useState(laneId);
|
||||
if (factsLane !== laneId) {
|
||||
setFactsLane(laneId);
|
||||
setFacts(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const read = () => {
|
||||
api.lanes
|
||||
.git(laneId)
|
||||
.then((f) => {
|
||||
if (alive) setFacts(keepIfSame(f));
|
||||
})
|
||||
.catch(() => {
|
||||
// Silent: a panel that cannot read git shows the rest of itself. An
|
||||
// error banner here would fire on every lane on every server blip.
|
||||
if (alive) setFacts(keepIfSame<LaneGitFacts>({ available: false }));
|
||||
});
|
||||
};
|
||||
read();
|
||||
const timer = setInterval(read, GIT_REFRESH_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [laneId, nonce]);
|
||||
|
||||
return [facts, () => setNonce((n) => n + 1)];
|
||||
}
|
||||
|
||||
/** Porcelain status code -> the letter shown and the colour it is shown in.
|
||||
* Keyed on the code's first meaningful character, so an index-side change
|
||||
* (`M `), a worktree-side one (` M`) and both (`MM`) read the same. */
|
||||
const GIT_STATUS_STYLE: Record<string, string> = {
|
||||
M: "text-status-warning",
|
||||
A: "text-status-success",
|
||||
D: "text-status-danger",
|
||||
R: "text-accent",
|
||||
C: "text-accent",
|
||||
U: "text-status-danger",
|
||||
"?": "text-fg-muted",
|
||||
};
|
||||
|
||||
/**
|
||||
* The file list behind a lane's uncommitted counts: which files, what happened
|
||||
* to each, and how many lines it gained and lost.
|
||||
*
|
||||
* It replaced a native `title` tooltip, which could show the same paths but
|
||||
* neither colour them nor align the deltas — and a plain porcelain dump is not
|
||||
* something you read at a glance.
|
||||
*
|
||||
* PORTALLED to `document.body` and `position: fixed`: the Changes panel
|
||||
* scrolls in its own `overflow` box, and an absolutely positioned panel would
|
||||
* be clipped by it. It flips above its anchor rather than off the bottom of
|
||||
* the window, and clamps to the right edge.
|
||||
*/
|
||||
function LaneGitFilesPanel({
|
||||
files,
|
||||
hidden,
|
||||
anchor,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
}: {
|
||||
files: LaneGitFile[];
|
||||
hidden: number;
|
||||
/** The counts button this panel hangs off. */
|
||||
anchor: HTMLElement | null;
|
||||
onPointerEnter: () => void;
|
||||
onPointerLeave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
// After the first paint, when the panel's real height is known: place it
|
||||
// under the anchor, or above it when that would run off the bottom.
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !panelRef.current) return;
|
||||
const a = anchor.getBoundingClientRect();
|
||||
const p = panelRef.current.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
const top =
|
||||
a.bottom + gap + p.height > window.innerHeight ? a.top - p.height - gap : a.bottom + gap;
|
||||
setPos({
|
||||
top: Math.max(8, top),
|
||||
left: Math.max(8, Math.min(a.left, document.documentElement.clientWidth - p.width - 8)),
|
||||
});
|
||||
}, [anchor, files]);
|
||||
// Totals are over the LISTED files only — the cap means the rest are unknown,
|
||||
// and a total that silently omits them would be a wrong number rather than a
|
||||
// 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);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
data-testid="lane-git-files"
|
||||
role="group"
|
||||
aria-label={t("git.filesHeading", { count: files.length })}
|
||||
// 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}
|
||||
onMouseLeave={onPointerLeave}
|
||||
style={{
|
||||
position: "fixed",
|
||||
zIndex: 60,
|
||||
top: pos?.top ?? 0,
|
||||
left: pos?.left ?? 0,
|
||||
// Hidden for the one frame before it has been measured, so it never
|
||||
// flashes at the top-left corner.
|
||||
visibility: pos ? "visible" : "hidden",
|
||||
}}
|
||||
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="ml-auto shrink-0 font-mono tabular-nums">
|
||||
<span className="text-status-success">+{added}</span>{" "}
|
||||
<span className="text-status-danger">−{deleted}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-0.5 font-mono text-[11px]">
|
||||
{files.map((f) => {
|
||||
const code = f.status.trim()[0] || "?";
|
||||
return (
|
||||
<li key={f.path} className="flex items-baseline gap-2">
|
||||
<span
|
||||
className={`w-3 shrink-0 font-semibold ${GIT_STATUS_STYLE[code] || "text-fg-muted"}`}
|
||||
title={f.status}
|
||||
>
|
||||
{code}
|
||||
</span>
|
||||
{/* The DIRECTORY truncates, never the filename: the name is what
|
||||
you scan for, the path is only there to disambiguate it. */}
|
||||
<span className="flex min-w-0 flex-1 items-baseline" title={f.path}>
|
||||
<span className="truncate text-fg-muted">{f.path.replace(/[^/]+$/, "")}</span>
|
||||
<span className="shrink-0 text-fg-primary">{f.path.split("/").pop()}</span>
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{f.added === null && f.deleted === null ? (
|
||||
<span className="text-fg-muted">{t("git.noDelta")}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-status-success">+{f.added ?? 0}</span>{" "}
|
||||
<span className="text-status-danger">−{f.deleted ?? 0}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{hidden > 0 && (
|
||||
<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
|
||||
);
|
||||
}
|
||||
|
||||
export function LaneGitHeader({
|
||||
laneId,
|
||||
git,
|
||||
onChanged,
|
||||
onNewLane,
|
||||
}: {
|
||||
laneId: number;
|
||||
git: LaneGitFacts | null;
|
||||
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
||||
onChanged: () => void;
|
||||
/** See `LaneGitBar`'s prop of the same name. */
|
||||
onNewLane?: (branch?: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [countsAnchor, setCountsAnchor] = useState<HTMLButtonElement | null>(null);
|
||||
// Closing is DELAYED: the panel is portalled away from the trigger, so the
|
||||
// pointer leaves the trigger before it can land on the panel it is heading
|
||||
// for. Any enter — trigger or panel — cancels the pending close.
|
||||
const filesCloseTimer = useRef<number | undefined>(undefined);
|
||||
const openFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
setFilesOpen(true);
|
||||
}, []);
|
||||
const closeFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
|
||||
}, []);
|
||||
useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []);
|
||||
|
||||
if (!git?.available) return null;
|
||||
const lead = (
|
||||
<>
|
||||
{/* One row with the actions: branch first and strongest, then the head
|
||||
and its subject, which is what truncates when the panel is narrow
|
||||
(the whole of it stays in the tooltip). */}
|
||||
<div
|
||||
data-testid="lane-git"
|
||||
className="flex min-w-0 flex-1 items-baseline gap-1.5 font-mono text-[11px]"
|
||||
title={`${git.head} ${git.subject}`}
|
||||
>
|
||||
<span className="flex shrink-0 items-center gap-1 self-center font-semibold text-fg-primary">
|
||||
<GitBranch className="h-3 w-3 text-fg-muted" />
|
||||
{git.branch}
|
||||
</span>
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
|
||||
</div>
|
||||
{(git.dirty > 0 || git.untracked > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
ref={setCountsAnchor}
|
||||
data-testid="lane-git-counts"
|
||||
aria-expanded={filesOpen}
|
||||
onMouseEnter={openFiles}
|
||||
onMouseLeave={closeFiles}
|
||||
// Focus and click open it too: hover is not a gesture a
|
||||
// keyboard or a touchscreen has.
|
||||
onFocus={openFiles}
|
||||
onBlur={closeFiles}
|
||||
onClick={() => setFilesOpen((v) => !v)}
|
||||
className="shrink-0 cursor-help rounded-full border border-status-warning/40 bg-status-warning/10 px-2 py-px text-[10.5px] text-status-warning"
|
||||
>
|
||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-testid="lane-git-header"
|
||||
className="mb-2 rounded-md border border-border bg-surface-2 px-2 py-1"
|
||||
>
|
||||
<LaneGitBar
|
||||
laneId={laneId}
|
||||
git={git}
|
||||
onChanged={onChanged}
|
||||
onNewLane={onNewLane}
|
||||
lead={lead}
|
||||
/>
|
||||
{filesOpen && git.files?.length > 0 && (
|
||||
<LaneGitFilesPanel
|
||||
files={git.files}
|
||||
hidden={git.dirty + git.untracked - git.files.length}
|
||||
anchor={countsAnchor}
|
||||
onPointerEnter={openFiles}
|
||||
onPointerLeave={closeFiles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -245,10 +245,11 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByLabelText("Branch to fork from");
|
||||
await user.type(screen.getByLabelText("Title"), "Auto named");
|
||||
// The placeholder previews the name the server will pick.
|
||||
// No `feat/…` guess: the prefix is the server's LANE_BRANCH_PREFIX, which
|
||||
// the form cannot see, so the placeholder only says the field is optional.
|
||||
expect(screen.getByLabelText("New branch name")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"feat/auto-named"
|
||||
"Automatic — named from the title"
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
@@ -304,6 +305,28 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("refuses a worktree of a repo with no commits and steers to Repo mode", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: [],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/empty");
|
||||
expect(await screen.findByText(/commit first, then publish/i)).toBeInTheDocument();
|
||||
await user.type(screen.getByLabelText("Title"), "demo");
|
||||
// There is nothing to branch from, so the worktree cannot be created.
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Use as a Repo lane" }));
|
||||
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard";
|
||||
import type { Lane, LaneRuntime } from "../../../lib/types";
|
||||
@@ -167,9 +167,9 @@ describe("LaneCard rebuilt layout", () => {
|
||||
expect(screen.queryByText("48%")).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces a needs-you message", () => {
|
||||
it("leaves the needs-you message to the strip card", () => {
|
||||
render(<LaneCard lane={full({ needs_action: "waiting on approval" })} onAction={vi.fn()} />);
|
||||
expect(screen.getByText(/waiting on approval/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/waiting on approval/)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps reset behind the ⋯ menu rather than showing it directly", async () => {
|
||||
@@ -204,126 +204,11 @@ describe("LaneCard rebuilt layout", () => {
|
||||
});
|
||||
|
||||
describe("LaneCard git block", () => {
|
||||
const facts = {
|
||||
available: true as const,
|
||||
branch: "feat/rename-metric",
|
||||
head: "9b3e74a",
|
||||
subject: "free-text rule mode in the form",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
files: [
|
||||
{ path: "server/lib/alerts.js", status: " M", added: 12, deleted: 3 },
|
||||
{ path: "client/src/lib/api.ts", status: "M ", added: 1, deleted: 0 },
|
||||
{ path: "scratch.txt", status: "??", added: null, deleted: null },
|
||||
],
|
||||
// Tracking facts ride along on the same endpoint; a branch that has never
|
||||
// been pushed reports nulls rather than zeros.
|
||||
hasRemote: true,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<LaneCard lane={makeLane({ branch: "stale/recorded" })} onAction={vi.fn()} />);
|
||||
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
expect(block.textContent).toContain("feat/rename-metric");
|
||||
expect(block.textContent).toContain("9b3e74a");
|
||||
expect(block.textContent).toContain("free-text rule mode in the form");
|
||||
expect(block.textContent).toContain("2");
|
||||
// The live branch replaces the lane's recorded one rather than doubling it.
|
||||
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
|
||||
});
|
||||
|
||||
it("names the files behind the counts on hover, with each one's line delta", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
// Hidden until asked for: the row is a one-line summary until you hover.
|
||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||
|
||||
fireEvent.mouseEnter(counts);
|
||||
const panel = screen.getByTestId("lane-git-files");
|
||||
expect(panel.textContent).toContain("alerts.js");
|
||||
expect(panel.textContent).toContain("+12");
|
||||
expect(panel.textContent).toContain("−3");
|
||||
// Totals across the listed files.
|
||||
expect(panel.textContent).toContain("+13");
|
||||
// An untracked file has no delta to report and says so rather than "+0".
|
||||
expect(panel.textContent).toContain("new");
|
||||
expect(panel.textContent).toContain("3 files changed");
|
||||
|
||||
// Closing is delayed so the pointer can travel into the panel and scroll
|
||||
// it — the panel is portalled to the body, away from its trigger.
|
||||
fireEvent.mouseLeave(counts);
|
||||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||||
});
|
||||
|
||||
it("stays open while the pointer is inside the panel", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
fireEvent.mouseEnter(counts);
|
||||
fireEvent.mouseLeave(counts);
|
||||
// The pointer landed on the panel before the pending close fired.
|
||||
fireEvent.mouseEnter(screen.getByTestId("lane-git-files"));
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||||
|
||||
fireEvent.mouseLeave(screen.getByTestId("lane-git-files"));
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||||
});
|
||||
|
||||
it("says how many files the server-side cap left out", async () => {
|
||||
// 40 uncommitted files, 3 listed: the counts are exact, the listing is not.
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
||||
});
|
||||
|
||||
it("omits the uncommitted line when the tree is clean", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
expect(block.textContent).not.toContain("modified");
|
||||
});
|
||||
|
||||
it("renders the card with no git block and no error when git is unavailable", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
|
||||
render(<LaneCard lane={makeLane({ id: 2 })} onAction={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByTestId("lane-card-2")).toBeInTheDocument();
|
||||
it("carries no working-copy row — that heads the Changes panel now", async () => {
|
||||
render(<LaneCard lane={makeLane({ id: 4 })} onAction={vi.fn()} />);
|
||||
expect(await screen.findByTestId("lane-card-4")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||||
});
|
||||
|
||||
it("swallows a rejected request instead of surfacing an error", async () => {
|
||||
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
|
||||
render(<LaneCard lane={makeLane({ id: 3 })} onAction={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByTestId("lane-card-3")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||||
expect(screen.queryByText(/network down/)).toBeNull();
|
||||
});
|
||||
|
||||
it("stops polling once the card unmounts", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
||||
const { unmount } = render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
expect(api.lanes.git).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, act, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneChangesPanel } from "../LaneChangesPanel";
|
||||
import { api } from "../../../lib/api";
|
||||
@@ -20,12 +20,16 @@ import type { LaneChanges } from "../../../lib/types";
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: {
|
||||
git: vi.fn(),
|
||||
gitBranches: vi.fn(),
|
||||
changes: vi.fn(),
|
||||
gitAction: vi.fn(),
|
||||
gitLog: vi.fn(),
|
||||
gitFile: vi.fn(),
|
||||
gitSaveFile: vi.fn(),
|
||||
gitLs: vi.fn(),
|
||||
gitTree: vi.fn(),
|
||||
gitTreeAll: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -76,7 +80,15 @@ function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}):
|
||||
};
|
||||
}
|
||||
|
||||
/** The overview row's discard button for one path. */
|
||||
function discardOf(path: string) {
|
||||
return within(screen.getByText(path).closest("li")!).getByRole("button", {
|
||||
name: "Discard this file's changes",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.changes).mockReset();
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||
vi.mocked(api.lanes.gitAction).mockReset();
|
||||
@@ -94,12 +106,14 @@ beforeEach(() => {
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.gitLs).mockResolvedValue({
|
||||
available: true,
|
||||
paths: ["src/main.ts"],
|
||||
truncated: false,
|
||||
total: 1,
|
||||
});
|
||||
vi.mocked(api.lanes.gitLs)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
paths: ["src/main.ts"],
|
||||
truncated: false,
|
||||
total: 1,
|
||||
});
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
@@ -211,13 +225,165 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue({
|
||||
available: true,
|
||||
branch: "feat/thing",
|
||||
head: "9b3e74a",
|
||||
subject: "wip",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
files: [],
|
||||
hasRemote: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
|
||||
expect(screen.getByTestId("lane-git-bar")).toBeInTheDocument();
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
|
||||
await user.click(stage!);
|
||||
// The header's counts moved with the index: waiting out its 30s poll
|
||||
// would leave them wrong beside the list that just changed.
|
||||
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("re-reads the diff after a git bar action", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue({
|
||||
available: true,
|
||||
branch: "feat/thing",
|
||||
head: "9b3e74a",
|
||||
subject: "wip",
|
||||
dirty: 0,
|
||||
untracked: 0,
|
||||
files: [],
|
||||
hasRemote: true,
|
||||
upstream: "origin/feat/thing",
|
||||
ahead: 0,
|
||||
behind: 1,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-git-bar");
|
||||
const before = vi.mocked(api.lanes.changes).mock.calls.length;
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Pull/ }));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "pull", {});
|
||||
// A pull rewrites the working tree the list below describes.
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(api.lanes.changes).mock.calls.length).toBeGreaterThan(before)
|
||||
);
|
||||
});
|
||||
|
||||
it("re-reads history too when Refresh is pressed there", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "History" }));
|
||||
await screen.findByText("feat: the commit");
|
||||
vi.mocked(api.lanes.gitLog).mockClear();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
// A pull or a switch from the git bar moves the commit list as well.
|
||||
await waitFor(() => expect(api.lanes.gitLog).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("stages every file the index lacks in one call, from above the list", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
const actions = screen.getByTestId("lane-changes-actions");
|
||||
// Commit box and Stage all come BEFORE the file list.
|
||||
expect(actions.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(screen.getByTestId("lane-changes-commit")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Stage all (2)" }));
|
||||
// The already-staged docs/API.md is not sent again.
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
paths: ["server/lib/git-ops.js", "notes.txt"],
|
||||
});
|
||||
});
|
||||
|
||||
it("lists staged files apart from the changes the next commit leaves behind", async () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
|
||||
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
|
||||
expect(within(staged).getByText("docs/API.md")).toBeInTheDocument();
|
||||
expect(within(staged).queryByText("notes.txt")).toBeNull();
|
||||
expect(within(unstaged).getByText("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
|
||||
expect(within(unstaged).queryByText("docs/API.md")).toBeNull();
|
||||
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("at full width shows the tree as a sidebar and opens a file straight into the editor", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [{ name: "main.ts", path: "src/main.ts", dir: false, status: " M" }],
|
||||
truncated: false,
|
||||
});
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "src/main.ts",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["const whole = 1;"],
|
||||
sha: "b".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
// The sidebar IS the Files view: its finder lives there, and the tab is gone.
|
||||
expect(within(sidebar).getByLabelText("Find a file by path…")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Files" })).toBeNull();
|
||||
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
|
||||
await user.click(await within(sidebar).findByText("main.ts"));
|
||||
|
||||
// No Edit click: the editor is simply there, Save/Cancel idle until typed.
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("const whole = 1;");
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
await user.type(editor, "x");
|
||||
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
||||
// Cancel reverts to the saved text and stays in the editor.
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
|
||||
});
|
||||
|
||||
it("at full width opens a changed file from the overview straight into the editor", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["a"],
|
||||
sha: "b".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(within(list).getByText("server/lib/git-ops.js"));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("a");
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("needs two clicks to discard, and only the second one reaches the server", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" });
|
||||
await user.click(discard!);
|
||||
await user.click(discardOf("server/lib/git-ops.js"));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(
|
||||
@@ -229,6 +395,75 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("disarms a pending discard when the lane changes, so one click cannot discard in another lane", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" });
|
||||
await user.click(discard!);
|
||||
|
||||
// Lane 8 has a file at the SAME path.
|
||||
rerender(<LaneChangesPanel laneId={8} />);
|
||||
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(8, expect.anything()));
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
|
||||
).toBeNull();
|
||||
const [again] = await screen.findAllByRole("button", { name: "Discard this file's changes" });
|
||||
await user.click(again!);
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disarms a pending discard once that path leaves the file list", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(discardOf("server/lib/git-ops.js"));
|
||||
|
||||
// The file was committed/reverted elsewhere; a later reappearance must not be pre-armed.
|
||||
const base = changes();
|
||||
const rest = base.available ? base.files.slice(1) : [];
|
||||
vi.mocked(api.lanes.changes).mockResolvedValueOnce(changes({ files: rest }));
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||
// A plain refresh (the poll's twin), not a git action — an action already disarms.
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
await waitFor(() => expect(screen.queryByText("server/lib/git-ops.js")).toBeNull());
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
await screen.findByText("server/lib/git-ops.js");
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a late response for the lane it has already left", async () => {
|
||||
let resolveOld: (v: LaneChanges) => void = () => {};
|
||||
vi.mocked(api.lanes.changes).mockImplementation((id) =>
|
||||
id === 7
|
||||
? new Promise<LaneChanges>((r) => (resolveOld = r))
|
||||
: Promise.resolve(
|
||||
changes({
|
||||
files: [
|
||||
{
|
||||
path: "lane8.txt",
|
||||
status: "??",
|
||||
untracked: true,
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
const { rerender } = render(<LaneChangesPanel laneId={7} />);
|
||||
rerender(<LaneChangesPanel laneId={8} />);
|
||||
expect(await screen.findByText("lane8.txt")).toBeInTheDocument();
|
||||
|
||||
resolveOld(changes());
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
|
||||
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens a commit from history as a commit-range review", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
@@ -275,8 +510,16 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByText("src")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands every folder at once, fetching whatever level wasn't cached yet", async () => {
|
||||
it("expands every folder at once from one whole-tree read", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.gitTreeAll).mockResolvedValue({
|
||||
available: true,
|
||||
truncated: false,
|
||||
levels: {
|
||||
"": [{ name: "src", path: "src", dir: true }],
|
||||
src: [{ name: "main.ts", path: "src/main.ts", dir: false }],
|
||||
},
|
||||
});
|
||||
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) => {
|
||||
if (path === "src")
|
||||
return {
|
||||
@@ -306,6 +549,34 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByText("src")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights modified and untracked files in the Files tree, and folders holding them", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [
|
||||
{ name: "src", path: "src", dir: true, changed: true },
|
||||
{ name: "docs", path: "docs", dir: true },
|
||||
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
|
||||
{ name: "new.txt", path: "new.txt", dir: false, status: "??" },
|
||||
{ name: "keep.txt", path: "keep.txt", dir: false },
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel 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("a.txt")).toHaveAttribute("data-tree-status", "M");
|
||||
expect(await row("a.txt")).toHaveClass("text-status-warning");
|
||||
expect(await row("new.txt")).toHaveAttribute("data-tree-status", "U");
|
||||
expect(await row("new.txt")).toHaveClass("text-status-success");
|
||||
expect(await row("src")).toHaveAttribute("data-tree-status", "dir");
|
||||
expect(await row("keep.txt")).not.toHaveAttribute("data-tree-status");
|
||||
expect(await row("docs")).not.toHaveAttribute("data-tree-status");
|
||||
});
|
||||
|
||||
it("quick-opens a tracked file as its whole content, not a diff", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
@@ -319,6 +590,113 @@ describe("LaneChangesPanel", () => {
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
|
||||
});
|
||||
|
||||
it("edits a whole file in place and saves over the exact version it opened", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "src/main.ts",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["const whole = 1;"],
|
||||
sha: "b".repeat(40),
|
||||
});
|
||||
vi.mocked(api.lanes.gitSaveFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "src/main.ts",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["const whole = 2;"],
|
||||
sha: "c".repeat(40),
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
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 screen.findByTestId("lane-file-view");
|
||||
|
||||
await user.click(screen.getByTestId("lane-file-edit"));
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("const whole = 1;");
|
||||
await user.clear(editor);
|
||||
await user.type(editor, "const whole = 2;");
|
||||
await user.click(screen.getByTestId("lane-file-save"));
|
||||
|
||||
expect(api.lanes.gitSaveFile).toHaveBeenCalledWith(
|
||||
7,
|
||||
"src/main.ts",
|
||||
"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();
|
||||
});
|
||||
|
||||
it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "src/main.ts",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: true, // over the 1 MB view cap: no `sha`, nothing safe to save
|
||||
lines: ["partial"],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
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"));
|
||||
|
||||
expect(await screen.findByTestId("lane-file-noedit")).toHaveTextContent(
|
||||
"Too large to edit here (over 1 MB)"
|
||||
);
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the draft and says why when the file changed on disk meanwhile", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "src/main.ts",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["x"],
|
||||
sha: "b".repeat(40),
|
||||
});
|
||||
vi.mocked(api.lanes.gitSaveFile).mockRejectedValue(
|
||||
Object.assign(new Error("the file changed on disk since it was opened"), { code: "ESTALE" })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
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 screen.findByTestId("lane-file-view");
|
||||
await user.click(screen.getByTestId("lane-file-edit"));
|
||||
await user.type(await screen.findByTestId("lane-file-editor"), "y");
|
||||
await user.click(screen.getByTestId("lane-file-save"));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/changed on disk/);
|
||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("xy");
|
||||
});
|
||||
|
||||
it("unstages every staged file in one call, on the same row as Stage all and commit", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
const row = screen.getByTestId("lane-changes-actions");
|
||||
expect(row).toContainElement(screen.getByTestId("lane-changes-commit"));
|
||||
expect(row).toContainElement(screen.getByRole("button", { name: "Stage all (2)" }));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Unstage all (1)" }));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "unstage", { paths: ["docs/API.md"] });
|
||||
});
|
||||
|
||||
it("switches the diff to side-by-side", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
@@ -411,6 +789,16 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a half-typed commit message on a lane switch", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-commit");
|
||||
await user.type(screen.getByLabelText("Commit message"), "fix: meant for lane 7");
|
||||
rerender(<LaneChangesPanel laneId={8} />);
|
||||
await screen.findByTestId("lane-changes-commit");
|
||||
expect(screen.getByLabelText("Commit message")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("hides the commit box when nothing is staged", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(
|
||||
changes({
|
||||
@@ -491,6 +879,114 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe("review actions", () => {
|
||||
const reviewed = () =>
|
||||
changes({
|
||||
files: [
|
||||
{
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
unstaged: true,
|
||||
added: 1,
|
||||
deleted: 1,
|
||||
binary: false,
|
||||
hunks: [HUNK, { ...HUNK, header: "@@ -9,2 +10,3 @@" }],
|
||||
},
|
||||
{
|
||||
path: "notes.txt",
|
||||
status: "??",
|
||||
untracked: true,
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
hunks: [HUNK],
|
||||
},
|
||||
],
|
||||
});
|
||||
const openReview = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(reviewed());
|
||||
await user.click(screen.getByRole("button", { name: "Review" }));
|
||||
await screen.findByTestId("lane-changes-review");
|
||||
};
|
||||
|
||||
it("stages one hunk by its @@ line, at the context the diff was read with", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
// Two hunks on the tracked file; the untracked one has none to act on.
|
||||
const stage = screen.getAllByRole("button", { name: "Stage this hunk" });
|
||||
expect(stage).toHaveLength(2);
|
||||
await user.click(stage[1]!);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
|
||||
path: "server/lib/git-ops.js",
|
||||
hunk: "@@ -9,2 +10,3 @@",
|
||||
context: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("hangs one review bar under each actionable hunk, counting them", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
// Two hunks on the tracked file; the untracked file gets no bar.
|
||||
const bars = screen.getAllByTestId("lane-hunk-bar");
|
||||
expect(bars.map((b) => b.textContent)).toEqual([
|
||||
expect.stringContaining("1 of 2"),
|
||||
expect.stringContaining("2 of 2"),
|
||||
]);
|
||||
// Undo sits beside Keep inside the bar — not on the @@ line.
|
||||
expect(bars[0]).toContainElement(
|
||||
screen.getAllByRole("button", { name: "Undo this hunk" })[0]!
|
||||
);
|
||||
expect(screen.getAllByRole("button", { name: "Previous change" })[0]).toBeDisabled();
|
||||
expect(screen.getAllByRole("button", { name: "Next change" })[1]).toBeDisabled();
|
||||
});
|
||||
|
||||
it("needs two clicks to undo a hunk, and arms only that hunk", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
const [first] = screen.getAllByRole("button", { name: "Undo this hunk" });
|
||||
await user.click(first!);
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
// The other hunk is still at its first click.
|
||||
expect(screen.getAllByRole("button", { name: "Undo this hunk" })).toHaveLength(1);
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Click again to undo this hunk — this cannot be undone",
|
||||
})
|
||||
);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard-hunk", {
|
||||
path: "server/lib/git-ops.js",
|
||||
hunk: "@@ -1,2 +1,3 @@",
|
||||
context: 3,
|
||||
confirm: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("stages a whole file from its review header without folding it", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
const [stageFile] = screen.getAllByRole("button", { name: "Stage this file" });
|
||||
await user.click(stageFile!);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps each file's header pinned while its diff scrolls", async () => {
|
||||
const user = userEvent.setup();
|
||||
await openReview(user);
|
||||
expect(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })).toHaveClass(
|
||||
"sticky",
|
||||
"top-0"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("opens a file only from its path, and folds it from anywhere else", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
@@ -515,6 +1011,73 @@ describe("LaneChangesPanel", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("opens, not folds, when Enter is pressed on the path", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "Review" }));
|
||||
await screen.findByTestId("lane-changes-review");
|
||||
|
||||
screen.getByRole("button", { name: "server/lib/git-ops.js" }).focus();
|
||||
await user.keyboard("{Enter}");
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||
range: "work",
|
||||
path: "server/lib/git-ops.js",
|
||||
context: 3,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a quick-open answer for the lane it has already left", async () => {
|
||||
const user = userEvent.setup();
|
||||
let answer: (v: { paths: string[] }) => void = () => {};
|
||||
vi.mocked(api.lanes.gitLs).mockReturnValueOnce(
|
||||
new Promise((r) => {
|
||||
answer = r as typeof answer;
|
||||
}) as ReturnType<typeof api.lanes.gitLs>
|
||||
);
|
||||
const { rerender } = render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await waitFor(() => expect(api.lanes.gitLs).toHaveBeenCalledWith(7, "main"));
|
||||
|
||||
rerender(<LaneChangesPanel laneId={8} />);
|
||||
await act(async () => answer({ paths: ["from/lane7.ts"] }));
|
||||
// Back in Files on lane 8, before its own search lands: nothing of lane 7.
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
expect(screen.queryByText("from/lane7.ts")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the newest quick-open answer when an older one lands after it", async () => {
|
||||
const user = userEvent.setup();
|
||||
let answer: (v: { paths: string[] }) => void = () => {};
|
||||
vi.mocked(api.lanes.gitLs)
|
||||
.mockReturnValueOnce(
|
||||
new Promise((r) => {
|
||||
answer = r as typeof answer;
|
||||
}) as ReturnType<typeof api.lanes.gitLs>
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
available: true,
|
||||
paths: ["new/mainx.ts"],
|
||||
truncated: false,
|
||||
total: 1,
|
||||
});
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
const box = await screen.findByLabelText("Find a file by path…");
|
||||
await user.type(box, "main");
|
||||
await waitFor(() => expect(api.lanes.gitLs).toHaveBeenCalledWith(7, "main"));
|
||||
await user.type(box, "x");
|
||||
expect(await screen.findByText("new/mainx.ts")).toBeInTheDocument();
|
||||
|
||||
await act(async () => answer({ paths: ["old/main-only.ts"] }));
|
||||
expect(screen.queryByText("old/main-only.ts")).toBeNull();
|
||||
});
|
||||
|
||||
it("goes back to the list it came from, at the offset it was left at", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
@@ -537,4 +1100,227 @@ describe("LaneChangesPanel", () => {
|
||||
expect(await screen.findByTestId("lane-changes-review")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-changes-body").scrollTop).toBe(640);
|
||||
});
|
||||
|
||||
it("opens an unchanged tracked file from Files with no error banner", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
vi.mocked(api.lanes.changes).mockRejectedValue(
|
||||
Object.assign(new Error("file has no changes in this range"), {
|
||||
code: "ENOFILECHANGE",
|
||||
status: 400,
|
||||
})
|
||||
);
|
||||
|
||||
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"));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledTimes(3));
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(screen.getByTestId("lane-file-view")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the overview rows mounted across re-renders (typing in the commit box)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-commit");
|
||||
const before = screen.getByText("server/lib/git-ops.js");
|
||||
await user.type(screen.getByLabelText("Commit message"), "x");
|
||||
expect(screen.getByText("server/lib/git-ops.js")).toBe(before);
|
||||
});
|
||||
|
||||
it("expands all folders with ONE request, not one per folder", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.gitTree).mockClear();
|
||||
vi.mocked(api.lanes.gitTreeAll)
|
||||
.mockClear()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
truncated: false,
|
||||
levels: {
|
||||
"": [{ name: "src", path: "src", dir: true }],
|
||||
src: [{ name: "main.ts", path: "src/main.ts", dir: false }],
|
||||
},
|
||||
});
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await screen.findByText("src");
|
||||
await user.click(screen.getByRole("button", { name: "Expand all folders" }));
|
||||
|
||||
expect(await screen.findByText("main.ts")).toBeInTheDocument();
|
||||
expect(api.lanes.gitTreeAll).toHaveBeenCalledTimes(1);
|
||||
// Only the root read the Files tab made on entry — no per-folder walk.
|
||||
expect(api.lanes.gitTree).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not repaint the previous lane after its git action resolves post-switch", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveAction: (v: { action: string; ok: true; output: string }) => void = () => {};
|
||||
vi.mocked(api.lanes.gitAction).mockReturnValue(
|
||||
new Promise((r) => (resolveAction = r)) as ReturnType<typeof api.lanes.gitAction>
|
||||
);
|
||||
vi.mocked(api.lanes.changes).mockImplementation(async (id) =>
|
||||
id === 7
|
||||
? changes()
|
||||
: changes({
|
||||
files: [
|
||||
{
|
||||
path: "lane8.txt",
|
||||
status: "??",
|
||||
untracked: true,
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
const { rerender } = render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
|
||||
await user.click(stage!);
|
||||
|
||||
rerender(<LaneChangesPanel laneId={8} />);
|
||||
expect(await screen.findByText("lane8.txt")).toBeInTheDocument();
|
||||
const lane7Reads = vi.mocked(api.lanes.changes).mock.calls.filter(([id]) => id === 7).length;
|
||||
|
||||
resolveAction({ action: "stage", ok: true, output: "" });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(vi.mocked(api.lanes.changes).mock.calls.filter(([id]) => id === 7)).toHaveLength(
|
||||
lane7Reads
|
||||
);
|
||||
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
|
||||
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not paint the previous lane's tree when an expand-all read outlives a lane switch", async () => {
|
||||
const user = userEvent.setup();
|
||||
let resolveSrc: () => void = () => {};
|
||||
vi.mocked(api.lanes.gitTreeAll).mockImplementation(
|
||||
() =>
|
||||
new Promise(
|
||||
(r) =>
|
||||
(resolveSrc = () =>
|
||||
r({
|
||||
available: true,
|
||||
truncated: false,
|
||||
levels: {
|
||||
"": [{ name: "src", path: "src", dir: true }],
|
||||
src: [{ name: "main.ts", path: "src/main.ts", dir: false }],
|
||||
},
|
||||
}))
|
||||
)
|
||||
);
|
||||
vi.mocked(api.lanes.gitTree).mockImplementation((id) => {
|
||||
return Promise.resolve({
|
||||
available: true,
|
||||
path: "",
|
||||
entries: [
|
||||
id === 7
|
||||
? { name: "src", path: "src", dir: true }
|
||||
: { name: "lib8", path: "lib8", dir: false },
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
const { rerender } = render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await screen.findByText("src");
|
||||
await user.click(screen.getByRole("button", { name: "Expand all folders" }));
|
||||
|
||||
rerender(<LaneChangesPanel laneId={8} />);
|
||||
await user.click(await screen.findByRole("button", { name: "Files" }));
|
||||
expect(await screen.findByText("lib8")).toBeInTheDocument();
|
||||
|
||||
resolveSrc();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.getByText("lib8")).toBeInTheDocument();
|
||||
expect(screen.queryByText("src")).toBeNull();
|
||||
expect(screen.queryByText("main.ts")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Expand all folders" })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a merge in progress (issue #45)", () => {
|
||||
const merging = (conflicts: number) => ({
|
||||
available: true as const,
|
||||
branch: "main",
|
||||
head: "abc1234",
|
||||
subject: "main",
|
||||
dirty: 1,
|
||||
untracked: 0,
|
||||
files: [],
|
||||
hasRemote: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
merging: true,
|
||||
conflicts,
|
||||
});
|
||||
const conflicted = changes({
|
||||
files: [
|
||||
{
|
||||
path: "a.txt",
|
||||
status: "UU",
|
||||
untracked: false,
|
||||
staged: true,
|
||||
unstaged: true,
|
||||
conflicted: true,
|
||||
added: 3,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
},
|
||||
{
|
||||
path: "b.txt",
|
||||
status: "A ",
|
||||
untracked: false,
|
||||
staged: true,
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it("lists conflicts on their own, resolves one side, and blocks the commit until none are left", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue(merging(1));
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(conflicted);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={3} />);
|
||||
|
||||
const group = await screen.findByTestId("lane-changes-conflicts");
|
||||
expect(within(group).getByText("a.txt")).toBeInTheDocument();
|
||||
// Not offered as an ordinary stage: that would mark it resolved blind.
|
||||
expect(screen.getAllByText("a.txt")).toHaveLength(1);
|
||||
expect(screen.getByRole("button", { name: "Complete merge" })).toBeDisabled();
|
||||
|
||||
await user.click(within(group).getByRole("button", { name: "Take theirs" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "resolve", {
|
||||
path: "a.txt",
|
||||
side: "theirs",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("completes the merge once nothing is conflicted, and aborts only on a second click", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue(merging(0));
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={3} />);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Complete merge" }));
|
||||
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge-commit", {}));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Abort merge" }));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalledWith(3, "merge-abort", expect.anything());
|
||||
await user.click(screen.getByRole("button", { name: "Click again to abort the merge" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge-abort", { confirm: true })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,13 +3,21 @@
|
||||
* @description Pins the side-by-side renderer: two independently scrolling
|
||||
* columns (a long line must be reachable, not painted over its neighbour)
|
||||
* whose horizontal scroll is mirrored, so the two halves of a line stay
|
||||
* opposite each other.
|
||||
* opposite each other. Also pins the clamped mirroring (a narrow column may
|
||||
* not drag the wide one back) and the row windowing of large diffs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { LaneDiffView } from "../LaneDiffView";
|
||||
import {
|
||||
LaneDiffView,
|
||||
LaneFileView,
|
||||
ROW_PX,
|
||||
WINDOW_THRESHOLD,
|
||||
analyseHunk,
|
||||
visibleRange,
|
||||
} from "../LaneDiffView";
|
||||
import type { LaneChangeFile } from "../../../lib/types";
|
||||
|
||||
const FILE: LaneChangeFile = {
|
||||
@@ -33,6 +41,17 @@ const FILE: LaneChangeFile = {
|
||||
],
|
||||
};
|
||||
|
||||
/** jsdom has no layout: give a scroller the extents a browser would. */
|
||||
function layout(
|
||||
el: HTMLElement,
|
||||
g: { scrollWidth?: number; clientWidth?: number; scrollHeight?: number; clientHeight?: number }
|
||||
) {
|
||||
for (const [k, v] of Object.entries(g))
|
||||
Object.defineProperty(el, k, { value: v, configurable: true });
|
||||
}
|
||||
|
||||
const ROOMY = { scrollWidth: 2000, clientWidth: 200, scrollHeight: 2000, clientHeight: 200 };
|
||||
|
||||
describe("LaneDiffView — side by side", () => {
|
||||
it("gives each column its own scroller", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
@@ -40,11 +59,28 @@ describe("LaneDiffView — side by side", () => {
|
||||
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
|
||||
});
|
||||
|
||||
it("bounds its own height so the horizontal bar stays on screen", () => {
|
||||
// A stacked review list caps each file; the single-file view takes the
|
||||
// height its parent gives it. Either way the diff scrolls itself rather
|
||||
// than growing a 2000-line container whose scrollbar is off the bottom.
|
||||
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
|
||||
// windowed one keeps the cap because windowing needs its own scroller.
|
||||
const { rerender } = render(<LaneDiffView file={FILE} layout="unified" />);
|
||||
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
|
||||
|
||||
const big: LaneChangeFile = {
|
||||
...FILE,
|
||||
hunks: [
|
||||
{
|
||||
...FILE.hunks![0]!,
|
||||
lines: Array.from({ length: WINDOW_THRESHOLD + 1 }, (_, i) => ({
|
||||
type: "ctx" as const,
|
||||
oldLine: i + 1,
|
||||
newLine: i + 1,
|
||||
text: `line ${i}`,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
rerender(<LaneDiffView file={big} layout="unified" />);
|
||||
expect(screen.getByTestId("lane-diff-view").className).toContain("max-h-[60vh]");
|
||||
|
||||
rerender(<LaneDiffView file={FILE} layout="unified" fill />);
|
||||
@@ -56,6 +92,8 @@ describe("LaneDiffView — side by side", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, ROOMY);
|
||||
layout(right, ROOMY);
|
||||
|
||||
left.scrollLeft = 320;
|
||||
fireEvent.scroll(left);
|
||||
@@ -66,6 +104,55 @@ describe("LaneDiffView — side by side", () => {
|
||||
expect(left.scrollLeft).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the split columns on the same row vertically too", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, ROOMY);
|
||||
layout(right, ROOMY);
|
||||
|
||||
left.scrollTop = 480;
|
||||
fireEvent.scroll(left);
|
||||
expect(right.scrollTop).toBe(480);
|
||||
|
||||
right.scrollTop = 40;
|
||||
fireEvent.scroll(right);
|
||||
expect(left.scrollTop).toBe(40);
|
||||
});
|
||||
|
||||
it("clamps the mirrored offset to the other column's own extent", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, { scrollWidth: 500, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
|
||||
layout(right, { scrollWidth: 1000, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
|
||||
|
||||
right.scrollLeft = 600;
|
||||
right.scrollTop = 250;
|
||||
fireEvent.scroll(right);
|
||||
expect(left.scrollLeft).toBe(300);
|
||||
expect(left.scrollTop).toBe(100);
|
||||
});
|
||||
|
||||
it("does not let the narrow column's clamped echo drag the wide one back", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, { scrollWidth: 500, clientWidth: 200 });
|
||||
layout(right, { scrollWidth: 1000, clientWidth: 200 });
|
||||
|
||||
right.scrollLeft = 600;
|
||||
fireEvent.scroll(right);
|
||||
// A browser fires the narrow column's scroll for the clamped write.
|
||||
fireEvent.scroll(left);
|
||||
expect(right.scrollLeft).toBe(600);
|
||||
|
||||
// A real move of the narrow column still carries the wide one.
|
||||
left.scrollLeft = 100;
|
||||
fireEvent.scroll(left);
|
||||
expect(right.scrollLeft).toBe(100);
|
||||
});
|
||||
|
||||
it("keeps the unified layout on one scroller", () => {
|
||||
render(<LaneDiffView file={FILE} layout="unified" />);
|
||||
expect(screen.queryByTestId("lane-diff-split-left")).toBeNull();
|
||||
@@ -158,3 +245,217 @@ describe("LaneDiffView — row styling", () => {
|
||||
expect(left!.className).not.toMatch(/status-(success|danger)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyseHunk", () => {
|
||||
const hunk = {
|
||||
header: "@@ -1,4 +1,4 @@",
|
||||
context: "",
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ type: "ctx" as const, oldLine: 1, newLine: 1, text: "keep" },
|
||||
{ type: "del" as const, oldLine: 2, newLine: null, text: "foo = 1" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 2, text: "foo = 2" },
|
||||
{ type: "del" as const, oldLine: 3, newLine: null, text: "bar = 1" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 3, text: "bar = 2" },
|
||||
],
|
||||
};
|
||||
|
||||
it("pairs each del/add with its emphasis, keyed by line index", () => {
|
||||
const a = analyseHunk(hunk);
|
||||
expect(a.rows).toHaveLength(3);
|
||||
expect(a.emph.get(1)).toEqual([6, 7]);
|
||||
expect(a.emph.get(2)).toEqual([6, 7]);
|
||||
expect(a.emph.get(3)).toEqual([6, 7]);
|
||||
expect(a.emph.get(4)).toEqual([6, 7]);
|
||||
expect(a.emph.has(0)).toBe(false);
|
||||
});
|
||||
|
||||
it("computes once per hunk, so both split columns and re-renders share it", () => {
|
||||
expect(analyseHunk(hunk)).toBe(analyseHunk(hunk));
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleRange", () => {
|
||||
it("covers the viewport plus overscan on both sides", () => {
|
||||
expect(visibleRange(0, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
|
||||
expect(visibleRange(1600, 160, 16, 1000, 5)).toEqual({ start: 95, end: 115 });
|
||||
});
|
||||
|
||||
it("clamps a scrollTop past the content to the last full viewport", () => {
|
||||
expect(visibleRange(99999, 160, 16, 1000, 5)).toEqual({ start: 985, end: 1000 });
|
||||
expect(visibleRange(-50, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
|
||||
});
|
||||
|
||||
it("never runs past a short list", () => {
|
||||
expect(visibleRange(0, 800, 16, 3, 5)).toEqual({ start: 0, end: 3 });
|
||||
expect(visibleRange(0, 800, 16, 0, 5)).toEqual({ start: 0, end: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
function bigFile(n: number): LaneChangeFile {
|
||||
const lines = Array.from({ length: n }, (_, i) =>
|
||||
i % 2
|
||||
? { type: "add" as const, oldLine: null, newLine: i + 1, text: `line ${i};` }
|
||||
: { type: "ctx" as const, oldLine: i + 1, newLine: i + 1, text: `line ${i};` }
|
||||
);
|
||||
lines[7] = { type: "ctx", oldLine: 8, newLine: 8, text: `line 7; ${"w".repeat(300)}` };
|
||||
return fileWith(lines);
|
||||
}
|
||||
|
||||
const rowsIn = (el: HTMLElement) => [...el.querySelectorAll<HTMLElement>("div.flex.w-max")];
|
||||
|
||||
describe("LaneDiffView — windowing", () => {
|
||||
it("renders every row below the threshold, with no spacers", () => {
|
||||
const { container } = render(
|
||||
<LaneDiffView file={bigFile(WINDOW_THRESHOLD - 10)} layout="unified" />
|
||||
);
|
||||
expect(rowsIn(container)).toHaveLength(WINDOW_THRESHOLD - 10);
|
||||
expect(container.querySelector("[data-diff-spacer]")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders far fewer rows than a 5000-line diff holds, keeping the full scroll height", () => {
|
||||
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
const rows = rowsIn(container);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(rows.length).toBeLessThan(300);
|
||||
const spacers = [...container.querySelectorAll<HTMLElement>("[data-diff-spacer]")];
|
||||
const spaced = spacers.reduce((sum, s) => sum + parseFloat(s.style.height), 0);
|
||||
// header + 5000 lines, every item one fixed row tall
|
||||
const rendered = container.querySelectorAll("[data-diff-item]").length;
|
||||
expect(spaced + rendered * ROW_PX).toBe(5001 * ROW_PX);
|
||||
});
|
||||
|
||||
it("moves the window with the scroll", () => {
|
||||
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
const view = screen.getByTestId("lane-diff-view");
|
||||
expect(container.textContent).not.toContain("line 3000;");
|
||||
view.scrollTop = 3000 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
expect(container.textContent).toContain("line 3000;");
|
||||
expect(container.textContent).not.toContain("line 10;");
|
||||
});
|
||||
|
||||
it("starts windowing where the reader is when a diff grows past the threshold", () => {
|
||||
const { container, rerender } = render(<LaneDiffView file={bigFile(400)} layout="unified" />);
|
||||
const view = screen.getByTestId("lane-diff-view");
|
||||
view.scrollTop = 350 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
// Expanding context replaces the file in place, same mounted view.
|
||||
rerender(<LaneDiffView file={bigFile(600)} layout="unified" />);
|
||||
expect(container.textContent).toContain("line 350;");
|
||||
});
|
||||
|
||||
it("starts from the top again after a round trip through split", () => {
|
||||
const { container, rerender } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
const view = screen.getByTestId("lane-diff-view");
|
||||
view.scrollTop = 3000 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
rerender(<LaneDiffView file={bigFile(5000)} layout="split" />);
|
||||
// In split the outer box is no scroller; the browser drops its offset.
|
||||
screen.getByTestId("lane-diff-view").scrollTop = 0;
|
||||
rerender(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
expect(container.textContent).toContain("line 5;");
|
||||
});
|
||||
|
||||
it("holds the horizontal extent at the longest line, rendered or not", () => {
|
||||
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
const view = screen.getByTestId("lane-diff-view");
|
||||
view.scrollTop = 3000 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
const content = container.querySelector<HTMLElement>("[data-diff-content]")!;
|
||||
// the 300-w line 7 is off screen now, and still sets the width
|
||||
expect(content.style.minWidth).toContain(`${"line 7; ".length + 300 + 1}ch`);
|
||||
});
|
||||
|
||||
it("windows both split columns over the same row range", () => {
|
||||
render(<LaneDiffView file={bigFile(5000)} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
|
||||
layout(right, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
|
||||
right.scrollTop = 2000 * ROW_PX;
|
||||
fireEvent.scroll(right);
|
||||
expect(left.scrollTop).toBe(2000 * ROW_PX);
|
||||
const l = rowsIn(left);
|
||||
const r = rowsIn(right);
|
||||
expect(l.length).toBeLessThan(300);
|
||||
expect(l).toHaveLength(r.length);
|
||||
expect(right.textContent).toContain("line 2001;");
|
||||
// same rows: the right column's add has its filler row on the left
|
||||
expect(left.textContent).toContain("line 2000;");
|
||||
});
|
||||
|
||||
it("windows a whole-file read too", () => {
|
||||
const lines = Array.from({ length: 5000 }, (_, i) => `row ${i}`);
|
||||
const { container } = render(<LaneFileView path="a.ts" lines={lines} />);
|
||||
expect(rowsIn(container).length).toBeLessThan(300);
|
||||
const view = screen.getByTestId("lane-file-view");
|
||||
view.scrollTop = 4000 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
expect(container.textContent).toContain("row 4000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneDiffView — hunk review bar navigation", () => {
|
||||
const hunk = (n: number) => ({
|
||||
header: `@@ -${n},1 +${n},1 @@`,
|
||||
context: "",
|
||||
oldStart: n,
|
||||
newStart: n,
|
||||
lines: [
|
||||
{ type: "del" as const, oldLine: n, newLine: null, text: `old ${n}` },
|
||||
{ type: "add" as const, oldLine: null, newLine: n, text: `new ${n}` },
|
||||
],
|
||||
});
|
||||
const three: LaneChangeFile = { ...FILE, hunks: [hunk(1), hunk(20), hunk(40)] };
|
||||
|
||||
// jsdom lays nothing out: give the ONE real scroller a scrollable box and
|
||||
// put hunk k's `@@` row k×100px below its top, wherever it has scrolled to.
|
||||
function renderInScroller() {
|
||||
const view = render(
|
||||
<div data-testid="scroller" style={{ overflowY: "auto" }}>
|
||||
<LaneDiffView file={three} layout="unified" hunkActions={() => <span />} />
|
||||
</div>
|
||||
);
|
||||
const scroller = screen.getByTestId("scroller");
|
||||
Object.defineProperty(scroller, "scrollHeight", { value: 1000 });
|
||||
Object.defineProperty(scroller, "clientHeight", { value: 100 });
|
||||
scroller.getBoundingClientRect = () => ({ top: 0 }) as DOMRect;
|
||||
for (const el of view.container.querySelectorAll<HTMLElement>("[data-hunk-index]")) {
|
||||
const k = Number(el.dataset.hunkIndex);
|
||||
el.getBoundingClientRect = () => ({ top: k * 100 - scroller.scrollTop }) as DOMRect;
|
||||
}
|
||||
return scroller;
|
||||
}
|
||||
|
||||
it("steps to the NEXT hunk and back to the PREVIOUS one, not the first", () => {
|
||||
const scroller = renderInScroller();
|
||||
const next = screen.getAllByRole("button", { name: "Next change" });
|
||||
const prev = screen.getAllByRole("button", { name: "Previous change" });
|
||||
|
||||
fireEvent.click(next[0]!); // bar 1 → hunk 2
|
||||
expect(scroller.scrollTop).toBe(100);
|
||||
fireEvent.click(next[1]!); // bar 2 → hunk 3
|
||||
expect(scroller.scrollTop).toBe(200);
|
||||
fireEvent.click(prev[2]!); // bar 3 → hunk 2, not hunk 1
|
||||
expect(scroller.scrollTop).toBe(100);
|
||||
});
|
||||
|
||||
it("counts the hunks and disables the arrows at either end", () => {
|
||||
renderInScroller();
|
||||
const bars = screen.getAllByTestId("lane-hunk-bar");
|
||||
expect(bars.map((b) => b.textContent)).toEqual([
|
||||
expect.stringContaining("1 of 3"),
|
||||
expect.stringContaining("2 of 3"),
|
||||
expect.stringContaining("3 of 3"),
|
||||
]);
|
||||
expect(screen.getAllByRole("button", { name: "Previous change" })[0]).toBeDisabled();
|
||||
expect(screen.getAllByRole("button", { name: "Next change" })[2]).toBeDisabled();
|
||||
});
|
||||
|
||||
it("leaves an unwindowed review diff uncapped, so the panel is its only scroller", () => {
|
||||
render(<LaneDiffView file={three} layout="unified" />);
|
||||
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* @file LaneGitBar.test.tsx
|
||||
* @description Pins the lane git action row: the ahead/behind readout, which
|
||||
* buttons a repo with no remote may press, that commit sends the typed
|
||||
* message, that a dirty tree blocks the branch switch client-side (the server
|
||||
* buttons a repo with no remote may press (and that it may publish to a typed URL),
|
||||
* what an unborn repo and a branch with no upstream show, that it offers no commit (the
|
||||
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
|
||||
* refuses it too), and that a failed action shows git's own words rather than
|
||||
* a generic sentence.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -24,6 +25,7 @@ function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}):
|
||||
available: true,
|
||||
branch: "feat/thing",
|
||||
head: "9b3e74a",
|
||||
unborn: false,
|
||||
subject: "wip",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
@@ -70,28 +72,72 @@ describe("LaneGitBar", () => {
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Fetch/ })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /Push/ })).toBeDisabled();
|
||||
// Local actions stay available — a repo with no remote still commits.
|
||||
expect(screen.getByRole("button", { name: /^Commit$/ })).toBeEnabled();
|
||||
// Local actions stay available — a repo with no remote still branches.
|
||||
expect(screen.getByRole("button", { name: /Branch/ })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("commits with the typed message and reports the change upward", async () => {
|
||||
it("offers no commit at all — that is the Changes panel's staged-only box", () => {
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
expect(screen.queryByRole("button", { name: /Commit/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an unborn repo's branch and asks for a first commit instead of a push", () => {
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({
|
||||
branch: "main",
|
||||
head: null,
|
||||
unborn: true,
|
||||
subject: "",
|
||||
hasRemote: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
})}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("lane-git-bar")).toHaveTextContent("no commits yet");
|
||||
expect(screen.getByText(/Commit first, then publish/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Fetch|Pull|Push|Publish/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("publishes a repo with no remote to a typed URL", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={onChanged} />);
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ hasRemote: false, upstream: null, ahead: null, behind: null })}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
|
||||
await user.type(screen.getByLabelText("Commit message"), "fix: the thing");
|
||||
await user.click(screen.getByRole("button", { name: /Commit 3 files/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Publish…" }));
|
||||
await user.type(screen.getByLabelText(/Remote URL/), "git@host:owner/repo.git");
|
||||
await user.click(screen.getByRole("button", { name: "Publish" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", { message: "fix: the thing" })
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "publish", {
|
||||
url: "git@host:owner/repo.git",
|
||||
})
|
||||
);
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers no commit when nothing is uncommitted", () => {
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /^Commit$/ })).toBeDisabled();
|
||||
it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ upstream: null, ahead: null, behind: null })}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /^Push$/ })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Publish branch" }));
|
||||
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "push", {}));
|
||||
});
|
||||
|
||||
it("loads the branch list on demand and refuses to switch while dirty", async () => {
|
||||
@@ -120,6 +166,15 @@ describe("LaneGitBar", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("re-reads the branch list after a switch, so the branch just left is not held", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("creates a branch without switching, then offers a sibling lane on it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNewLane = vi.fn();
|
||||
@@ -146,20 +201,32 @@ describe("LaneGitBar", () => {
|
||||
expect(onNewLane).toHaveBeenCalledWith("development");
|
||||
});
|
||||
|
||||
it("commits only the index when asked", async () => {
|
||||
it("offers a sibling lane only on a branch no worktree holds — the server refuses the rest", async () => {
|
||||
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
||||
available: true,
|
||||
branches: ["development", "feat/thing", "feat/free"],
|
||||
current: "feat/thing",
|
||||
worktrees: [
|
||||
{ path: "/repo/lane3", branch: "feat/thing" },
|
||||
{ path: "/repo/dev", branch: "development" },
|
||||
],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
const newLane = () => screen.getByRole("button", { name: "New lane here" });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
|
||||
await user.type(screen.getByLabelText(/Commit message/), "fix: staged only");
|
||||
await user.click(screen.getByRole("button", { name: "Commit staged" }));
|
||||
// This lane's own branch: its worktree is this lane.
|
||||
await user.selectOptions(select, "feat/thing");
|
||||
expect(newLane()).toBeDisabled();
|
||||
// Held by another worktree: git allows one worktree per branch (EBRANCHBUSY).
|
||||
await user.selectOptions(select, "development");
|
||||
expect(newLane()).toBeDisabled();
|
||||
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", {
|
||||
message: "fix: staged only",
|
||||
only: "staged",
|
||||
})
|
||||
);
|
||||
await user.selectOptions(select, "feat/free");
|
||||
expect(newLane()).toBeEnabled();
|
||||
});
|
||||
|
||||
it("prints git's own rejection text when an action fails", async () => {
|
||||
@@ -172,4 +239,168 @@ describe("LaneGitBar", () => {
|
||||
await user.click(screen.getByRole("button", { name: /Push/ }));
|
||||
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent("non-fast-forward");
|
||||
});
|
||||
|
||||
it("re-reads the branch list for the new lane instead of showing the old one's", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
|
||||
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
|
||||
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
||||
expect(await screen.findByLabelText("Switch branch…")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("sends one branch-create for a double Enter", async () => {
|
||||
vi.mocked(api.lanes.gitAction).mockReturnValue(new Promise(() => {}));
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.type(await screen.findByLabelText(/New branch/), "feat/once{Enter}{Enter}");
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forgets which branches the previous lane's worktrees held once the lane changes", async () => {
|
||||
let resolveOld: (v: Awaited<ReturnType<typeof api.lanes.gitBranches>>) => void = () => {};
|
||||
vi.mocked(api.lanes.gitBranches).mockReturnValueOnce(
|
||||
new Promise((r) => {
|
||||
resolveOld = r;
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
|
||||
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
||||
// Lane 3's answer lands late: its worktree held `development`.
|
||||
resolveOld({
|
||||
available: true,
|
||||
branches: ["development", "feat/thing"],
|
||||
current: "feat/thing",
|
||||
worktrees: [{ path: "/repo/dev", branch: "development" }],
|
||||
});
|
||||
await user.selectOptions(select, "development");
|
||||
const newLane = screen.getByRole("button", { name: "New lane here" });
|
||||
expect(newLane).toBeEnabled();
|
||||
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
||||
});
|
||||
|
||||
it("claims no sync state when the upstream comparison could not be counted", () => {
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ upstream: "origin/gone", ahead: null, behind: null })}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText("in sync")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-git-tracking")).toBeNull();
|
||||
});
|
||||
|
||||
it("explains a push with nowhere to go instead of the server's generic text", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.gitAction).mockRejectedValue(
|
||||
Object.assign(new Error("this repository has no remote to push to"), { code: "ENOREMOTE" })
|
||||
);
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Push/ }));
|
||||
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent(
|
||||
"No remote to push to or pull from"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("git login (issue #45)", () => {
|
||||
const authErr = (rejected = false) =>
|
||||
Object.assign(new Error("git.smartgift.io.vn needs a username and password or access token"), {
|
||||
code: "EGITAUTH",
|
||||
auth: { protocol: "https", host: "git.smartgift.io.vn", username: "vi", rejected },
|
||||
});
|
||||
|
||||
it("asks for a login when git needs one, then resends the same action with it", async () => {
|
||||
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(authErr());
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={onChanged} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Push/ }));
|
||||
const form = await screen.findByTestId("lane-git-login");
|
||||
expect(form).toHaveTextContent("git.smartgift.io.vn");
|
||||
expect(screen.getByLabelText("Username")).toHaveValue("vi");
|
||||
expect(screen.getByLabelText(/Remember for everyone/)).toBeChecked();
|
||||
await user.type(screen.getByLabelText("Password or access token"), "s3cret");
|
||||
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "push", {
|
||||
credentials: {
|
||||
protocol: "https",
|
||||
host: "git.smartgift.io.vn",
|
||||
username: "vi",
|
||||
password: "s3cret",
|
||||
remember: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-login")).toBeNull());
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
expect(document.body.textContent).not.toContain("s3cret");
|
||||
});
|
||||
|
||||
it("says the login was wrong when the host rejected it, and keeps the form", async () => {
|
||||
vi.mocked(api.lanes.gitAction)
|
||||
.mockRejectedValueOnce(authErr())
|
||||
.mockRejectedValueOnce(authErr(true));
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Fetch/ }));
|
||||
await user.type(await screen.findByLabelText("Password or access token"), "bad");
|
||||
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
|
||||
expect(await screen.findByTestId("lane-git-login")).toHaveTextContent(/rejected/i);
|
||||
expect(screen.getByLabelText("Password or access token")).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("merge (issue #45)", () => {
|
||||
it("merges the picked branch into the current one after a confirm", async () => {
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "Merge into current" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
|
||||
);
|
||||
confirm.mockRestore();
|
||||
});
|
||||
|
||||
it("offers to merge the upstream when the branch has diverged", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "Merge origin/feat/thing" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "origin/feat/thing" })
|
||||
);
|
||||
});
|
||||
|
||||
it("disables pull and switch while a merge is in progress", () => {
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={{ ...facts(), merging: true, conflicts: 1 } as LaneGitFacts}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Pull/ })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /Branch/ })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* @file LaneGitHeader.test.tsx
|
||||
* @description Pins the Changes panel's working-copy line: the live branch,
|
||||
* head, subject and uncommitted counts, the per-file hover panel behind the
|
||||
* counts, that a lane switch drops the previous lane's facts at once, and that
|
||||
* an unreadable repo renders nothing rather than an error.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "../LaneGitHeader";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { git: vi.fn(), gitAction: vi.fn(), gitBranches: vi.fn() } },
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
});
|
||||
|
||||
/** The panel's own wiring: one facts read feeding the header. */
|
||||
function Harness({ laneId }: { laneId: number }) {
|
||||
const [git, refresh] = useLaneGitFacts(laneId);
|
||||
return <LaneGitHeader laneId={laneId} git={git} onChanged={refresh} />;
|
||||
}
|
||||
|
||||
describe("LaneGitHeader", () => {
|
||||
const facts = {
|
||||
available: true as const,
|
||||
branch: "feat/rename-metric",
|
||||
head: "9b3e74a",
|
||||
subject: "free-text rule mode in the form",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
files: [
|
||||
{ path: "server/lib/alerts.js", status: " M", added: 12, deleted: 3 },
|
||||
{ path: "client/src/lib/api.ts", status: "M ", added: 1, deleted: 0 },
|
||||
{ path: "scratch.txt", status: "??", added: null, deleted: null },
|
||||
],
|
||||
// Tracking facts ride along on the same endpoint; a branch that has never
|
||||
// been pushed reports nulls rather than zeros.
|
||||
hasRemote: true,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
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 () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
const { rerender } = render(<Harness laneId={1} />);
|
||||
await screen.findByText(/feat\/rename-metric/);
|
||||
|
||||
// The next lane's read never lands: nothing of lane 1's git may linger.
|
||||
vi.mocked(api.lanes.git).mockReturnValueOnce(new Promise(() => {}));
|
||||
rerender(<Harness laneId={2} />);
|
||||
expect(screen.queryByText(/feat\/rename-metric/)).toBeNull();
|
||||
});
|
||||
|
||||
it("names the files behind the counts on hover, with each one's line delta", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
// Hidden until asked for: the row is a one-line summary until you hover.
|
||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||
|
||||
fireEvent.mouseEnter(counts);
|
||||
const panel = screen.getByTestId("lane-git-files");
|
||||
expect(panel.textContent).toContain("alerts.js");
|
||||
expect(panel.textContent).toContain("+12");
|
||||
expect(panel.textContent).toContain("−3");
|
||||
// Totals across the listed files.
|
||||
expect(panel.textContent).toContain("+13");
|
||||
// An untracked file has no delta to report and says so rather than "+0".
|
||||
expect(panel.textContent).toContain("new");
|
||||
expect(panel.textContent).toContain("3 files changed");
|
||||
|
||||
// Closing is delayed so the pointer can travel into the panel and scroll
|
||||
// it — the panel is portalled to the body, away from its trigger.
|
||||
fireEvent.mouseLeave(counts);
|
||||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||||
});
|
||||
|
||||
it("stays open while the pointer is inside the panel", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
fireEvent.mouseEnter(counts);
|
||||
fireEvent.mouseLeave(counts);
|
||||
// The pointer landed on the panel before the pending close fired.
|
||||
fireEvent.mouseEnter(screen.getByTestId("lane-git-files"));
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||||
|
||||
fireEvent.mouseLeave(screen.getByTestId("lane-git-files"));
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||||
});
|
||||
|
||||
it("says how many files the server-side cap left out", async () => {
|
||||
// 40 uncommitted files, 3 listed: the counts are exact, the listing is not.
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
||||
});
|
||||
|
||||
it("omits the uncommitted line when the tree is clean", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
|
||||
render(<Harness laneId={1} />);
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
expect(block.textContent).not.toContain("modified");
|
||||
});
|
||||
|
||||
it("renders nothing and no error when git is unavailable", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
|
||||
render(<Harness laneId={2} />);
|
||||
|
||||
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(2));
|
||||
expect(screen.queryByTestId("lane-git-header")).toBeNull();
|
||||
});
|
||||
|
||||
it("swallows a rejected request instead of surfacing an error", async () => {
|
||||
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
|
||||
render(<Harness laneId={3} />);
|
||||
|
||||
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(3));
|
||||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||||
expect(screen.queryByText(/network down/)).toBeNull();
|
||||
});
|
||||
|
||||
it("stops polling once the panel unmounts", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
||||
const { unmount } = render(<Harness laneId={1} />);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -16,8 +16,9 @@
|
||||
* and its live tmux session — instead of leaving the previous lane's terminal
|
||||
* on screen under a new lane's header.
|
||||
*
|
||||
* A lane's pane is one tmux session, so starting or resuming into a lane
|
||||
* already holding a live run answers 409 ERUNLIVE rather than overwriting a
|
||||
* Each pane is one tmux session on its lane — split panes all show the same
|
||||
* lane, pane `slot` N owning `agenthub-lane-<id>[-N]` — so starting or resuming
|
||||
* into a pane already holding a live run answers 409 ERUNLIVE rather than overwriting a
|
||||
* prompt in flight — `onStartFromSetup` and `onResumeFromHistory` both catch
|
||||
* that and attach to the still-live session instead of failing outright
|
||||
* (with a notice explaining what happened), since that session is still
|
||||
@@ -27,7 +28,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Play, AlertCircle } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
@@ -47,6 +48,20 @@ import { TerminalView } from "./TerminalView";
|
||||
import { RunSetup } from "./RunSetup";
|
||||
import { ActiveRunsSwitcher } from "./RunHistory";
|
||||
|
||||
/** The claude lane a session's cwd belongs to: the longest path-boundary
|
||||
* prefix, the same rule the server binds hook sessions with. External
|
||||
* sessions are all claude — Codex emits no hooks. */
|
||||
function owningLaneId(cwd: string, lanes: Lane[]): number | null {
|
||||
let best: Lane | null = null;
|
||||
for (const l of lanes) {
|
||||
if (l.tool !== "claude") continue;
|
||||
const root = l.cwd.replace(/\/+$/, "");
|
||||
if (cwd !== root && !cwd.startsWith(root + "/")) continue;
|
||||
if (!best || root.length > best.cwd.replace(/\/+$/, "").length) best = l;
|
||||
}
|
||||
return best?.id ?? null;
|
||||
}
|
||||
|
||||
export interface LaneConsolePaneProps {
|
||||
lanes: Lane[];
|
||||
laneId: number | null;
|
||||
@@ -62,6 +77,19 @@ export interface LaneConsolePaneProps {
|
||||
wsConnected: boolean;
|
||||
defaultCwd?: string;
|
||||
onHasActiveRunChange?: (active: boolean) => void;
|
||||
/** Which split pane this is. Every pane shows the SAME lane, each in its own
|
||||
* tmux session: slot 0 is the lane's own, 1–3 are `agenthub-lane-<id>-<slot>`. */
|
||||
slot?: number;
|
||||
/** Runs the OTHER panes are showing — left out of this pane's Active runs so
|
||||
* one terminal is never attached twice side by side. */
|
||||
hiddenRunIds?: string[];
|
||||
/** Reports the run this pane shows (null on the setup form). */
|
||||
onHandleChange?: (runId: string | null) => void;
|
||||
}
|
||||
|
||||
/** The tmux session a lane's split pane owns (see server/lib/pty-run.js). */
|
||||
function paneRunId(laneId: number, slot: number): string {
|
||||
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
|
||||
}
|
||||
|
||||
export function LaneConsolePane({
|
||||
@@ -77,6 +105,9 @@ export function LaneConsolePane({
|
||||
wsConnected,
|
||||
defaultCwd,
|
||||
onHasActiveRunChange,
|
||||
slot = 0,
|
||||
hiddenRunIds,
|
||||
onHandleChange,
|
||||
}: LaneConsolePaneProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const { t: tLanes } = useTranslation("lanes");
|
||||
@@ -97,12 +128,37 @@ export function LaneConsolePane({
|
||||
|
||||
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
|
||||
const laneTool = currentLane?.tool ?? "claude";
|
||||
// The page polls runs and sessions for EVERY lane; this pane's Active runs
|
||||
// list is this lane's only. No lane selected keeps the full lists.
|
||||
const laneRuns = useMemo(
|
||||
() =>
|
||||
laneId === null || !activeRuns
|
||||
? activeRuns
|
||||
: {
|
||||
...activeRuns,
|
||||
items: activeRuns.items.filter(
|
||||
(r) => r.laneId === laneId && !hiddenRunIds?.includes(r.id)
|
||||
),
|
||||
},
|
||||
[activeRuns, laneId, hiddenRunIds]
|
||||
);
|
||||
const laneSessions = useMemo(
|
||||
() =>
|
||||
laneId === null
|
||||
? externalSessions
|
||||
: (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId),
|
||||
[externalSessions, laneId, lanes]
|
||||
);
|
||||
const toolLabel = laneTool === "codex" ? "Codex" : "Claude";
|
||||
|
||||
useEffect(() => {
|
||||
onHasActiveRunChange?.(handle !== null);
|
||||
}, [handle, onHasActiveRunChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onHandleChange?.(handle?.id ?? null);
|
||||
}, [handle, onHandleChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLane && defaultCwd && cwd === "") {
|
||||
setCwd(defaultCwd);
|
||||
@@ -141,10 +197,11 @@ export function LaneConsolePane({
|
||||
setResumeSession(null);
|
||||
setError(null);
|
||||
setBusy(null);
|
||||
setHandle(runs?.items.find((r) => r.laneId === laneId && r.status === "running") ?? null);
|
||||
const own = laneId === null ? null : paneRunId(laneId, slot);
|
||||
setHandle(runs?.items.find((r) => r.id === own && r.status === "running") ?? null);
|
||||
autoAttachedForLane.current = null;
|
||||
refreshList();
|
||||
}, [laneId, refreshList]);
|
||||
}, [laneId, slot, refreshList]);
|
||||
|
||||
// Picking a pipeline template writes NOTHING into the prompt box: a prefilled
|
||||
// slash command (or the codex stage-checklist prose) is sent as soon as the
|
||||
@@ -161,12 +218,13 @@ export function LaneConsolePane({
|
||||
useEffect(() => {
|
||||
if (handle || laneId === null) return;
|
||||
if (autoAttachedForLane.current === laneId) return;
|
||||
const running = activeRuns?.items.find((r) => r.laneId === laneId && r.status === "running");
|
||||
const own = paneRunId(laneId, slot);
|
||||
const running = activeRuns?.items.find((r) => r.id === own && r.status === "running");
|
||||
if (running) {
|
||||
autoAttachedForLane.current = laneId;
|
||||
setHandle(running);
|
||||
}
|
||||
}, [activeRuns, laneId, handle]);
|
||||
}, [activeRuns, laneId, slot, handle]);
|
||||
|
||||
// The cwd tracks the lane's own folder separately, keyed on the resolved
|
||||
// path rather than on `laneId` alone: the pane can mount before the lane
|
||||
@@ -245,6 +303,7 @@ export function LaneConsolePane({
|
||||
let laneStartResult;
|
||||
try {
|
||||
laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||
slot,
|
||||
prompt: args.initialPrompt || "",
|
||||
model: args.model || undefined,
|
||||
permissionMode: args.permissionMode,
|
||||
@@ -259,29 +318,27 @@ export function LaneConsolePane({
|
||||
// already has a live run"), never the literal strings "409" or
|
||||
// "ERUNLIVE" — matching against `.message` here is dead code that
|
||||
// always falls through to the raw error below.
|
||||
// This pane's own session is the live one — land on it.
|
||||
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
|
||||
const fresh = await api.lanes.list().catch(() => null);
|
||||
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
|
||||
if (updatedLane?.run_id) {
|
||||
await attachToRun(updatedLane.run_id);
|
||||
setError(t("errors.attachedToLiveInstead"));
|
||||
return;
|
||||
}
|
||||
await attachToRun(paneRunId(targetLaneId, slot));
|
||||
setError(t("errors.attachedToLiveInstead"));
|
||||
return;
|
||||
}
|
||||
throw laneErr;
|
||||
}
|
||||
|
||||
if (!laneStartResult.lane?.run_id) {
|
||||
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
|
||||
if (!startedId) {
|
||||
throw new Error(t("errors.noRunIdReturned"));
|
||||
}
|
||||
|
||||
try {
|
||||
const fetched = await api.run.get(laneStartResult.lane.run_id);
|
||||
const fetched = await api.run.get(startedId);
|
||||
setHandle(fetched);
|
||||
refreshList();
|
||||
} catch {
|
||||
try {
|
||||
await attachToRun(laneStartResult.lane.run_id);
|
||||
await attachToRun(startedId);
|
||||
refreshList();
|
||||
} catch (fallbackErr: unknown) {
|
||||
const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown";
|
||||
@@ -295,7 +352,18 @@ export function LaneConsolePane({
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[busy, t, lanes, laneId, laneTool, onLaneIdChange, onLaneCreated, attachToRun, refreshList]
|
||||
[
|
||||
busy,
|
||||
t,
|
||||
lanes,
|
||||
laneId,
|
||||
laneTool,
|
||||
slot,
|
||||
onLaneIdChange,
|
||||
onLaneCreated,
|
||||
attachToRun,
|
||||
refreshList,
|
||||
]
|
||||
);
|
||||
|
||||
const onResumeFromHistory = useCallback(
|
||||
@@ -323,6 +391,7 @@ export function LaneConsolePane({
|
||||
let laneStartResult;
|
||||
try {
|
||||
laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||
slot,
|
||||
prompt: "",
|
||||
model: item.model || undefined,
|
||||
permissionMode: item.permission_mode || undefined,
|
||||
@@ -337,23 +406,20 @@ export function LaneConsolePane({
|
||||
// — the server's `.message` is prose, never the literal string
|
||||
// "ERUNLIVE" or "409".
|
||||
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
|
||||
const fresh = await api.lanes.list().catch(() => null);
|
||||
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
|
||||
if (updatedLane?.run_id) {
|
||||
onLaneIdChange(targetLaneId);
|
||||
await attachToRun(updatedLane.run_id);
|
||||
setError(t("errors.attachedToLiveInstead"));
|
||||
return;
|
||||
}
|
||||
onLaneIdChange(targetLaneId);
|
||||
await attachToRun(paneRunId(targetLaneId, slot));
|
||||
setError(t("errors.attachedToLiveInstead"));
|
||||
return;
|
||||
}
|
||||
throw laneErr;
|
||||
}
|
||||
|
||||
if (!laneStartResult.lane?.run_id) {
|
||||
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
|
||||
if (!startedId) {
|
||||
throw new Error("No run_id returned from lane start");
|
||||
}
|
||||
|
||||
fetched = await api.run.get(laneStartResult.lane.run_id);
|
||||
fetched = await api.run.get(startedId);
|
||||
onLaneIdChange(targetLaneId);
|
||||
} else {
|
||||
fetched = await api.run.start({
|
||||
@@ -377,7 +443,7 @@ export function LaneConsolePane({
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange, attachToRun]
|
||||
[busy, refreshList, t, lanes, slot, onLaneCreated, onLaneIdChange, attachToRun]
|
||||
);
|
||||
|
||||
const onViewFromHistory = useCallback(
|
||||
@@ -487,11 +553,11 @@ export function LaneConsolePane({
|
||||
</p>
|
||||
</div>
|
||||
<ActiveRunsSwitcher
|
||||
activeRuns={activeRuns}
|
||||
activeRuns={laneRuns}
|
||||
currentHandleId={handle?.id || null}
|
||||
onAttach={attachToRun}
|
||||
runHistory={runHistory}
|
||||
externalSessions={externalSessions}
|
||||
externalSessions={laneSessions}
|
||||
onResumeFromHistory={onResumeFromHistory}
|
||||
onViewFromHistory={onViewFromHistory}
|
||||
onKill={killRun}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
*
|
||||
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
|
||||
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
|
||||
* "Active runs" counts everything actually running. They carry no tmux session
|
||||
* "Active runs" counts everything actually running in scope. They carry no tmux session
|
||||
* the dashboard can attach to, so their only action is Resume, which spawns a
|
||||
* fresh tmux-backed `claude --resume <session>` in that cwd — Kill is withheld
|
||||
* from them for the same reason.
|
||||
|
||||
@@ -53,30 +53,214 @@ import type {
|
||||
RunStartArgs,
|
||||
CodexSandbox,
|
||||
CodexApproval,
|
||||
CcMdItem,
|
||||
} from "../../lib/api";
|
||||
import type { Session, LaneTool } from "../../lib/types";
|
||||
import { Select } from "../Select";
|
||||
|
||||
// Minimal SlashCommand type (from deleted RunConsole)
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
source: "project" | "user" | "plugin" | "builtin";
|
||||
source: "project" | "user" | "builtin";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Minimal PromptEditor component (from deleted RunConsole)
|
||||
// Commands the CLI answers itself. They are typed into the pane like any
|
||||
// other text, so they do run — they are just not files anyone can list.
|
||||
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
"help",
|
||||
"clear",
|
||||
"compact",
|
||||
"config",
|
||||
"model",
|
||||
"memory",
|
||||
"hooks",
|
||||
"cost",
|
||||
"agents",
|
||||
"review",
|
||||
"permissions",
|
||||
"status",
|
||||
"init",
|
||||
"mcp",
|
||||
"plugin",
|
||||
"output-style",
|
||||
"exit",
|
||||
].map((name) => ({ name, source: "builtin" }));
|
||||
|
||||
const SOURCE_ORDER = { project: 0, user: 1, builtin: 2 } as const;
|
||||
const SOURCE_TONE: Record<SlashCommand["source"], string> = {
|
||||
project: "text-status-success border-status-success/40",
|
||||
user: "text-accent border-accent/40",
|
||||
builtin: "text-fg-muted border-border",
|
||||
};
|
||||
|
||||
/** Tiered match: exact > prefix > word prefix > substring > subsequence >
|
||||
* description (3+ chars only, so one keystroke drags nothing tangential in). */
|
||||
function scoreSlashMatch(name: string, description: string | undefined, q: string): number {
|
||||
if (!q) return 1;
|
||||
const n = name.toLowerCase();
|
||||
if (n === q) return 1000;
|
||||
if (n.startsWith(q)) return 800 - Math.min(n.length, 100);
|
||||
if (n.split(/[-_.:\s]/).some((p) => p.startsWith(q))) return 600 - Math.min(n.length, 100);
|
||||
const idx = n.indexOf(q);
|
||||
if (idx >= 0) return 400 - Math.min(idx, 100);
|
||||
let i = 0;
|
||||
for (let k = 0; k < n.length && i < q.length; k++) if (n[k] === q[i]) i++;
|
||||
if (i === q.length) return 200;
|
||||
if (q.length >= 3 && (description || "").toLowerCase().includes(q)) return 100;
|
||||
return 0;
|
||||
}
|
||||
|
||||
interface Trigger {
|
||||
kind: "slash" | "file";
|
||||
query: string;
|
||||
/** Where the `/` or `@` token starts, so a pick replaces it up to the cursor. */
|
||||
start: number;
|
||||
cursor: number;
|
||||
}
|
||||
|
||||
/** The `/…` or `@…` token the cursor is in, if any. A token starts at the
|
||||
* line start or after whitespace, so `a/b` and `me@x.io` never trigger. */
|
||||
function detectTrigger(value: string, cursor: number): Trigger | null {
|
||||
let start = cursor;
|
||||
while (start > 0 && !/\s/.test(value[start - 1] ?? " ")) start--;
|
||||
const tok = value.slice(start, cursor);
|
||||
if (tok.startsWith("/")) return { kind: "slash", query: tok.slice(1), start, cursor };
|
||||
if (tok.startsWith("@")) return { kind: "file", query: tok.slice(1), start, cursor };
|
||||
return null;
|
||||
}
|
||||
|
||||
interface PromptEditorProps {
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder: string;
|
||||
rows?: number;
|
||||
slashCommands: SlashCommand[];
|
||||
/** Offer slash commands. Off for codex: these are Claude Code's. */
|
||||
slash: boolean;
|
||||
/** Where `@` looks for files and project commands/skills are read from. */
|
||||
fileCwd: string;
|
||||
}
|
||||
|
||||
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
|
||||
function PromptEditor({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
rows = 5,
|
||||
slash,
|
||||
fileCwd,
|
||||
}: PromptEditorProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [trigger, setTrigger] = useState<Trigger | null>(null);
|
||||
const [active, setActive] = useState(0);
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
const [commands, setCommands] = useState<SlashCommand[] | null>(null);
|
||||
const [commandsCwd, setCommandsCwd] = useState(fileCwd);
|
||||
if (commandsCwd !== fileCwd) {
|
||||
setCommandsCwd(fileCwd);
|
||||
setCommands(null);
|
||||
}
|
||||
const kind = trigger?.kind === "slash" && !slash ? null : trigger?.kind;
|
||||
|
||||
// Commands and skills are read the first time `/` is typed for this cwd —
|
||||
// two directory walks nobody should pay for on every render of the form.
|
||||
useEffect(() => {
|
||||
if (kind !== "slash" || commands) return;
|
||||
let live = true;
|
||||
const toCmd = (c: CcMdItem): SlashCommand => ({
|
||||
name: c.name,
|
||||
source: c.scope,
|
||||
description: c.frontmatter?.description || c.preview.slice(0, 80),
|
||||
});
|
||||
Promise.all([
|
||||
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
]).then(([c, sk]) => {
|
||||
if (live) setCommands([...c.items, ...sk.items].map(toCmd).concat(BUILTIN_SLASH_COMMANDS));
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [kind, commands, fileCwd]);
|
||||
|
||||
// Files: debounced, and only the newest query may land.
|
||||
const query = trigger?.query ?? "";
|
||||
useEffect(() => {
|
||||
if (kind !== "file") return;
|
||||
let live = true;
|
||||
const tid = window.setTimeout(() => {
|
||||
api.run
|
||||
.files(fileCwd, query)
|
||||
.then((r) => live && setFiles(r.items))
|
||||
.catch(() => live && setFiles([]));
|
||||
}, 120);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearTimeout(tid);
|
||||
};
|
||||
}, [kind, query, fileCwd]);
|
||||
|
||||
const slashItems = useMemo(() => {
|
||||
if (kind !== "slash" || !commands) return [];
|
||||
const q = query.toLowerCase();
|
||||
return commands
|
||||
.map((cmd) => ({ cmd, score: scoreSlashMatch(cmd.name, cmd.description, q) }))
|
||||
.filter((x) => x.score > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
SOURCE_ORDER[a.cmd.source] - SOURCE_ORDER[b.cmd.source] ||
|
||||
a.cmd.name.localeCompare(b.cmd.name)
|
||||
)
|
||||
.map((x) => x.cmd);
|
||||
}, [kind, commands, query]);
|
||||
const count = kind === "file" ? files.length : slashItems.length;
|
||||
const current = Math.min(active, Math.max(0, count - 1));
|
||||
|
||||
const pick = (token: string) => {
|
||||
if (!trigger) return;
|
||||
const before = value.slice(0, trigger.start);
|
||||
const after = value.slice(trigger.cursor);
|
||||
const sep = after.startsWith(" ") ? "" : " ";
|
||||
onChange(before + token + sep + after);
|
||||
setTrigger(null);
|
||||
setActive(0);
|
||||
const pos = before.length + token.length + 1;
|
||||
requestAnimationFrame(() => {
|
||||
taRef.current?.focus();
|
||||
taRef.current?.setSelectionRange(pos, pos);
|
||||
});
|
||||
};
|
||||
const pickAt = (i: number) => {
|
||||
if (kind === "file") {
|
||||
const f = files[i];
|
||||
if (f) pick(`@${f}`);
|
||||
} else {
|
||||
const c = slashItems[i];
|
||||
if (c) pick(`/${c.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (kind && count > 0) {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const step = e.key === "ArrowDown" ? 1 : -1;
|
||||
setActive(Math.min(count - 1, Math.max(0, current + step)));
|
||||
return;
|
||||
}
|
||||
if ((e.key === "Enter" || e.key === "Tab") && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
pickAt(current);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (kind && e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setTrigger(null);
|
||||
return;
|
||||
}
|
||||
// Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline.
|
||||
// isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate.
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
@@ -85,15 +269,89 @@ function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: Prom
|
||||
}
|
||||
};
|
||||
|
||||
const retrack = (ta: HTMLTextAreaElement) => {
|
||||
setTrigger(detectTrigger(ta.value, ta.selectionStart ?? 0));
|
||||
};
|
||||
|
||||
const rowClass = (i: number) =>
|
||||
`w-full text-left px-3 py-1.5 transition-colors ${i === current ? "bg-accent/15" : "hover:bg-surface-3"}`;
|
||||
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
/>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
retrack(e.target);
|
||||
setActive(0);
|
||||
}}
|
||||
onSelect={(e) => retrack(e.currentTarget)}
|
||||
onBlur={() => setTrigger(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
spellCheck={false}
|
||||
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
/>
|
||||
{kind && (
|
||||
<div
|
||||
role="listbox"
|
||||
data-testid="prompt-autocomplete"
|
||||
className="absolute z-30 left-0 right-0 top-full mt-1 max-h-72 overflow-auto rounded-md border border-border bg-surface-1 py-1 shadow-lg"
|
||||
>
|
||||
<div className="px-3 py-1.5 border-b border-border text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{kind === "slash" ? t("autocomplete.slashHint") : t("autocomplete.fileHint")}
|
||||
</div>
|
||||
{count === 0 ? (
|
||||
<div className="px-3 py-2 text-[11px] text-fg-muted">
|
||||
{kind === "slash" && !commands ? "…" : t("autocomplete.noMatches")}
|
||||
</div>
|
||||
) : kind === "slash" ? (
|
||||
slashItems.map((c, i) => (
|
||||
<button
|
||||
key={`${c.source}:${c.name}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === current}
|
||||
// Keep focus in the textarea, or its blur closes the list first.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => pickAt(i)}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
className={rowClass(i)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[12px] text-fg-primary">/{c.name}</span>
|
||||
<span
|
||||
className={`rounded border px-1.5 py-0.5 font-mono text-[9px] ${SOURCE_TONE[c.source]}`}
|
||||
>
|
||||
{c.source}
|
||||
</span>
|
||||
</div>
|
||||
{c.description && (
|
||||
<div className="mt-0.5 truncate text-[10.5px] text-fg-muted">{c.description}</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
files.map((f, i) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === current}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => pickAt(i)}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
className={`${rowClass(i)} truncate font-mono text-[11px] text-fg-secondary`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,7 +380,6 @@ interface RunSetupProps {
|
||||
* sessions. Undefined when no lane is selected (the picker then lists
|
||||
* everything, same as before lanes existed). */
|
||||
laneCwd?: string;
|
||||
slashCommands?: SlashCommand[];
|
||||
runHistory: DashboardRunHistoryItem[];
|
||||
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
/** The selected lane's CLI. Decides which advanced fields render. */
|
||||
@@ -287,7 +544,7 @@ export function RunSetup(props: RunSetupProps) {
|
||||
onSubmit={() => handleStart(props)}
|
||||
placeholder={t("fields.promptPlaceholderTerminal")}
|
||||
rows={5}
|
||||
slashCommands={props.slashCommands ?? []}
|
||||
slash={props.tool !== "codex"}
|
||||
fileCwd={props.resumeSession?.cwd || props.cwd}
|
||||
/>
|
||||
<div className="mt-1 text-[10px] text-fg-muted">
|
||||
|
||||
@@ -11,15 +11,23 @@
|
||||
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
|
||||
* Always painted dark, in the palette of the user's chosen DARK theme
|
||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
|
||||
* and its PATH pasted into the pane — what a local terminal does on a drop,
|
||||
* and how `claude` attaches an image.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
|
||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
||||
|
||||
interface TerminalViewProps {
|
||||
runId: string;
|
||||
@@ -45,6 +53,42 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
const paletteRef = useRef(palette);
|
||||
paletteRef.current = palette;
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const { t } = useTranslation("run");
|
||||
const [drop, setDrop] = useState<DropState | null>(null);
|
||||
// dragenter/dragleave fire for every child crossed, so count them: the hint
|
||||
// only goes away when the pointer leaves the terminal itself.
|
||||
const dragDepth = useRef(0);
|
||||
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const flash = (state: DropState) => {
|
||||
setDrop(state);
|
||||
if (flashTimer.current) clearTimeout(flashTimer.current);
|
||||
flashTimer.current = setTimeout(() => setDrop(null), 2500);
|
||||
};
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (flashTimer.current) clearTimeout(flashTimer.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onDrop = async (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
const images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||
if (images.length === 0) return flash("imagesOnly");
|
||||
setDrop("uploading");
|
||||
try {
|
||||
const saved = await Promise.all(images.map((f) => api.run.dropImage(f)));
|
||||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||||
// sees one pasted path rather than typed keystrokes.
|
||||
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
|
||||
termRef.current?.focus();
|
||||
setDrop(null);
|
||||
} catch {
|
||||
flash("failed");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// An explicit fontFamily pins every glyph to one bundled webfont. Left
|
||||
@@ -249,10 +293,44 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ backgroundColor: palette.background }}
|
||||
data-testid="terminal-view"
|
||||
/>
|
||||
className="relative h-full w-full"
|
||||
onDragEnter={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth.current += 1;
|
||||
setDrop("hint");
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault(); // without it the browser refuses the drop
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
|
||||
}}
|
||||
onDrop={(e) => void onDrop(e)}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ backgroundColor: palette.background }}
|
||||
data-testid="terminal-view"
|
||||
/>
|
||||
{drop && (
|
||||
// pointer-events-none: the drop must land on the terminal, not on this.
|
||||
<div
|
||||
data-testid="terminal-drop"
|
||||
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
||||
drop === "failed" || drop === "imagesOnly"
|
||||
? "border-red-400 text-red-300"
|
||||
: "border-sky-400 text-sky-200"
|
||||
}`}
|
||||
>
|
||||
{t(`drop.${drop}`)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -331,4 +331,126 @@ describe("LaneConsolePane", () => {
|
||||
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("console-body")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists only this lane's runs and sessions in Active runs, not every lane's", () => {
|
||||
const run = (id: string, laneId: number, promptPreview: string) => ({
|
||||
id,
|
||||
laneId,
|
||||
status: "running",
|
||||
cwd: "/workspace/x",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: "2026-09-24T01:00:00Z",
|
||||
promptPreview,
|
||||
});
|
||||
const session = (id: string, cwd: string, name: string) =>
|
||||
({
|
||||
id,
|
||||
cwd,
|
||||
name,
|
||||
model: null,
|
||||
source: "local",
|
||||
started_at: "2026-09-24T01:00:00Z",
|
||||
}) as never;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LaneConsolePane
|
||||
{...baseProps()}
|
||||
lanes={[
|
||||
LANE,
|
||||
{ ...LANE, id: 2, cwd: "/workspace/b" },
|
||||
// Nested inside lane 1: its sessions are ITS own, by longest prefix.
|
||||
{ ...LANE, id: 3, cwd: "/workspace/a/nested" },
|
||||
]}
|
||||
activeRuns={{ items: [run("r1", 1, "mine"), run("r2", 2, "other lane's")] as never }}
|
||||
externalSessions={[
|
||||
session("s1", "/workspace/a/src", "my session"),
|
||||
session("s2", "/workspace/b", "b session"),
|
||||
session("s3", "/workspace/a/nested/x", "nested session"),
|
||||
session("s4", "/workspace/ab", "sibling prefix"),
|
||||
]}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /2 active runs/ }));
|
||||
expect(screen.getByText("mine")).toBeTruthy();
|
||||
expect(screen.getByText("my session")).toBeTruthy();
|
||||
expect(screen.queryByText("other lane's")).toBeNull();
|
||||
expect(screen.queryByText("b session")).toBeNull();
|
||||
expect(screen.queryByText("nested session")).toBeNull();
|
||||
expect(screen.queryByText("sibling prefix")).toBeNull();
|
||||
});
|
||||
|
||||
describe("split panes on one lane", () => {
|
||||
const run = (id: string, promptPreview = id) => ({
|
||||
id,
|
||||
laneId: 1,
|
||||
status: "running" as const,
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: "2026-09-24T01:00:00Z",
|
||||
promptPreview,
|
||||
tool: "claude" as const,
|
||||
});
|
||||
|
||||
it("attaches a pane only to its own slot's session", async () => {
|
||||
const activeRuns = { items: [run("agenthub-lane-1"), run("agenthub-lane-1-2")] };
|
||||
render(<LaneConsolePane {...baseProps()} activeRuns={activeRuns} slot={2} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
|
||||
"data-run-id",
|
||||
"agenthub-lane-1-2"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("starts into its own slot and shows the session the server opened", async () => {
|
||||
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
lane: { ...LANE, run_id: "agenthub-lane-1" },
|
||||
run_id: "agenthub-lane-1-1",
|
||||
});
|
||||
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue(run("agenthub-lane-1-1"));
|
||||
render(<LaneConsolePane {...baseProps()} slot={1} />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(
|
||||
1,
|
||||
"start",
|
||||
expect.objectContaining({ slot: 1 })
|
||||
)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
|
||||
"data-run-id",
|
||||
"agenthub-lane-1-1"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves runs other panes show out of Active runs", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LaneConsolePane
|
||||
{...baseProps()}
|
||||
slot={1}
|
||||
activeRuns={{
|
||||
items: [run("agenthub-lane-1", "shown"), run("agenthub-lane-1-3", "free")],
|
||||
}}
|
||||
hiddenRunIds={["agenthub-lane-1"]}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /1 active run/ }));
|
||||
expect(screen.getByText("free")).toBeTruthy();
|
||||
expect(screen.queryByText("shown")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { RunSetup } from "../RunSetup";
|
||||
import type { CwdSuggestion } from "../../../lib/api";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -26,7 +27,22 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
run: { files: r({ items: [] }) },
|
||||
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
|
||||
ccConfig: { file: r({ text: "" }) },
|
||||
ccConfig: {
|
||||
file: r({ text: "" }),
|
||||
commands: r({
|
||||
items: [
|
||||
{
|
||||
scope: "project",
|
||||
name: "ship-it",
|
||||
frontmatter: { description: "Ship the change" },
|
||||
preview: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
skills: r({
|
||||
items: [{ scope: "user", name: "review-pr", frontmatter: {}, preview: "Review a PR" }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -66,7 +82,6 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
|
||||
busy={false}
|
||||
activeRuns={null}
|
||||
resumeSession={null}
|
||||
slashCommands={[]}
|
||||
runHistory={[]}
|
||||
tool="claude"
|
||||
sandbox="workspace-write"
|
||||
@@ -300,7 +315,6 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
||||
busy={false}
|
||||
activeRuns={null}
|
||||
resumeSession={null}
|
||||
slashCommands={[]}
|
||||
runHistory={[]}
|
||||
laneCwd="/Users/tester/lane-b"
|
||||
tool="claude"
|
||||
@@ -364,3 +378,48 @@ describe("RunSetup — per-tool fields", () => {
|
||||
expect(onResumeLast).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — / and @ suggestions in the prompt box", () => {
|
||||
const box = () => screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholderTerminal"));
|
||||
|
||||
it("lists the lane's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
|
||||
const { spies } = renderSetup({ prompt: "" });
|
||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||
|
||||
// Both prefix matches; the shorter name ranks first.
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options.map((o) => o.textContent)).toEqual([
|
||||
expect.stringContaining("/review"),
|
||||
expect.stringContaining("/review-pr"),
|
||||
]);
|
||||
fireEvent.keyDown(box(), { key: "ArrowDown" });
|
||||
// Read for the directory the run will start in, not the server's own.
|
||||
expect(api.ccConfig.commands).toHaveBeenCalledWith(undefined, "/Users/tester");
|
||||
expect(api.ccConfig.skills).toHaveBeenCalledWith(undefined, "/Users/tester");
|
||||
|
||||
fireEvent.keyDown(box(), { key: "Enter" });
|
||||
expect(spies.onPromptChange).toHaveBeenLastCalledWith("/review-pr ");
|
||||
// Enter picked the suggestion; it did not send the prompt.
|
||||
expect(spies.onStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suggests files under the cwd on @", async () => {
|
||||
vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] });
|
||||
// The prompt is controlled by the page: put the cursor at the end of it.
|
||||
const { spies } = renderSetup({ prompt: "look at @ma" });
|
||||
(box() as HTMLTextAreaElement).setSelectionRange(11, 11);
|
||||
fireEvent.select(box());
|
||||
|
||||
fireEvent.click(await screen.findByText("src/main.ts"));
|
||||
expect(api.run.files).toHaveBeenCalledWith("/Users/tester", "ma");
|
||||
expect(spies.onPromptChange).toHaveBeenLastCalledWith("look at @src/main.ts ");
|
||||
});
|
||||
|
||||
it("offers no slash commands on a codex lane", async () => {
|
||||
renderSetup({ prompt: "", tool: "codex" });
|
||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.queryByTestId("prompt-autocomplete")).toBeNull();
|
||||
expect(api.ccConfig.commands).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, act } from "@testing-library/react";
|
||||
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { TerminalView } from "../TerminalView";
|
||||
|
||||
const writeMock = vi.fn();
|
||||
@@ -18,6 +18,11 @@ const disposeMock = vi.fn();
|
||||
const wheelHandlerMock = vi.fn();
|
||||
const keyHandlerMock = vi.fn();
|
||||
const focusMock = vi.fn();
|
||||
const pasteMock = vi.fn();
|
||||
const dropImageMock = vi.fn();
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { dropImage: (f: Blob) => dropImageMock(f) } },
|
||||
}));
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
const termState = {
|
||||
@@ -61,6 +66,7 @@ vi.mock("@xterm/xterm", () => ({
|
||||
attachCustomWheelEventHandler: wheelHandlerMock,
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
focus: focusMock,
|
||||
paste: pasteMock,
|
||||
})),
|
||||
}));
|
||||
// jsdom loads no stylesheet, so stand in for the CSS-variable probe.
|
||||
@@ -314,4 +320,31 @@ describe("TerminalView", () => {
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("pastes the server-side path of a dropped image into the pane", async () => {
|
||||
dropImageMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab.png" });
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
|
||||
const target = screen.getByTestId("terminal-view");
|
||||
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop image to attach");
|
||||
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab.png "));
|
||||
expect(dropImageMock).toHaveBeenCalledWith(img);
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||
});
|
||||
|
||||
it("uploads nothing for a dropped non-image and says why", async () => {
|
||||
dropImageMock.mockReset();
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
|
||||
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||
dataTransfer: { types: ["Files"], files: [txt] },
|
||||
});
|
||||
expect(await screen.findByText("Only images can be dropped here")).toBeInTheDocument();
|
||||
expect(dropImageMock).not.toHaveBeenCalled();
|
||||
expect(pasteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,14 +25,15 @@
|
||||
"addLaneBranchBusy": "{{branch}} — already checked out at {{path}}",
|
||||
"addLaneBranchExistingHint": "The worktree checks this branch out as it is — no new branch, no base.",
|
||||
"addLaneBranchExistingLabel": "Branch to check out",
|
||||
"addLaneBranchHintAuto": "Leave empty to use the default name shown above.",
|
||||
"addLaneBranchHintAuto": "Leave empty to let the server name the branch from the title.",
|
||||
"addLaneBranchLabel": "New branch name",
|
||||
"addLaneBranchModeCurrent": "Current branch",
|
||||
"addLaneBranchModeCurrentHint": "Work straight on this repo's checked-out branch (main) — no worktree, no fork. Switches to Repo mode.",
|
||||
"addLaneBranchModeExisting": "Existing branch",
|
||||
"addLaneBranchModeNew": "New branch",
|
||||
"addLaneBranchPlaceholder": "feat/my-feature",
|
||||
"addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.",
|
||||
"addLaneBranchPlaceholder": "Automatic — named from the title",
|
||||
"addLaneNoBranches": "This repo has no commits yet, so a worktree has nothing to branch from. Add it as a Repo lane: commit first, then publish it from the Changes panel.",
|
||||
"addLaneNoBranchesUseRepo": "Use as a Repo lane",
|
||||
"addLaneNoFreeBranches": "Every branch in this repo is already checked out in a worktree. Create a new branch instead.",
|
||||
"addLaneExists": "This folder already has a lane: #{{id}} {{title}}. It is selected; pick another folder to add a new one.",
|
||||
"addLaneNotARepo": "Not a git repository (or no read access) yet.",
|
||||
@@ -108,8 +109,13 @@
|
||||
"changes.refresh": "Refresh",
|
||||
"changes.resize": "Resize the changes panel (next: {{size}})",
|
||||
"changes.size.compact": "Narrow",
|
||||
"changes.size.half": "50 / 50",
|
||||
"changes.size.wide": "70 / 30",
|
||||
"changes.size.half": "Medium",
|
||||
"changes.size.full": "Full screen",
|
||||
"changes.stageAll": "Stage all ({{count}})",
|
||||
"changes.unstageAll": "Unstage all ({{count}})",
|
||||
"changes.dropEdits": "Discard your unsaved edits to this file?",
|
||||
"changes.stagedGroup": "Staged Changes",
|
||||
"changes.unstagedGroup": "Changes",
|
||||
"changes.stage": "Stage this file",
|
||||
"changes.title": "Changes",
|
||||
"changes.truncated": "Some changes were left out to keep this panel responsive.",
|
||||
@@ -169,21 +175,21 @@
|
||||
"gitBar.branchDelete": "Delete",
|
||||
"gitBar.branchDeleteHint": "Delete the selected branch (refused if it has unmerged commits)",
|
||||
"gitBar.branchNewPlaceholder": "New branch name…",
|
||||
"gitBar.cancel": "Cancel",
|
||||
"gitBar.commit": "Commit",
|
||||
"gitBar.commitAll_one": "Commit {{count}} file",
|
||||
"gitBar.commitAll_other": "Commit {{count}} files",
|
||||
"gitBar.commitHint": "Stage everything and commit",
|
||||
"gitBar.commitNothing": "Nothing to commit",
|
||||
"gitBar.commitPlaceholder": "Commit message",
|
||||
"gitBar.commitStaged": "Commit staged",
|
||||
"gitBar.commitStagedHint": "Commit only what is staged in the Changes panel",
|
||||
"gitBar.fetch": "Fetch",
|
||||
"gitBar.fetchHint": "git fetch --all --prune",
|
||||
"gitBar.errNoRemote": "No remote to push to or pull from. Add one, or set this branch's upstream, in a terminal.",
|
||||
"gitBar.inSync": "in sync",
|
||||
"gitBar.newLane": "New lane here",
|
||||
"gitBar.newLaneHeld": "Already checked out at {{path}} — git allows one worktree per branch",
|
||||
"gitBar.newLaneHint": "Open Add lane for a sibling worktree on the selected branch",
|
||||
"gitBar.noCommits": "no commits yet",
|
||||
"gitBar.noUpstream": "not pushed",
|
||||
"gitBar.publish": "Publish…",
|
||||
"gitBar.publishApply": "Publish",
|
||||
"gitBar.publishBranch": "Publish branch",
|
||||
"gitBar.publishBranchHint": "git push -u origin HEAD — this branch has no upstream yet",
|
||||
"gitBar.publishHint": "Add a remote named origin and push this branch to it (git remote add origin + git push -u origin HEAD)",
|
||||
"gitBar.publishUrl": "Remote URL: https://host/owner/repo.git or git@host:owner/repo.git",
|
||||
"gitBar.pull": "Pull",
|
||||
"gitBar.pullHint": "git pull --ff-only — refuses instead of merging when the branch has diverged",
|
||||
"gitBar.push": "Push",
|
||||
@@ -194,6 +200,7 @@
|
||||
"gitBar.switchDirty": "Commit or discard the uncommitted changes first",
|
||||
"gitBar.switchHint": "Switch this worktree to another local branch",
|
||||
"gitBar.switchLabel": "Switch branch…",
|
||||
"gitBar.unbornHint": "Commit first, then publish this repo to a remote.",
|
||||
"kind.adopted": "adopted",
|
||||
"kind.managed": "managed",
|
||||
"laneDetail.hide": "Hide details",
|
||||
@@ -239,5 +246,46 @@
|
||||
"title": "Lanes",
|
||||
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
|
||||
"worktrees.heading_one": "{{count}} worktree",
|
||||
"worktrees.heading_other": "{{count}} worktrees"
|
||||
"worktrees.heading_other": "{{count}} worktrees",
|
||||
"changes.stageHunk": "Stage this hunk",
|
||||
"changes.undoHunk": "Undo this hunk",
|
||||
"changes.undoHunkConfirm": "Click again to undo this hunk — this cannot be undone",
|
||||
"changes.stageHunkShort": "Keep",
|
||||
"changes.undoHunkShort": "Undo",
|
||||
"changes.undoHunkShortConfirm": "Confirm undo",
|
||||
"changes.edit": "Edit",
|
||||
"changes.save": "Save",
|
||||
"changes.cancelEdit": "Cancel",
|
||||
"changes.editorLabel": "Edit {{path}}",
|
||||
"changes.prevHunk": "Previous change",
|
||||
"changes.nextHunk": "Next change",
|
||||
"changes.hunkOf": "{{n}} of {{total}}",
|
||||
"changes.noEditBinary": "Binary file — not editable here",
|
||||
"changes.noEditTooBig": "Too large to edit here (over 1 MB)",
|
||||
"changes.noEdit": "Not editable here",
|
||||
"gitBar.loginTitle": "Git needs a login for {{host}}",
|
||||
"gitBar.loginRejected": "The server rejected that username or token.",
|
||||
"gitBar.loginUser": "Username",
|
||||
"gitBar.loginPassword": "Password or access token",
|
||||
"gitBar.loginRemember": "Remember for everyone using this AgentHub",
|
||||
"gitBar.loginTokenHint": "On Gitea: Settings → Applications → Generate token, with repository read/write.",
|
||||
"gitBar.loginInsecure": "This dashboard is on plain http — the token crosses the network unencrypted.",
|
||||
"gitBar.loginSubmit": "Log in and retry",
|
||||
"gitBar.loginCancel": "Cancel",
|
||||
"gitBar.merge": "Merge into current",
|
||||
"gitBar.mergeHint": "Merge the selected branch into the branch this lane is on",
|
||||
"gitBar.mergeUpstream": "Merge {{upstream}}",
|
||||
"gitBar.mergeUpstreamHint": "The branch has diverged from its upstream: merge it in (conflicts are resolved in the Changes panel)",
|
||||
"gitBar.mergeConfirm": "Merge {{branch}} into {{current}}? Files in this lane change now — if an agent is working in this lane, let it finish first.",
|
||||
"gitBar.mergeConflicts": "Merge stopped on {{count}} conflicted file(s) — resolve them in the list below.",
|
||||
"gitBar.merging": "A merge is in progress: finish or abort it first",
|
||||
"changes.conflictsGroup": "Merge conflicts",
|
||||
"changes.mergeBanner": "Merge in progress — {{count}} file(s) still conflicted",
|
||||
"changes.mergeReady": "Merge in progress — every conflict is resolved",
|
||||
"changes.mergeCommit": "Complete merge",
|
||||
"changes.mergeAbort": "Abort merge",
|
||||
"changes.mergeAbortConfirm": "Click again to abort the merge",
|
||||
"changes.takeOurs": "Keep mine",
|
||||
"changes.takeTheirs": "Take theirs",
|
||||
"changes.markResolved": "Mark resolved (after editing out the markers)"
|
||||
}
|
||||
|
||||
@@ -164,5 +164,11 @@
|
||||
"input": "Input",
|
||||
"label": "Context",
|
||||
"output": "Output"
|
||||
},
|
||||
"drop": {
|
||||
"hint": "Drop image to attach",
|
||||
"uploading": "Uploading image…",
|
||||
"failed": "Could not attach the image",
|
||||
"imagesOnly": "Only images can be dropped here"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,7 +452,8 @@
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"aiRouter": "AI Router"
|
||||
"aiRouter": "AI Router",
|
||||
"gitCredentials": "Git credentials"
|
||||
},
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
@@ -509,5 +510,23 @@
|
||||
"lightThemes": "Light themes",
|
||||
"active": "· in use now",
|
||||
"default": "Default"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
"description": "Logins AgentHub gives git for a dashboard fetch, pull or push — one per host, shared by everyone using this AgentHub. A token is never shown again after it is saved.",
|
||||
"add": "Add login",
|
||||
"replace": "Replace token",
|
||||
"remove": "Delete",
|
||||
"removeConfirm": "Delete this git login? Dashboard fetch/pull/push to that host will ask for a login again.",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"fieldHost": "Server URL",
|
||||
"fieldUser": "Username",
|
||||
"fieldToken": "Password or access token",
|
||||
"colHost": "Host",
|
||||
"colUser": "Username",
|
||||
"colSaved": "Saved",
|
||||
"empty": "No git login saved. The git bar asks for one the first time a host needs it.",
|
||||
"badUrl": "Enter the server as a URL, e.g. https://git.example.com"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,14 +25,15 @@
|
||||
"addLaneBranchBusy": "{{branch}} — đang được checkout tại {{path}}",
|
||||
"addLaneBranchExistingHint": "Worktree checkout đúng nhánh này — không tạo nhánh mới, không cần nhánh gốc.",
|
||||
"addLaneBranchExistingLabel": "Nhánh cần checkout",
|
||||
"addLaneBranchHintAuto": "Để trống để dùng tên mặc định hiển thị ở trên.",
|
||||
"addLaneBranchHintAuto": "Để trống để server tự đặt tên nhánh theo tiêu đề.",
|
||||
"addLaneBranchLabel": "Tên nhánh mới",
|
||||
"addLaneBranchModeCurrent": "Nhánh hiện tại",
|
||||
"addLaneBranchModeCurrentHint": "Làm thẳng trên nhánh repo đang mở (main) — không tạo worktree, không fork nhánh. Chuyển sang chế độ Repo.",
|
||||
"addLaneBranchModeExisting": "Nhánh có sẵn",
|
||||
"addLaneBranchModeNew": "Nhánh mới",
|
||||
"addLaneBranchPlaceholder": "feat/tinh-nang-cua-toi",
|
||||
"addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.",
|
||||
"addLaneBranchPlaceholder": "Tự động — đặt theo tiêu đề",
|
||||
"addLaneNoBranches": "Repo này chưa có commit nào nên worktree không có gì để rẽ nhánh. Hãy thêm nó thành lane Repo: commit trước, rồi publish từ bảng Changes.",
|
||||
"addLaneNoBranchesUseRepo": "Dùng làm lane Repo",
|
||||
"addLaneNoFreeBranches": "Mọi nhánh của repo này đều đang nằm trong một worktree. Hãy tạo nhánh mới.",
|
||||
"addLaneExists": "Thư mục này đã có lane: #{{id}} {{title}}. Lane đó đã được chọn; hãy chọn thư mục khác để thêm lane mới.",
|
||||
"addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).",
|
||||
@@ -108,8 +109,13 @@
|
||||
"changes.refresh": "Tải lại",
|
||||
"changes.resize": "Đổi độ rộng khung changes (kế tiếp: {{size}})",
|
||||
"changes.size.compact": "Hẹp",
|
||||
"changes.size.half": "50 / 50",
|
||||
"changes.size.wide": "70 / 30",
|
||||
"changes.size.half": "Vừa",
|
||||
"changes.size.full": "Toàn màn hình",
|
||||
"changes.stageAll": "Stage tất cả ({{count}})",
|
||||
"changes.unstageAll": "Bỏ stage tất cả ({{count}})",
|
||||
"changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?",
|
||||
"changes.stagedGroup": "Đã stage",
|
||||
"changes.unstagedGroup": "Thay đổi",
|
||||
"changes.stage": "Đưa file này vào index",
|
||||
"changes.title": "Thay đổi",
|
||||
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
|
||||
@@ -169,21 +175,21 @@
|
||||
"gitBar.branchDelete": "Xoá",
|
||||
"gitBar.branchDeleteHint": "Xoá nhánh đang chọn (git từ chối nếu còn commit chưa merge)",
|
||||
"gitBar.branchNewPlaceholder": "Tên nhánh mới…",
|
||||
"gitBar.cancel": "Huỷ",
|
||||
"gitBar.commit": "Commit",
|
||||
"gitBar.commitAll_one": "Commit {{count}} file",
|
||||
"gitBar.commitAll_other": "Commit {{count}} file",
|
||||
"gitBar.commitHint": "Stage tất cả rồi commit",
|
||||
"gitBar.commitNothing": "Không có gì để commit",
|
||||
"gitBar.commitPlaceholder": "Nội dung commit",
|
||||
"gitBar.commitStaged": "Commit phần đã stage",
|
||||
"gitBar.commitStagedHint": "Chỉ commit những file đã stage ở thẻ Changes",
|
||||
"gitBar.fetch": "Fetch",
|
||||
"gitBar.fetchHint": "git fetch --all --prune",
|
||||
"gitBar.errNoRemote": "Không có remote để push hoặc pull. Hãy thêm remote, hoặc đặt upstream cho nhánh này, trong terminal.",
|
||||
"gitBar.inSync": "đã đồng bộ",
|
||||
"gitBar.newLane": "Tạo lane mới",
|
||||
"gitBar.newLaneHeld": "Nhánh đã được checkout tại {{path}} — git chỉ cho một worktree trên mỗi nhánh",
|
||||
"gitBar.newLaneHint": "Mở Add lane để tạo worktree anh em trên nhánh đang chọn",
|
||||
"gitBar.noCommits": "chưa có commit",
|
||||
"gitBar.noUpstream": "chưa push",
|
||||
"gitBar.publish": "Publish…",
|
||||
"gitBar.publishApply": "Publish",
|
||||
"gitBar.publishBranch": "Publish nhánh",
|
||||
"gitBar.publishBranchHint": "git push -u origin HEAD — nhánh này chưa có upstream",
|
||||
"gitBar.publishHint": "Thêm remote tên origin rồi push nhánh này lên đó (git remote add origin + git push -u origin HEAD)",
|
||||
"gitBar.publishUrl": "URL remote: https://host/owner/repo.git hoặc git@host:owner/repo.git",
|
||||
"gitBar.pull": "Pull",
|
||||
"gitBar.pullHint": "git pull --ff-only — nhánh đã rẽ nhánh thì báo lỗi chứ không merge",
|
||||
"gitBar.push": "Push",
|
||||
@@ -194,6 +200,7 @@
|
||||
"gitBar.switchDirty": "Hãy commit hoặc bỏ các thay đổi chưa commit trước",
|
||||
"gitBar.switchHint": "Đổi worktree này sang nhánh local khác",
|
||||
"gitBar.switchLabel": "Đổi nhánh…",
|
||||
"gitBar.unbornHint": "Hãy commit trước, rồi publish repo này lên remote.",
|
||||
"kind.adopted": "đã nhận",
|
||||
"kind.managed": "được quản lý",
|
||||
"laneDetail.hide": "Ẩn chi tiết",
|
||||
@@ -239,5 +246,46 @@
|
||||
"title": "Làn đường",
|
||||
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn",
|
||||
"worktrees.heading_one": "{{count}} worktree",
|
||||
"worktrees.heading_other": "{{count}} worktree"
|
||||
"worktrees.heading_other": "{{count}} worktree",
|
||||
"changes.stageHunk": "Stage đoạn này",
|
||||
"changes.undoHunk": "Hoàn tác đoạn này",
|
||||
"changes.undoHunkConfirm": "Bấm lần nữa để hoàn tác đoạn này — không thể khôi phục",
|
||||
"changes.stageHunkShort": "Giữ",
|
||||
"changes.undoHunkShort": "Hoàn tác",
|
||||
"changes.undoHunkShortConfirm": "Xác nhận",
|
||||
"changes.edit": "Sửa",
|
||||
"changes.save": "Lưu",
|
||||
"changes.cancelEdit": "Huỷ",
|
||||
"changes.editorLabel": "Sửa {{path}}",
|
||||
"changes.prevHunk": "Thay đổi trước",
|
||||
"changes.nextHunk": "Thay đổi sau",
|
||||
"changes.hunkOf": "{{n}} / {{total}}",
|
||||
"changes.noEditBinary": "File nhị phân — không sửa được ở đây",
|
||||
"changes.noEditTooBig": "File quá lớn để sửa ở đây (trên 1 MB)",
|
||||
"changes.noEdit": "Không sửa được ở đây",
|
||||
"gitBar.loginTitle": "Git cần đăng nhập {{host}}",
|
||||
"gitBar.loginRejected": "Máy chủ từ chối (rejected) tài khoản hoặc token này.",
|
||||
"gitBar.loginUser": "Tên đăng nhập",
|
||||
"gitBar.loginPassword": "Mật khẩu hoặc access token",
|
||||
"gitBar.loginRemember": "Ghi nhớ cho mọi người dùng AgentHub",
|
||||
"gitBar.loginTokenHint": "Trên Gitea: Settings → Applications → Generate token, cấp quyền đọc/ghi repository.",
|
||||
"gitBar.loginInsecure": "Dashboard đang chạy http thường - token sẽ đi qua mạng không mã hoá.",
|
||||
"gitBar.loginSubmit": "Đăng nhập và thử lại",
|
||||
"gitBar.loginCancel": "Huỷ",
|
||||
"gitBar.merge": "Merge vào nhánh hiện tại",
|
||||
"gitBar.mergeHint": "Merge nhánh đang chọn vào nhánh lane này đang đứng",
|
||||
"gitBar.mergeUpstream": "Merge {{upstream}}",
|
||||
"gitBar.mergeUpstreamHint": "Nhánh đã lệch với upstream: merge vào (conflict được giải quyết trong Changes panel)",
|
||||
"gitBar.mergeConfirm": "Merge {{branch}} vào {{current}}? File trong lane sẽ thay đổi ngay - nếu có agent đang làm việc trong lane này, hãy để nó xong trước.",
|
||||
"gitBar.mergeConflicts": "Merge dừng ở {{count}} file conflict - hãy giải quyết trong danh sách bên dưới.",
|
||||
"gitBar.merging": "Đang merge: hãy hoàn tất hoặc huỷ merge trước",
|
||||
"changes.conflictsGroup": "Conflict khi merge",
|
||||
"changes.mergeBanner": "Đang merge - còn {{count}} file conflict",
|
||||
"changes.mergeReady": "Đang merge - đã giải quyết hết conflict",
|
||||
"changes.mergeCommit": "Hoàn tất merge",
|
||||
"changes.mergeAbort": "Huỷ merge",
|
||||
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ merge",
|
||||
"changes.takeOurs": "Giữ bản của mình",
|
||||
"changes.takeTheirs": "Lấy bản kia",
|
||||
"changes.markResolved": "Đánh dấu đã xong (sau khi đã sửa hết dấu conflict)"
|
||||
}
|
||||
|
||||
@@ -163,5 +163,11 @@
|
||||
"input": "Input",
|
||||
"label": "Context",
|
||||
"output": "Output"
|
||||
},
|
||||
"drop": {
|
||||
"hint": "Thả ảnh để đính kèm",
|
||||
"uploading": "Đang tải ảnh lên…",
|
||||
"failed": "Không đính kèm được ảnh",
|
||||
"imagesOnly": "Chỉ thả được ảnh vào đây"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,7 +452,8 @@
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"aiRouter": "AI Router"
|
||||
"aiRouter": "AI Router",
|
||||
"gitCredentials": "Git credentials"
|
||||
},
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
@@ -509,5 +510,23 @@
|
||||
"lightThemes": "Theme sáng",
|
||||
"active": "· đang dùng",
|
||||
"default": "Mặc định"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
"description": "Tài khoản AgentHub dùng cho git khi fetch, pull, push từ dashboard - mỗi host một tài khoản, dùng chung cho mọi người dùng AgentHub này. Token đã lưu sẽ không hiển thị lại.",
|
||||
"add": "Thêm tài khoản",
|
||||
"replace": "Thay token",
|
||||
"remove": "Xoá",
|
||||
"removeConfirm": "Xoá tài khoản git này? Fetch/pull/push tới host đó từ dashboard sẽ hỏi đăng nhập lại.",
|
||||
"save": "Lưu",
|
||||
"cancel": "Huỷ",
|
||||
"fieldHost": "URL máy chủ",
|
||||
"fieldUser": "Tên đăng nhập",
|
||||
"fieldToken": "Mật khẩu hoặc access token",
|
||||
"colHost": "Host",
|
||||
"colUser": "Tên đăng nhập",
|
||||
"colSaved": "Đã lưu",
|
||||
"empty": "Chưa lưu tài khoản git nào. Thanh git sẽ hỏi khi một host cần đăng nhập.",
|
||||
"badUrl": "Nhập máy chủ dạng URL, ví dụ https://git.example.com"
|
||||
}
|
||||
}
|
||||
|
||||
+115
-10
@@ -482,6 +482,15 @@ export function dashboardToken(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** `?scope=&cwd=` for the cc-config list endpoints, each part only when given. */
|
||||
function ccQuery(scope?: CcScope, cwd?: string): string {
|
||||
const qs = new URLSearchParams();
|
||||
if (scope) qs.set("scope", scope);
|
||||
if (cwd) qs.set("cwd", cwd);
|
||||
const q = qs.toString();
|
||||
return q ? `?${q}` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared fetch wrapper used by every method on {@link api}. Prefixes `path`
|
||||
* with {@link BASE} ("/api"), attaches the dashboard auth token (if any) as
|
||||
@@ -530,6 +539,8 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), {
|
||||
code: body?.error?.code,
|
||||
status: res.status,
|
||||
// EGITAUTH from a lane git action: which host wants a login.
|
||||
auth: body?.error?.auth as GitAuthNeeded | undefined,
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
@@ -1390,10 +1401,11 @@ export const api = {
|
||||
* list endpoints below (`agents`, `commands`, `outputStyles`).
|
||||
*
|
||||
* @param scope Optional {@link CcScope} ("user"|"project"|"all") filter.
|
||||
* @param cwd Project directory to read, instead of the server's own cwd.
|
||||
* @returns `{ items }` — the {@link CcMdItem} summaries for each skill.
|
||||
*/
|
||||
skills: (scope?: CcScope) =>
|
||||
request<{ items: CcMdItem[] }>(`/cc-config/skills${scope ? `?scope=${scope}` : ""}`),
|
||||
skills: (scope?: CcScope, cwd?: string) =>
|
||||
request<{ items: CcMdItem[] }>(`/cc-config/skills${ccQuery(scope, cwd)}`),
|
||||
/**
|
||||
* GET /api/cc-config/agents - user and/or project subagent definitions.
|
||||
* @param scope Optional {@link CcScope} filter.
|
||||
@@ -1404,10 +1416,11 @@ export const api = {
|
||||
/**
|
||||
* GET /api/cc-config/commands - user and/or project slash commands.
|
||||
* @param scope Optional {@link CcScope} filter.
|
||||
* @param cwd Project directory to read, instead of the server's own cwd.
|
||||
* @returns `{ items }` — {@link CcMdItem} summaries for each command.
|
||||
*/
|
||||
commands: (scope?: CcScope) =>
|
||||
request<{ items: CcMdItem[] }>(`/cc-config/commands${scope ? `?scope=${scope}` : ""}`),
|
||||
commands: (scope?: CcScope, cwd?: string) =>
|
||||
request<{ items: CcMdItem[] }>(`/cc-config/commands${ccQuery(scope, cwd)}`),
|
||||
/**
|
||||
* GET /api/cc-config/output-styles.
|
||||
* @param scope Optional {@link CcScope} filter.
|
||||
@@ -1910,6 +1923,24 @@ export const api = {
|
||||
*
|
||||
* No response here ever carries an API key — a saved profile reports only
|
||||
* `hasKey`. */
|
||||
/** AgentHub's own git credentials (issue #45), shared by every user of this
|
||||
* install. No response ever carries a password — only `hasToken`. */
|
||||
gitCredentials: {
|
||||
/** GET /api/git-credentials */
|
||||
list: () => request<{ items: GitCredential[] }>("/git-credentials"),
|
||||
/** POST /api/git-credentials - save, or replace the one for that host. */
|
||||
save: (body: GitCredentialInput) =>
|
||||
request<{ items: GitCredential[] }>("/git-credentials", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/** DELETE /api/git-credentials/:protocol/:host */
|
||||
remove: (protocol: string, host: string) =>
|
||||
request<{ items: GitCredential[] }>(
|
||||
`/git-credentials/${encodeURIComponent(protocol)}/${encodeURIComponent(host)}`,
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
},
|
||||
aiRouters: {
|
||||
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
|
||||
list: () => request<{ items: AiRouter[] }>("/ai-routers"),
|
||||
@@ -1971,6 +2002,14 @@ export const api = {
|
||||
if (q) qs.set("q", q);
|
||||
return request<{ items: string[] }>(`/run/files?${qs.toString()}`);
|
||||
},
|
||||
/** POST /api/run/drop-image - save an image dropped on the terminal on the
|
||||
* server's machine; the returned path is what gets pasted into the pane. */
|
||||
dropImage: (file: Blob) =>
|
||||
request<{ path: string }>("/run/drop-image", {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: { "Content-Type": file.type },
|
||||
}),
|
||||
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
|
||||
start: (args: RunStartArgs) =>
|
||||
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
||||
@@ -2436,6 +2475,16 @@ export const api = {
|
||||
request<LaneFileContent>(
|
||||
`/lanes/${id}/git/file?path=${encodeURIComponent(path)}&rev=${encodeURIComponent(rev)}`
|
||||
),
|
||||
/**
|
||||
* PUT /api/lanes/:id/git/file — save the editor's text over the working
|
||||
* file. `baseSha` is the `sha` it was opened at; a file that changed on
|
||||
* disk since answers 409 ESTALE rather than being overwritten.
|
||||
*/
|
||||
gitSaveFile: (id: number, path: string, content: string, baseSha: string) =>
|
||||
request<LaneFileContent>(`/lanes/${id}/git/file`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ path, content, baseSha }),
|
||||
}),
|
||||
/**
|
||||
* GET /api/lanes/:id/git/ls — tracked paths matching a substring, for
|
||||
* quick-open. Ignored files never appear (it reads `git ls-files`).
|
||||
@@ -2451,6 +2500,16 @@ export const api = {
|
||||
* @param id The lane id.
|
||||
* @param path Repo-relative directory; "" for the repo root.
|
||||
*/
|
||||
/**
|
||||
* GET /api/lanes/:id/git/tree-all — every level of the tracked tree in one
|
||||
* read, keyed by directory ("" is the root), each shaped like `gitTree`'s.
|
||||
*/
|
||||
gitTreeAll: (id: number) =>
|
||||
request<{
|
||||
available: boolean;
|
||||
levels: Record<string, LaneTreeEntry[]>;
|
||||
truncated: boolean;
|
||||
}>(`/lanes/${id}/git/tree-all`),
|
||||
gitTree: (id: number, path = "") =>
|
||||
request<{
|
||||
available: boolean;
|
||||
@@ -2474,12 +2533,13 @@ export const api = {
|
||||
* POST /api/lanes/:id/git/:action — one git write on the lane's worktree.
|
||||
* @param id The lane id.
|
||||
* @param action `fetch`, `pull` (always --ff-only), `push`, `stage`,
|
||||
* `unstage`, `discard`, `commit`, `switch`, `branch-create` or
|
||||
* `branch-delete`.
|
||||
* `unstage`, `discard`, `commit`, `switch`, `branch-create`,
|
||||
* `branch-delete` or `publish`.
|
||||
* @param body `{message, only}` for commit; `{branch, create}` for switch;
|
||||
* `{branch, base}` for branch-create; `{paths}` for stage/unstage;
|
||||
* `{paths, confirm: true}` for discard — which is irreversible, so the
|
||||
* server refuses it without that flag.
|
||||
* server refuses it without that flag; `{url}` for publish (a repo with
|
||||
* no remote yet: adds `origin` and pushes HEAD with an upstream).
|
||||
* @returns `{action, ok, output}` — `output` is git's own text, shown as-is.
|
||||
*/
|
||||
gitAction: (
|
||||
@@ -2493,19 +2553,35 @@ export const api = {
|
||||
| "stage"
|
||||
| "unstage"
|
||||
| "discard"
|
||||
| "stage-hunk"
|
||||
| "discard-hunk"
|
||||
| "branch-create"
|
||||
| "branch-delete",
|
||||
| "branch-delete"
|
||||
| "publish"
|
||||
| "merge"
|
||||
| "resolve"
|
||||
| "merge-commit"
|
||||
| "merge-abort",
|
||||
body: {
|
||||
url?: string;
|
||||
message?: string;
|
||||
only?: "staged";
|
||||
branch?: string;
|
||||
base?: string;
|
||||
create?: boolean;
|
||||
paths?: string[];
|
||||
path?: string;
|
||||
hunk?: string;
|
||||
context?: number;
|
||||
confirm?: boolean;
|
||||
/** resolve: which side wins, or `done` for a hand-edited file. */
|
||||
side?: "ours" | "theirs" | "done";
|
||||
/** fetch/pull/push after `EGITAUTH`: used for this one command, saved
|
||||
* only when git accepted it and `remember` is set. */
|
||||
credentials?: GitCredentialInput & { remember: boolean };
|
||||
} = {}
|
||||
) =>
|
||||
request<{ action: string; ok: true; output: string; branch?: string }>(
|
||||
request<{ action: string; ok: true; output: string; branch?: string; conflicts?: string[] }>(
|
||||
`/lanes/${id}/git/${action}`,
|
||||
{ method: "POST", body: JSON.stringify(body) }
|
||||
),
|
||||
@@ -2585,7 +2661,9 @@ export const api = {
|
||||
* @returns `{ lane, ok }` — the updated lane (if applicable) or a boolean result.
|
||||
*/
|
||||
action: (id: number, action: string, body: Record<string, unknown> = {}) =>
|
||||
request<{ lane?: Lane; ok?: boolean }>(`/lanes/${id}/${action}`, {
|
||||
// `run_id` answers a `start`: the session it opened, which for a split
|
||||
// pane's slot (`body.slot` 1–3) is not the lane's own `run_id`.
|
||||
request<{ lane?: Lane; ok?: boolean; run_id?: string }>(`/lanes/${id}/${action}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
@@ -2865,6 +2943,31 @@ export interface AiRouterModels {
|
||||
}
|
||||
|
||||
/** One saved AI Router profile, as the API returns it — **never** with a key. */
|
||||
/** A saved git login, as the server reports it: never the password. */
|
||||
export interface GitCredential {
|
||||
protocol: "http" | "https";
|
||||
host: string;
|
||||
username: string;
|
||||
savedAt: string;
|
||||
hasToken: true;
|
||||
}
|
||||
|
||||
export interface GitCredentialInput {
|
||||
protocol: string;
|
||||
host: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** The `auth` block of a 409 `EGITAUTH`: the host git needed a login for. */
|
||||
export interface GitAuthNeeded {
|
||||
protocol: "http" | "https";
|
||||
host: string;
|
||||
username: string | null;
|
||||
/** true when a login WAS sent and the host refused it. */
|
||||
rejected: boolean;
|
||||
}
|
||||
|
||||
export interface AiRouter {
|
||||
/** Identifier, derived from the label. Stable: a lane stores this. */
|
||||
name: string;
|
||||
@@ -3578,6 +3681,8 @@ export interface RunStartArgs {
|
||||
export interface RunHandle {
|
||||
id: string;
|
||||
laneId: number | null;
|
||||
/** Which Workspace split pane's session this is: 0 is the lane's own. */
|
||||
slot?: number | null;
|
||||
status: RunStatus;
|
||||
cwd: string | null;
|
||||
model: string | null;
|
||||
|
||||
+17
-3
@@ -2437,6 +2437,7 @@ export interface Lane {
|
||||
gate_decision: string | null;
|
||||
ci_status: string | null;
|
||||
needs_action: string | null;
|
||||
needs_action_session?: string | null;
|
||||
links: Record<string, string>;
|
||||
stages: Record<string, { enteredAt: string; evidence: string | null; result: string | null }>;
|
||||
notes: string | null;
|
||||
@@ -2584,9 +2585,11 @@ export type LaneGitFacts =
|
||||
available: true;
|
||||
/** Branch name, or the literal `HEAD` for a detached checkout. */
|
||||
branch: string;
|
||||
/** Short commit hash of HEAD. */
|
||||
head: string;
|
||||
/** Subject line of the HEAD commit. */
|
||||
/** Short commit hash of HEAD; null in a repo with no commit yet. */
|
||||
head: string | null;
|
||||
/** True when the repo has no commit yet (a fresh `git init`). */
|
||||
unborn?: boolean;
|
||||
/** Subject line of the HEAD commit; "" when unborn. */
|
||||
subject: string;
|
||||
/** Tracked files with uncommitted changes. */
|
||||
dirty: number;
|
||||
@@ -2603,6 +2606,10 @@ export type LaneGitFacts =
|
||||
ahead: number | null;
|
||||
/** Commits the upstream has that HEAD does not; null without an upstream. */
|
||||
behind: number | null;
|
||||
/** A merge is in progress (issue #45). Absent on an older server. */
|
||||
merging?: boolean;
|
||||
/** Files the merge still holds unmerged, repo-wide. */
|
||||
conflicts?: number;
|
||||
}
|
||||
| { available: false };
|
||||
|
||||
@@ -2651,6 +2658,9 @@ export type LaneFileContent =
|
||||
binary: boolean;
|
||||
truncated: boolean;
|
||||
lines: string[];
|
||||
/** Version stamp of a whole, textual `work` file — what a save sends
|
||||
* back as `baseSha`. Absent at a commit, or when binary/truncated. */
|
||||
sha?: string;
|
||||
}
|
||||
| { available: false };
|
||||
|
||||
@@ -2660,6 +2670,10 @@ export interface LaneTreeEntry {
|
||||
/** Path relative to the repo root. */
|
||||
path: string;
|
||||
dir: boolean;
|
||||
/** A changed file's porcelain code (` M`, `??`, `A `, ` D`…); absent when clean. */
|
||||
status?: string;
|
||||
/** A folder with a changed or untracked file anywhere under it. */
|
||||
changed?: true;
|
||||
}
|
||||
|
||||
/** One row of a rendered diff — both gutters already resolved server-side. */
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* @file GitCredentialsConfig.tsx
|
||||
* @description The **Git credentials** tab of Settings (issue #45) — the
|
||||
* logins AgentHub itself gives git for a dashboard fetch/pull/push, one per
|
||||
* host, shared by everyone using this install. Lists them, replaces a token,
|
||||
* adds one, deletes one. A token is write-only here: the server never returns
|
||||
* one, so the form starts blank and the list shows only that one is saved.
|
||||
* The git bar's login form saves into the same store.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { KeyRound, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { api, type GitCredential } from "../lib/api";
|
||||
|
||||
const INPUT =
|
||||
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
|
||||
|
||||
export function GitCredentialsConfig() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [items, setItems] = useState<GitCredential[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<{ url: string; username: string; token: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setItems((await api.gitCredentials.list()).items);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const save = async () => {
|
||||
if (!form) return;
|
||||
setError(null);
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(form.url.trim());
|
||||
} catch {
|
||||
setError(t("gitCreds.badUrl"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await api.gitCredentials.save({
|
||||
protocol: url.protocol.replace(/:$/, ""),
|
||||
host: url.host,
|
||||
username: form.username.trim(),
|
||||
password: form.token,
|
||||
});
|
||||
setItems(r.items);
|
||||
setForm(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (c: GitCredential) => {
|
||||
if (!window.confirm(t("gitCreds.removeConfirm"))) return;
|
||||
try {
|
||||
setItems((await api.gitCredentials.remove(c.protocol, c.host)).items);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<KeyRound className="h-4 w-4 text-fg-muted" />
|
||||
{t("gitCreds.title")}
|
||||
</h2>
|
||||
<p className="mt-1 max-w-2xl text-xs text-fg-muted">{t("gitCreds.description")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm({ url: "", username: "", token: "" })}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("gitCreds.add")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{form && (
|
||||
<form
|
||||
className="grid max-w-2xl gap-2 rounded-lg border border-border p-3 sm:grid-cols-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitCreds.fieldHost")}
|
||||
placeholder="https://git.example.com"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<input
|
||||
aria-label={t("gitCreds.fieldUser")}
|
||||
placeholder={t("gitCreds.fieldUser")}
|
||||
autoComplete="off"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("gitCreds.fieldToken")}
|
||||
placeholder={t("gitCreds.fieldToken")}
|
||||
autoComplete="new-password"
|
||||
value={form.token}
|
||||
onChange={(e) => setForm({ ...form, token: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
<div className="flex gap-2 sm:col-span-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!form.url.trim() || !form.username.trim() || !form.token}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitCreds.save")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm(null)}
|
||||
className="rounded border border-border-light px-3 py-1 text-xs text-fg-secondary hover:bg-surface-3"
|
||||
>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-surface-2 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">{t("gitCreds.colHost")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("gitCreds.colUser")}</th>
|
||||
<th className="px-3 py-2 font-medium">{t("gitCreds.colSaved")}</th>
|
||||
<th className="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((c) => (
|
||||
<tr key={`${c.protocol}://${c.host}`} className="border-t border-border">
|
||||
<td className="px-3 py-2 font-mono text-[11px] text-fg-primary">{c.host}</td>
|
||||
<td className="px-3 py-2 text-fg-secondary">{c.username}</td>
|
||||
<td className="px-3 py-2 text-fg-secondary">
|
||||
{new Date(c.savedAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span className="inline-flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("gitCreds.replace")} ${c.host}`}
|
||||
title={t("gitCreds.replace")}
|
||||
onClick={() =>
|
||||
setForm({
|
||||
url: `${c.protocol}://${c.host}`,
|
||||
username: c.username,
|
||||
token: "",
|
||||
})
|
||||
}
|
||||
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("gitCreds.remove")} ${c.host}`}
|
||||
title={t("gitCreds.remove")}
|
||||
onClick={() => void remove(c)}
|
||||
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<tr className="border-t border-border">
|
||||
<td colSpan={4} className="px-3 py-4 text-center text-fg-muted">
|
||||
{t("gitCreds.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
/**
|
||||
* @file SettingsHub.tsx
|
||||
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex) plus the AI Router profiles they can be pointed at, with the active tab kept in the `?tool=` query param.
|
||||
* @description Tabbed shell for /settings that hosts the dashboard's own settings plus one configuration panel per AI tool (Claude Code and Codex) plus the AI Router profiles they can be pointed at and the git logins dashboard fetch/pull/push use, with the active tab kept in the `?tool=` query param.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Boxes, Router, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
|
||||
import { Boxes, KeyRound, Router, Settings as SettingsIcon, SquareTerminal } from "lucide-react";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
import { Settings } from "./Settings";
|
||||
import { CcConfig } from "./CcConfig";
|
||||
import { CodexConfig } from "./CodexConfig";
|
||||
import { AiRouterConfig } from "./AiRouterConfig";
|
||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||
|
||||
interface ToolTab {
|
||||
id: string;
|
||||
@@ -33,6 +34,12 @@ const TOOL_TABS: ToolTab[] = [
|
||||
{ id: "claude-code", labelKey: "tools.claudeCode", Icon: Boxes, Panel: CcConfig },
|
||||
{ id: "codex", labelKey: "tools.codex", Icon: SquareTerminal, Panel: CodexConfig },
|
||||
{ id: "ai-router", labelKey: "tools.aiRouter", Icon: Router, Panel: AiRouterConfig },
|
||||
{
|
||||
id: "git-credentials",
|
||||
labelKey: "tools.gitCredentials",
|
||||
Icon: KeyRound,
|
||||
Panel: GitCredentialsConfig,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -51,8 +51,8 @@ import {
|
||||
Pencil,
|
||||
Layers,
|
||||
PanelLeftClose,
|
||||
ChevronsLeftRight,
|
||||
PanelRightClose,
|
||||
Maximize2,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { CwdSuggestion, RunListResponse } from "../lib/api";
|
||||
@@ -88,21 +88,23 @@ const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
||||
/** The three widths the Changes panel cycles through, then closed. Reading a
|
||||
* diff and watching a console want opposite amounts of room, and which one is
|
||||
* "work" changes several times an hour — hence ONE control that steps
|
||||
* narrow → 50/50 → 70/30 → closed, rather than a width button beside a
|
||||
* 30/70 → 50/50 → full → closed, rather than a width button beside a
|
||||
* collapse button that both live in the panel's tightest corner. */
|
||||
const CHANGES_SIZES = ["compact", "half", "wide"] as const;
|
||||
const CHANGES_SIZES = ["compact", "half", "full"] as const;
|
||||
type ChangesSize = (typeof CHANGES_SIZES)[number];
|
||||
const CHANGES_WIDTH: Record<ChangesSize, string> = {
|
||||
compact: "w-[28rem] xl:w-[34rem]",
|
||||
compact: "w-[30%]",
|
||||
half: "w-1/2",
|
||||
wide: "w-[70%]",
|
||||
// The console beside it is HIDDEN, not unmounted: a live run's terminal
|
||||
// and a half-typed prompt must survive a round trip through full width.
|
||||
full: "w-full",
|
||||
};
|
||||
/** The icon the one resize control shows AT each size: what the next click
|
||||
* gives you — an even split, a wider panel, then closed. */
|
||||
* gives you — an even split, the full width, then closed. */
|
||||
const CHANGES_NEXT_ICON: Record<ChangesSize, typeof Columns2> = {
|
||||
compact: Columns2,
|
||||
half: ChevronsLeftRight,
|
||||
wide: PanelRightClose,
|
||||
half: Maximize2,
|
||||
full: PanelRightClose,
|
||||
};
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────
|
||||
@@ -158,7 +160,6 @@ function ConsoleArea({
|
||||
selectedLaneId,
|
||||
splitView,
|
||||
setLayout,
|
||||
setPaneLaneId,
|
||||
binaryStatus,
|
||||
cwdSuggestions,
|
||||
activeRuns,
|
||||
@@ -174,7 +175,6 @@ function ConsoleArea({
|
||||
selectedLaneId: number | null;
|
||||
splitView: SplitViewState;
|
||||
setLayout: (layout: SplitLayout) => void;
|
||||
setPaneLaneId: (index: number, id: number) => void;
|
||||
binaryStatus: Record<LaneTool, { found: boolean; path: string | null }> | null;
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
activeRuns: RunListResponse | null;
|
||||
@@ -186,6 +186,30 @@ function ConsoleArea({
|
||||
onLaneIdChange: (id: number | null) => void;
|
||||
showToggle?: boolean;
|
||||
}) {
|
||||
// Every split pane shows the SELECTED lane, each in its own tmux session
|
||||
// (slot = pane index). Tracking what each pane shows lets the others leave
|
||||
// that run out of their Active runs, so no terminal is attached twice.
|
||||
const [paneRunIds, setPaneRunIds] = useState<(string | null)[]>([]);
|
||||
const onPaneHandle = useCallback((i: number, id: string | null) => {
|
||||
setPaneRunIds((prev) => {
|
||||
if (prev[i] === id) return prev;
|
||||
const next = [...prev];
|
||||
next[i] = id;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const paneHandlers = useMemo(
|
||||
() => [0, 1, 2, 3].map((i) => (id: string | null) => onPaneHandle(i, id)),
|
||||
[onPaneHandle]
|
||||
);
|
||||
const hiddenFor = useMemo(
|
||||
() =>
|
||||
[0, 1, 2, 3].map((i) =>
|
||||
paneRunIds.filter((id, j): id is string => j !== i && j < splitView.layout && !!id)
|
||||
),
|
||||
[paneRunIds, splitView.layout]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showToggle && <SplitLayoutToggle splitView={splitView} setLayout={setLayout} />}
|
||||
@@ -193,6 +217,7 @@ function ConsoleArea({
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
laneId={selectedLaneId}
|
||||
onHandleChange={paneHandlers[0]}
|
||||
showLaneSelector={false}
|
||||
onLaneIdChange={onLaneIdChange}
|
||||
onLaneCreated={onLaneCreated}
|
||||
@@ -210,13 +235,16 @@ function ConsoleArea({
|
||||
splitView.layout === 2 ? "grid-cols-2" : "grid-cols-2 grid-rows-2"
|
||||
}`}
|
||||
>
|
||||
{splitView.paneLaneIds.map((id, i) => (
|
||||
{Array.from({ length: splitView.layout }, (_, i) => (
|
||||
<LaneConsolePane
|
||||
key={i}
|
||||
lanes={lanes}
|
||||
laneId={id}
|
||||
showLaneSelector
|
||||
onLaneIdChange={(newId) => setPaneLaneId(i, newId)}
|
||||
laneId={selectedLaneId}
|
||||
slot={i}
|
||||
hiddenRunIds={hiddenFor[i]}
|
||||
onHandleChange={paneHandlers[i]}
|
||||
showLaneSelector={false}
|
||||
onLaneIdChange={onLaneIdChange}
|
||||
onLaneCreated={onLaneCreated}
|
||||
binaryStatus={binaryStatus}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
@@ -330,16 +358,6 @@ export function Workspace() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setPaneLaneId = useCallback((index: number, id: number) => {
|
||||
setSplitView((prev) => {
|
||||
const paneLaneIds = [...prev.paneLaneIds];
|
||||
paneLaneIds[index] = id;
|
||||
const next = { ...prev, paneLaneIds };
|
||||
writeSplitViewState(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Pre-flight: probe binary + active runs + cwd suggestions + lanes on mount
|
||||
const refreshLanes = useCallback(async () => {
|
||||
try {
|
||||
@@ -565,8 +583,11 @@ export function Workspace() {
|
||||
|
||||
// Viewport locked only when a live run is showing (TerminalView needs locked
|
||||
// viewport for chat scrolling). The config-card screen needs normal page flow
|
||||
// so the form is fully reachable on short windows.
|
||||
const viewportLocked = paneHasActiveRun;
|
||||
// so the form is fully reachable on short windows. A full-width Changes panel
|
||||
// hides that form anyway, and without a locked height it shrinks to its
|
||||
// content — the editor inside it collapsed to a few lines (no live run).
|
||||
const viewportLocked =
|
||||
paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full");
|
||||
|
||||
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
|
||||
setLaneActionError(null);
|
||||
@@ -733,7 +754,9 @@ export function Workspace() {
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<section
|
||||
data-testid="lane-detail"
|
||||
className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}`}
|
||||
className={`flex min-h-0 min-w-0 flex-1 flex-col ${currentLane ? "card p-4" : ""}${
|
||||
currentLane && changesOpen && changesSize === "full" ? " hidden" : ""
|
||||
}`}
|
||||
>
|
||||
{/* One header row, not two. The pipeline picker, the stage badges,
|
||||
the feature picker and the router select used to sit on a second
|
||||
@@ -874,7 +897,8 @@ export function Workspace() {
|
||||
)}
|
||||
{currentLane && showInfo && (
|
||||
<div className="max-h-[45vh] shrink-0 overflow-y-auto">
|
||||
<div className="mb-3">
|
||||
{/* No margin when the card rendered nothing (see LaneCard). */}
|
||||
<div className="mb-3 has-[>div:empty]:mb-0">
|
||||
<LaneCard
|
||||
lane={currentLane}
|
||||
onAction={(a, b) => handleLaneAction(currentLane.id, a, b)}
|
||||
@@ -882,10 +906,6 @@ export function Workspace() {
|
||||
(l) => l.source_repo === currentLane.cwd && l.id !== currentLane.id
|
||||
)}
|
||||
onSelectLane={setSelectedLaneId}
|
||||
onNewLaneFromRepo={(repo, branch) => {
|
||||
setAddLanePrefill({ sourceRepo: repo, branch });
|
||||
setAddLaneOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
@@ -956,7 +976,6 @@ export function Workspace() {
|
||||
selectedLaneId={selectedLaneId}
|
||||
splitView={splitView}
|
||||
setLayout={setLayout}
|
||||
setPaneLaneId={setPaneLaneId}
|
||||
binaryStatus={binaryStatus}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
activeRuns={activeRuns}
|
||||
@@ -1014,7 +1033,20 @@ export function Workspace() {
|
||||
})()}
|
||||
</button>
|
||||
</div>
|
||||
<LaneChangesPanel laneId={currentLane.id} />
|
||||
<LaneChangesPanel
|
||||
laneId={currentLane.id}
|
||||
full={changesSize === "full"}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
// adopted lane is its own repo.
|
||||
onNewLane={(branch) => {
|
||||
setAddLanePrefill({
|
||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||||
branch,
|
||||
});
|
||||
setAddLaneOpen(true);
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
) : (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @file Tests for the Git credentials settings tab (issue #45): lists saved
|
||||
* logins without ever rendering a token, replaces the token for a host, adds
|
||||
* one from a URL, and deletes only after a confirm.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { GitCredentialsConfig } from "../GitCredentialsConfig";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: { gitCredentials: { list: vi.fn(), save: vi.fn(), remove: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const saved = {
|
||||
protocol: "https" as const,
|
||||
host: "git.smartgift.io.vn",
|
||||
username: "vi",
|
||||
savedAt: "2026-09-25T01:00:00.000Z",
|
||||
hasToken: true as const,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.gitCredentials.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [saved] });
|
||||
vi.mocked(api.gitCredentials.save)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [saved] });
|
||||
vi.mocked(api.gitCredentials.remove).mockReset().mockResolvedValue({ items: [] });
|
||||
});
|
||||
|
||||
describe("GitCredentialsConfig", () => {
|
||||
it("lists a saved login and replaces its token", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<GitCredentialsConfig />);
|
||||
expect(await screen.findByText("git.smartgift.io.vn")).toBeInTheDocument();
|
||||
expect(screen.getByText("vi")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "gitCreds.replace git.smartgift.io.vn" }));
|
||||
expect(screen.getByLabelText("gitCreds.fieldHost")).toHaveValue("https://git.smartgift.io.vn");
|
||||
expect(screen.getByLabelText("gitCreds.fieldToken")).toHaveValue("");
|
||||
await user.type(screen.getByLabelText("gitCreds.fieldToken"), "newtok");
|
||||
await user.click(screen.getByRole("button", { name: "gitCreds.save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.gitCredentials.save).toHaveBeenCalledWith({
|
||||
protocol: "https",
|
||||
host: "git.smartgift.io.vn",
|
||||
username: "vi",
|
||||
password: "newtok",
|
||||
})
|
||||
);
|
||||
expect(document.body.textContent).not.toContain("newtok");
|
||||
});
|
||||
|
||||
it("adds a login from a URL with a port", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<GitCredentialsConfig />);
|
||||
await user.click(await screen.findByRole("button", { name: "gitCreds.add" }));
|
||||
await user.type(
|
||||
screen.getByLabelText("gitCreds.fieldHost"),
|
||||
"http://gitea.lan:3000/org/repo.git"
|
||||
);
|
||||
await user.type(screen.getByLabelText("gitCreds.fieldUser"), "bot");
|
||||
await user.type(screen.getByLabelText("gitCreds.fieldToken"), "t");
|
||||
await user.click(screen.getByRole("button", { name: "gitCreds.save" }));
|
||||
await waitFor(() =>
|
||||
expect(api.gitCredentials.save).toHaveBeenCalledWith({
|
||||
protocol: "http",
|
||||
host: "gitea.lan:3000",
|
||||
username: "bot",
|
||||
password: "t",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes only after a confirm", async () => {
|
||||
const confirm = vi
|
||||
.spyOn(window, "confirm")
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(true);
|
||||
const user = userEvent.setup();
|
||||
render(<GitCredentialsConfig />);
|
||||
const del = await screen.findByRole("button", { name: "gitCreds.remove git.smartgift.io.vn" });
|
||||
await user.click(del);
|
||||
expect(api.gitCredentials.remove).not.toHaveBeenCalled();
|
||||
await user.click(del);
|
||||
await waitFor(() =>
|
||||
expect(api.gitCredentials.remove).toHaveBeenCalledWith("https", "git.smartgift.io.vn")
|
||||
);
|
||||
confirm.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file Workspace.changesPanel.test.tsx
|
||||
* @description The Changes panel is ONE control: it steps narrow → 50/50 →
|
||||
* 70/30 → closed, and the rail reopens it narrow. The width survives a reload.
|
||||
* @description The Changes panel is ONE control: it steps 30/70 → 50/50 →
|
||||
* full → closed, and the rail reopens it at 30/70. The width survives a reload.
|
||||
* All of it lives in localStorage and reaches no server — the same rule as the
|
||||
* lane strip's order.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -75,6 +75,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
}),
|
||||
pipelines: r({ pipelines: [] }),
|
||||
git: r({ available: false }),
|
||||
gitTree: r({ entries: [] }),
|
||||
runtime: r({ up: false, ports: {}, lastError: null }),
|
||||
features: { list: r({ features: [] }), show: r({ feature: null }) },
|
||||
proof: { list: r({ features: [] }), imageUrl: () => "" },
|
||||
@@ -142,26 +143,28 @@ describe("Workspace — the Changes panel's width", () => {
|
||||
}
|
||||
}
|
||||
|
||||
it("steps narrow → 50/50 → 70/30 → closed on one control", async () => {
|
||||
it("steps 30/70 → 50/50 → full → closed on one control, with no 70/30 step", async () => {
|
||||
await openPanel();
|
||||
const card = () => screen.getByTestId("lane-changes-card");
|
||||
expect(card().className).toContain("w-[28rem]");
|
||||
expect(card().className).toContain("w-[30%]");
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
expect(card().className).toContain("w-1/2");
|
||||
|
||||
// Full width HIDES the console beside it — hidden, still mounted.
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
expect(card().className).toContain("w-[70%]");
|
||||
expect(card().className).toContain("w-full");
|
||||
expect(screen.getByTestId("lane-detail").className).toContain("hidden");
|
||||
|
||||
// Past the widest step the panel closes — and reopens narrow, not back at
|
||||
// 70/30, which is why closing resets the width.
|
||||
// Past the widest step the panel closes — and reopens at 30/70, not back
|
||||
// at full width, which is why closing resets the width.
|
||||
fireEvent.click(screen.getByTestId("lane-changes-resize"));
|
||||
await settle();
|
||||
expect(screen.queryByTestId("lane-changes-card")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("lane-changes-expand"));
|
||||
await settle();
|
||||
expect(card().className).toContain("w-[28rem]");
|
||||
expect(card().className).toContain("w-[30%]");
|
||||
});
|
||||
|
||||
it("remembers the width across a reload, and never asks the server", async () => {
|
||||
|
||||
@@ -652,46 +652,38 @@ describe("split terminal view", () => {
|
||||
expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("switching to 2-pane layout renders two independent panes with lane pickers", async () => {
|
||||
it("switching to 2-pane layout renders two panes bound to the selected lane", async () => {
|
||||
await renderWorkspace();
|
||||
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
|
||||
await settle();
|
||||
expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(2);
|
||||
expect(screen.getAllByTestId("pane-lane-select")).toHaveLength(2);
|
||||
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
|
||||
expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("switching to 4-pane layout renders four panes", async () => {
|
||||
await renderWorkspace();
|
||||
fireEvent.click(screen.getByRole("button", { name: /4.*pane/i }));
|
||||
await settle();
|
||||
expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(4);
|
||||
expect(screen.getAllByTestId("console-body")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("persists the layout and pane selections to localStorage across remounts", async () => {
|
||||
it("persists the layout to localStorage across remounts", async () => {
|
||||
const { unmount } = await renderWorkspace();
|
||||
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
|
||||
await settle();
|
||||
const selects = screen.getAllByTestId("pane-lane-select");
|
||||
const select = selects[1];
|
||||
expect(select).toBeDefined();
|
||||
fireEvent.change(select!, { target: { value: String(lanesToReturn[1]!.id) } });
|
||||
await settle();
|
||||
unmount();
|
||||
|
||||
await renderWorkspace();
|
||||
const persistedSelects = screen.getAllByTestId("pane-lane-select");
|
||||
expect(persistedSelects).toHaveLength(2);
|
||||
expect((persistedSelects[1] as HTMLSelectElement).value).toBe(String(lanesToReturn[1]!.id));
|
||||
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("falls back to unselected when a persisted lane id no longer exists", async () => {
|
||||
it("ignores a persisted per-pane lane id: every pane follows the selected lane", async () => {
|
||||
localStorage.setItem(
|
||||
"agenthub.workspace.splitView",
|
||||
JSON.stringify({ layout: 2, paneLaneIds: [9999, null] })
|
||||
);
|
||||
await renderWorkspace();
|
||||
// Lane 9999 doesn't exist, so it falls back to null (unselected).
|
||||
// The second pane is already null. Both render as pane-empty.
|
||||
expect(screen.getAllByTestId("pane-empty")).toHaveLength(2);
|
||||
expect(screen.queryAllByTestId("pane-empty")).toHaveLength(0);
|
||||
expect(screen.getAllByTestId("console-body")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5570,11 +5570,16 @@ exports[`screen snapshots > Run 1`] = `
|
||||
>
|
||||
Prompt
|
||||
</label>
|
||||
<textarea
|
||||
class="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
placeholder="Ask Claude anything…"
|
||||
rows="5"
|
||||
/>
|
||||
<div
|
||||
class="relative"
|
||||
>
|
||||
<textarea
|
||||
class="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
placeholder="Ask Claude anything…"
|
||||
rows="5"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="mt-1 text-[10px] text-fg-muted"
|
||||
>
|
||||
@@ -10359,7 +10364,7 @@ exports[`screen snapshots > Settings 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v1.8.5 (rebuild client to match)
|
||||
UI build v1.8.9 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
+157
-22
@@ -224,7 +224,10 @@ existing `lane_update` message when the lane becomes `idle` or `failed`.
|
||||
runs will spawn. Immutable once the lane is created; an attempt to update it on
|
||||
an existing lane returns `400 EBADTOOL`.
|
||||
|
||||
`sourceRepo` must exist, be absolute, and be a git repository. When `base` is
|
||||
`sourceRepo` must exist, be absolute, and be a git repository with at least
|
||||
one commit — a repo with no commits has nothing to branch from and is refused
|
||||
with `400 EEMPTYREPO` before any lane row is created (adopt it as a Repo lane
|
||||
with `POST /api/lanes/ensure` instead, commit, then `publish`). When `base` is
|
||||
omitted, it defaults to the `LANE_BASE_BRANCH` env var, or `main` when that is
|
||||
also unset. The feature branch is named `<LANE_BRANCH_PREFIX><slug>` (prefix
|
||||
defaults to `feat/`). `title` (or an optional `slug`) determines the
|
||||
@@ -261,6 +264,8 @@ All fields are optional. The tool (CLI invoked) comes from the lane itself, neve
|
||||
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a <value>`.
|
||||
- **`resumeSessionId`** or **`resumeLast: true`** — resume an earlier run instead of starting new. For Codex lanes specifically, `resumeLast: true` uses `codex resume --last`; `resumeSessionId` uses `codex resume <sessionId>`. For Claude lanes, `resumeSessionId` re-attaches a Claude session by id.
|
||||
|
||||
- **`slot`** — which Workspace split pane's session to start, `0`–`3` (default `0`, else `400 EBADSLOT`). Slot 0 is the lane's own `agenthub-lane-<id>` and is what `run_id` records; slots 1–3 are extra sessions on the same lane, `agenthub-lane-<id>-<slot>`, and never touch `run_id`/`status`. The response adds `run_id`: the session this start opened. `stop`, `reset` and `remove` kill every slot's session, not only `run_id`.
|
||||
|
||||
Error `503 ETMUXMISSING` is returned when the host has no `tmux` binary. Every run the dashboard starts is a tmux session, so this is a missing dependency rather than a bad request — the message carries the install command, and the same code comes back from `POST /api/run`. Nothing else in the dashboard is affected.
|
||||
|
||||
Error `409 ERUNLIVE` is returned if the lane already has a live run; stop it first with the `stop` action. The same code and status come back when the lane's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently.
|
||||
@@ -378,6 +383,7 @@ What the lane's directory looks like to git right now:
|
||||
"available": true,
|
||||
"branch": "feat/rename-metric",
|
||||
"head": "9b3e74a",
|
||||
"unborn": false,
|
||||
"subject": "free-text rule mode in the form",
|
||||
"dirty": 2,
|
||||
"untracked": 1,
|
||||
@@ -392,8 +398,14 @@ What the lane's directory looks like to git right now:
|
||||
}
|
||||
```
|
||||
|
||||
- `dirty`, `untracked` and `files` cover only the lane's own directory, with
|
||||
cwd-relative, unquoted paths — a lane that is a subdirectory of its repo does
|
||||
not count its siblings' files (the same scoping as `/git/changes`).
|
||||
- A `cwd` that is missing, is not a git repository, or makes git fail returns
|
||||
`{"available": false}` with HTTP **200** — a normal state, not a fault.
|
||||
- A repo with **no commit yet** (a fresh `git init`) is available too: it
|
||||
reports `unborn: true`, `head: null`, `subject: ""` and the branch the first
|
||||
commit will land on.
|
||||
- `files` names the files the counts came from, **capped at 20** (the counts
|
||||
themselves are never capped). `status` is the two-character
|
||||
`git status --porcelain` code; `path` is the new path for a rename.
|
||||
@@ -432,6 +444,12 @@ unresolvable one is `400 EBADREV`, never an empty diff).
|
||||
range the diff has effectively become the whole file, which is what "expand
|
||||
all" means in the panel.
|
||||
|
||||
Every path on the lane git surface (reads and writes) is **relative to the
|
||||
lane's `cwd`**, even when that cwd is a subdirectory of its repo: changes
|
||||
outside the cwd are not listed and cannot be named. Paths are literal file
|
||||
names, never pathspec globs — `new*.js` or `app/[id]/page.js` names exactly
|
||||
that file.
|
||||
|
||||
```json
|
||||
{
|
||||
"available": true,
|
||||
@@ -487,8 +505,10 @@ all" means in the panel.
|
||||
```http
|
||||
GET /api/lanes/:id/git/log?limit=30[&file=<path>]
|
||||
GET /api/lanes/:id/git/file?path=<path>[&rev=work|HEAD|<sha>]
|
||||
PUT /api/lanes/:id/git/file {path, content, baseSha}
|
||||
GET /api/lanes/:id/git/ls?q=<substring>
|
||||
GET /api/lanes/:id/git/tree?path=<dir>
|
||||
GET /api/lanes/:id/git/tree-all
|
||||
```
|
||||
|
||||
The reads behind the Changes panel's **History**, whole-file view, quick-open
|
||||
@@ -497,19 +517,43 @@ and file explorer. All four are read-only and unguarded, and all four answer
|
||||
|
||||
- **`/git/log`** → `{available, commits:[{sha, short, subject, author, date,
|
||||
refs}]}`, newest first, `limit` clamped to 200. `file=` narrows it to the
|
||||
commits touching that path. Reading one commit's diff is
|
||||
commits touching that path — relative, no `..` segment, else
|
||||
`400 EBADPATH`. Reading one commit's diff is
|
||||
`/git/changes?range=commit&sha=`.
|
||||
- **`/git/file`** → `{available, path, rev, binary, truncated, lines}`.
|
||||
- **`PUT /git/file`** saves the whole-file editor (same-origin guarded;
|
||||
`server/lib/lane-file-write.js`). Same path guards as the read, plus: the
|
||||
file must already exist, be text and within the 1 MB cap, and `baseSha`
|
||||
must equal the `sha` it was opened at — otherwise `409 ESTALE`, never an
|
||||
overwrite. `403 ESECRET` for `settings.local.json`; `400 EBADPATH`,
|
||||
`ENOFILE`, `EBADCONTENT`, `ETOOBIG`, `EBINARY`. Answers the file re-read,
|
||||
fresh `sha` included. CRLF endings and the final newline are preserved.
|
||||
- **`/git/file`** → `{available, path, rev, binary, truncated, lines, sha?}`
|
||||
(`sha` only for a whole, textual `work` file: what a save sends back).
|
||||
`rev=work` (default) is the file on disk — the one branch that reads the
|
||||
filesystem, and it is realpath-confined to the lane directory; anything else
|
||||
goes through `git show <rev>:<path>`. Binary content is reported, never
|
||||
returned. Capped at 1 MB. `400 EBADPATH`, `EBADREV` or `ENOFILE`.
|
||||
filesystem, and it is realpath-confined to the lane directory (a symlink
|
||||
resolving outside it is `EBADPATH`); anything else goes through
|
||||
`git show <rev>:./<path>` (cwd-relative). At EVERY rev the path must be
|
||||
relative with no `..` segment (else `400 EBADPATH`), and it is normalised
|
||||
(`./a//b` → `a/b`, also the `path` echoed back). Binary content is reported,
|
||||
never returned. Capped at 1 MB — a larger file is `truncated: true`, never
|
||||
`ENOFILE` (a working-tree file is read only up to the cap, never whole).
|
||||
`400 EBADPATH`, `EBADREV` or `ENOFILE`. `.claude/settings.local.json`
|
||||
(where a lane's AI Router key lives) is `403 ESECRET` at EVERY rev and at
|
||||
ANY depth (`sub/.claude/settings.local.json`, a subdirectory lane's key, as
|
||||
seen from a lane at the repo root), and so is any path that RESOLVES to it
|
||||
(a symlinked `.claude` → `conf/` makes it `conf/settings.local.json`). In
|
||||
fact ANY file named `settings.local.json`, in any directory and at any rev,
|
||||
is refused — a symlink target's name is all a lane above it, or a commit of
|
||||
it, ever sees. Its hunks are never returned by `/git/changes` either.
|
||||
- **`/git/ls`** → `{available, paths, truncated, total}`, capped at 500. It
|
||||
reads `git ls-files`, so ignored files never appear and no skip-list has to
|
||||
be maintained; basename hits rank first.
|
||||
- **`/git/tree`** → `{available, path, entries:[{name, path, dir}], truncated}`
|
||||
— one directory level, tracked entries only, directories folded and listed
|
||||
first. `400 EBADPATH` for a path that escapes the lane.
|
||||
- **`/git/tree`** → `{available, path, entries:[{name, path, dir, status?, changed?}], truncated}`
|
||||
— one directory level: tracked files plus untracked ones git does not
|
||||
ignore (`ls-files --cached --others --exclude-standard`), directories folded
|
||||
and listed first. A changed file carries its porcelain `status` (` M`, `??`,
|
||||
`A `, ` D`…), a folder with anything changed under it `changed: true`. `400 EBADPATH` for a path that escapes the lane.
|
||||
- **`/git/tree-all`** → `{available, levels: {"<dir>": [{name, path, dir}]}, truncated}` — every level of the same tree (entries decorated the same way) from ONE `git ls-files`, each level shaped and capped like `/git/tree`'s; the Files tab's **Expand all folders** is this one request instead of one per folder.
|
||||
|
||||
#### Read a lane's own branches
|
||||
|
||||
@@ -530,20 +574,29 @@ POST /api/lanes/:id/git/:action
|
||||
```
|
||||
|
||||
The git **writes** the dashboard offers. Guarded (same-origin) and serialized
|
||||
per lane under the lane lock.
|
||||
per git index (the lane lock keyed on the worktree's git dir), so a claude and
|
||||
a codex lane on one cwd — or a root and a subdirectory lane of one repo —
|
||||
cannot collide on `index.lock`.
|
||||
|
||||
| Action | Runs | Body |
|
||||
| --- | --- | --- |
|
||||
| `fetch` | `git fetch --all --prune` | — |
|
||||
| `pull` | `git pull --ff-only` | — |
|
||||
| `push` | `git push -u origin HEAD` | — |
|
||||
| `push` | `git push -u <remote> HEAD` — the branch's upstream remote, else the only remote, else `origin`, else `400 ENOREMOTE` | — |
|
||||
| `stage` | `git add -- <paths>` | `{"paths": ["..."]}` |
|
||||
| `unstage` | `git restore --staged -- <paths>` | `{"paths": ["..."]}` |
|
||||
| `unstage` | `git restore --staged -- <paths>` (a staged rename also passes its original path, so both halves unstage) | `{"paths": ["..."]}` |
|
||||
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked) | `{"paths": ["..."], "confirm": true}` |
|
||||
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "context": 3}` |
|
||||
| `discard-hunk` | `git apply -R` of that one hunk, off the working tree | `{"path": "...", "hunk": "@@ ... @@", "context": 3, "confirm": true}` |
|
||||
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"` | `{"message": "...", "only": "staged"}` |
|
||||
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
|
||||
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
|
||||
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
|
||||
| `publish` | `git remote add origin -- <url>` then `git push -u origin HEAD` — only when the repo has NO remote | `{"url": "https://host/owner/repo.git"}` |
|
||||
| `merge` | `git merge --no-edit <ref>` — a local or remote-tracking branch that exists | `{"branch": "origin/main"}` |
|
||||
| `resolve` | a conflicted file: `checkout --ours\|--theirs` + `add`, or `add` once hand-edited (`done`) | `{"path": "...", "side": "ours\|theirs\|done"}` |
|
||||
| `merge-commit` | `git commit --no-edit` — concludes the merge | — |
|
||||
| `merge-abort` | `git merge --abort` | `{"confirm": true}` |
|
||||
|
||||
```json
|
||||
{ "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" }
|
||||
@@ -551,27 +604,104 @@ per lane under the lane lock.
|
||||
|
||||
- `pull` is **always** `--ff-only` and `switch` refuses a dirty tree
|
||||
(`400 EDIRTY`): neither can invent a merge commit or a conflict underneath a
|
||||
running agent. There is deliberately no merge, rebase, reset or stash here —
|
||||
running agent. There is deliberately no rebase, reset or stash here —
|
||||
`reset` stays behind the destroy guard on `POST /api/lanes/:id/reset`.
|
||||
- **Merge** (issue #45) happens only when asked for by name: on a clean tree
|
||||
(`400 EDIRTY`), from a lane at the repo root (`400 ESUBDIR` — a merge is
|
||||
repo-wide), for a branch listed under `refs/heads` or `refs/remotes`
|
||||
(`400 ENOBRANCH`). A merge that stops on conflicts is still a **200**, with
|
||||
`conflicts: ["path", …]`; a clean one answers `conflicts: []`. While a merge
|
||||
is in progress `pull`, `switch`, `merge`, `stage-hunk` and `discard-hunk` are
|
||||
`409 EMERGING`, and `GET /api/lanes/:id/git` reports `merging: true` plus
|
||||
`conflicts` (unmerged files, repo-wide). `resolve` accepts only a path git
|
||||
reports as conflicted (`400 EBADPATH`) and a `side` of `ours`/`theirs`/`done`
|
||||
(`400 EBADSIDE`); `done` and a plain `stage` of a conflicted file both refuse
|
||||
while a conflict marker is left in it (`400 EMARKERS`). A side that deleted
|
||||
the file resolves by removing it. `merge-commit` is `409 ECONFLICTS` while
|
||||
anything is unmerged, and it and `merge-abort` are `409 ENOMERGE` with no
|
||||
merge in progress. `merge-abort` needs `confirm: true` (`400 ECONFIRM`).
|
||||
- `push` pushes `HEAD`, never a branch name from the request, so a lane can
|
||||
only push the branch it is on.
|
||||
- A **path** in the body is never trusted as a path: `stage`, `unstage` and
|
||||
`discard` accept only paths `git status` is reporting as changed right now,
|
||||
so `..`, an absolute path and an untouched file are all `400 EBADPATH`.
|
||||
- `discard` is the one irreversible action here and it **requires**
|
||||
`confirm: true` (`400 ECONFIRM` without it). The UI additionally asks twice.
|
||||
A `commit` without `only: "staged"` sweeps in (`git add -A -- .`) only the
|
||||
lane cwd's own changes, and is `400 ENOCHANGES` when the lane has none —
|
||||
even if the repo is dirty elsewhere.
|
||||
- `discard` and `discard-hunk` are the irreversible actions here and both
|
||||
**require** `confirm: true` (`400 ECONFIRM` without it). The UI additionally
|
||||
asks twice.
|
||||
- A hunk is named by its `@@` line and matched against a FRESH diff at the
|
||||
`context` the caller read it with (send the `context` the changes read
|
||||
returned), so a hunk the file has moved past is `409 EHUNKGONE`, never a
|
||||
stale patch applied somewhere else. The path goes through the same
|
||||
`git status` check; an untracked file has no hunks (`400 EUNTRACKED`). A
|
||||
hunk that no longer applies (e.g. part of it is already staged) is
|
||||
`409 EGIT`.
|
||||
- `branch-create` never switches — the usual reason to create one from a lane
|
||||
is to spawn a sibling worktree lane, and moving this pane's HEAD under a
|
||||
working agent is not. `branch-delete` is `-d`, never `-D`, and refuses
|
||||
`main`/`master`/`development`/`develop` (`400 EPROTECTED`), the current
|
||||
branch and a branch another worktree holds (`400 EBRANCHBUSY`).
|
||||
- `publish` is the one write whose **destination comes from the request**, so
|
||||
it only ever gives a remote to a repo that has none: `400 ENOCOMMITS` before
|
||||
the first commit, `400 EREMOTEEXISTS` once any remote exists (it never
|
||||
re-points one), and the name is always `origin`. The URL is allowlisted —
|
||||
`https://` with no userinfo (a token in the URL would land in `.git/config`),
|
||||
`ssh://` and scp-like `user@host:path`; a local path, `file://`, `ext::`,
|
||||
`fd::`, `git://`, `http://` and anything starting with `-` are
|
||||
`400 EBADURL`. If the push fails, `origin` is removed again, so a typo or a
|
||||
missing credential can simply be retried. The response adds
|
||||
`"remote": "origin"`.
|
||||
- Bad input is **400** (`EBADACTION`, `EBADMESSAGE`, `EBADBRANCH`, `EBADPATH`,
|
||||
`ECONFIRM`, `EPROTECTED`, `ENOBRANCH`, `EBRANCHEXISTS`, `EBRANCHBUSY`,
|
||||
`EDIRTY`, `ENOCHANGES`); a git failure is
|
||||
**500** with git's own `stderr` appended to `error.message` — a rejection or a
|
||||
credential failure reads better in git's words.
|
||||
- Network actions (`fetch`/`pull`/`push`) are killed after 60s so an unreachable
|
||||
`ECONFIRM`, `EUNTRACKED`, `EPROTECTED`, `ENOBRANCH`, `EBRANCHEXISTS`, `EBRANCHBUSY`,
|
||||
`EDIRTY`, `ENOCHANGES`, `ESTAGEDOUTSIDE`, `ENOREMOTE`, `EBADURL`, `EREMOTEEXISTS`,
|
||||
`ENOCOMMITS`); git itself exiting
|
||||
non-zero (a rejected push, a diverged pull) is
|
||||
**409 `EGIT`** with git's own `stderr` appended to `error.message` — a
|
||||
rejection reads better in git's words. Anything else (git not installed, a
|
||||
timeout kill) is **500**, still under a string code (`EGIT` when there is
|
||||
none) — `error.code` is never git's numeric exit status.
|
||||
- In a lane whose cwd is a subdirectory of its repo, `commit` refuses with
|
||||
`400 ESTAGEDOUTSIDE` while the index holds a change outside that directory:
|
||||
`git commit` takes the whole index whatever the cwd, and scoping it with a
|
||||
pathspec would commit the working-tree copy (unstaged edits included).
|
||||
- Network actions (`fetch`/`pull`/`push`/`publish`) are killed after 60s so an unreachable
|
||||
remote fails instead of holding the request open.
|
||||
- **A remote that wants a login** (issue #45) is **409 `EGITAUTH`**, not
|
||||
`EGIT`: the dashboard's git has no terminal to prompt in. The error carries
|
||||
`auth: { protocol, host, username, rejected }` — `rejected: true` when a
|
||||
login WAS sent and refused. Resend the same action with
|
||||
`credentials: { protocol, host, username, password, remember }`: accepted
|
||||
only for a host that is one of the repo's own remotes (`400 EBADHOST`
|
||||
otherwise), used for that one command, and saved to AgentHub's store (below)
|
||||
only after git succeeded AND `remember` is true. Empty or multi-line values
|
||||
are `400 EBADCRED`.
|
||||
|
||||
#### Git credentials
|
||||
|
||||
```
|
||||
GET /api/git-credentials Saved logins: { items: [{ protocol, host, username, savedAt, hasToken }] }
|
||||
POST /api/git-credentials Save or replace — Body: { protocol, host, username, password }
|
||||
DELETE /api/git-credentials/:protocol/:host Delete one (404 ENOTFOUND)
|
||||
```
|
||||
|
||||
AgentHub's OWN git logins, one per `protocol + host`, shared by everyone using
|
||||
this install; the **Git credentials** tab of Settings
|
||||
(`/settings?tool=git-credentials`) and the git bar's login form both write
|
||||
here. Stored in `<CLAUDE_HOME>/agenthub-git-credentials/credentials.json`
|
||||
(dir `0700`, file `0600`). **No response ever carries a password.** Writes are
|
||||
same-origin guarded; a bad protocol/host is `400 EBADHOST`, an empty login
|
||||
`400 EBADCRED`.
|
||||
|
||||
A network action reaches git with `-c credential.helper= -c
|
||||
credential.helper=<AgentHub's helper>` whenever AgentHub holds a login for one
|
||||
of the repo's remote hosts (or the request carries one) — ONLY AgentHub's
|
||||
helper, so a stale token in the machine's helper cannot win over the one just
|
||||
typed, and git's post-success `store` cannot copy a use-once token into the
|
||||
OS user's helper. Otherwise the command is unchanged and the machine's own
|
||||
helpers answer as before. The secret never reaches argv: the helper reads the
|
||||
store file, or a use-once login from its own process env.
|
||||
|
||||
#### Read a lane's runtime
|
||||
|
||||
@@ -1804,9 +1934,9 @@ The `/api/cc-config/*` namespace powers the Claude Config Explorer, now the **Cl
|
||||
|
||||
```http
|
||||
GET /api/cc-config/overview
|
||||
GET /api/cc-config/skills?scope=user|project|all
|
||||
GET /api/cc-config/skills?scope=user|project|all&cwd=<dir>
|
||||
GET /api/cc-config/agents
|
||||
GET /api/cc-config/commands
|
||||
GET /api/cc-config/commands?scope=user|project|all&cwd=<dir>
|
||||
GET /api/cc-config/output-styles
|
||||
GET /api/cc-config/plugins
|
||||
GET /api/cc-config/marketplaces
|
||||
@@ -1842,6 +1972,8 @@ POST /api/cc-config/mcp/add Body: { name, scope, transport?, url?,
|
||||
POST /api/cc-config/mcp/remove Body: { name, scope? }
|
||||
```
|
||||
|
||||
The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a lane's own project commands and skills are the ones offered.
|
||||
|
||||
`scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`.
|
||||
|
||||
`GET /api/cc-config/plugins` returns each installed plugin's `contributes` block: the `skills` / `agents` / `commands` / `outputStyles` / `hooks` counts, plus `contributes.items` listing the first four surfaces **by name** (`[{ name, description, file }]`) so the UI can show *which* artifacts a plugin ships, not only how many. `name` is the file's frontmatter `name` when it declares one — the string Claude Code actually addresses it by — otherwise the filename. Each surface covers both the conventional directory (`skills/`, `agents/`, `commands/`, `output-styles/`) **and** any path `plugin.json` declares under that key: a plugin that lists its commands explicitly and keeps nothing in `commands/` (this repo's own `agenthub` does) previously reported zero. A declared path that resolves outside the plugin's install directory is ignored — a third-party manifest must not make the dashboard read arbitrary files. The counts are always the matching list's length. `hooks` has no `items` list: a plugin's hooks are matcher/command pairs declared inline in `plugin.json`, not named artifacts.
|
||||
@@ -1933,12 +2065,15 @@ GET /api/run/binary?tool= { found, path } for the `claude` or `codex
|
||||
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
|
||||
GET /api/run/history?limit=&laneId= Persisted run history; laneId narrows to one lane's runs
|
||||
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
|
||||
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <tmpdir>/agenthub-drops-<uid>/ with a random name, pruned after a day; the terminal pastes the path
|
||||
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
|
||||
POST /api/run Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt? }
|
||||
GET /api/run/:id Run handle (returns live run state)
|
||||
DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
|
||||
```
|
||||
|
||||
`:id` must be a lane pane name, `agenthub-lane-<n>`; anything else answers 404 without reaching tmux, so these two routes can never touch a tmux session the dashboard did not start.
|
||||
|
||||
**`GET /api/run/binary`:** Probes for the binary (`claude` or `codex`). Optional query parameter `?tool=codex` probes for Codex; omitted or `?tool=claude` probes for Claude. Returns `{ found: bool, path: string | null }`.
|
||||
|
||||
**`POST /api/run` (start/attach):** Requires `laneId` (the lane this run belongs to). Creates or attaches an existing tmux session named `agenthub-lane-<id>` in the lane's working directory. If that session already exists and its pane is idling at a shell prompt, the appropriate command line (including `--resume` and any initial prompt) is typed into that pane instead of being dropped; if the pane is running a program, the request adopts the session unchanged. Optionally accepts `initialPrompt` to immediately type/send into the session (if empty or omitted, the session is created/attached with no initial input). Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The dashboard self-heals a lane's `run_id`/`status` on every read if the tmux session has been killed externally.
|
||||
|
||||
+3
-1
@@ -389,7 +389,9 @@ CREATE TABLE notifications (
|
||||
|
||||
### model_pricing
|
||||
|
||||
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD).
|
||||
Per-model pricing rules for cost calculation, keyed by `model_pattern` (a SQL-style glob; `%` matches any characters). Rates are per **million** tokens (USD). The longest matching pattern wins.
|
||||
|
||||
**Automatic rates.** `server/lib/pricing-sync.js` runs at server start and every 15 minutes. Any Claude model seen in `token_usage` whose family (the id minus a `-YYYYMMDD` suffix) has no `<family>%` row of its own gets one, INSERTED from LiteLLM's public price list (`model_prices_and_context_window.json` on GitHub). The list is fetched only when something is missing and cached for 24 h. Existing rows are never updated, so a rate edited in Settings always wins. A rate outside 0–1000 $/MTok is rejected, and a failed fetch changes nothing. Set `AGENTHUB_PRICING_SYNC=0` (or `false`/`off`) to turn it off.
|
||||
|
||||
```sql
|
||||
CREATE TABLE model_pricing (
|
||||
|
||||
+113
-33
@@ -22,7 +22,7 @@ agenthub lanes add --cwd /path/to/repo --title "My Feature"
|
||||
|
||||
The lane is now ready to track work. When you run a Claude Code session in that directory (or a subdirectory), the dashboard will bind the session to the lane automatically by matching the cwd.
|
||||
|
||||
**Note:** The directory must exist and be an absolute path. A lane with a cwd of `/tmp/wt` will NOT capture `/tmp/wt-sibling` (no partial prefix match) but WILL capture sessions in `/tmp/wt/subdir/` (subdirectories are owned by the parent lane unless a deeper lane exists).
|
||||
**Note:** The directory must exist and be an absolute path. A lane with a cwd of `/tmp/wt` will NOT capture `/tmp/wt-sibling` (no partial prefix match) but WILL capture sessions in `/tmp/wt/subdir/` (subdirectories are owned by the parent lane unless a deeper lane exists). A `cwd` that exists is stored by its real path (`fs.realpathSync.native`), so `/tmp/wt` on macOS is stored as `/private/tmp/wt` and a differently-cased path on a case-insensitive volume as the spelling on disk — the path a session reports. A path that does not exist yet is resolved through its deepest existing ancestor (`/tmp/new` → `/private/tmp/new`); rows created before this rule keep theirs.
|
||||
|
||||
To provision a dashboard-managed git worktree instead of adopting an existing directory, use a source repository:
|
||||
|
||||
@@ -421,14 +421,14 @@ The dashboard web UI merges lanes and runs into a single **Workspace** page acce
|
||||
- **Detail panel** — the selected lane's declared stage, its inferred stage when detection leads, a full-width pipeline map, and a legend naming all five node states plus the dashed-amber inferred treatment.
|
||||
- **Terminal** — a real interactive terminal (xterm.js) displaying the tmux session's PTY output, with full support for interactive commands, editors, and pagers. A live run keeps its rendered history and scroll position when scrolling. It grabs the keyboard the moment it appears, so typing goes into the pane without clicking it first, and when the pane's process ends — a double Ctrl+C out of `claude`, `exit`, a crash — the console returns to the run setup form on its own, with no "New run" click.
|
||||
- **Clipboard in a pane** — the lane's tmux session is started with the graphical session's own `DISPLAY`/`WAYLAND_DISPLAY`/`XAUTHORITY`/`XDG_RUNTIME_DIR` (read from `systemctl --user show-environment`, this process's own values winning; `server/lib/desktop-env.js`). The dashboard normally runs as a systemd *user* service, which has none of them, so a pane it started had no clipboard at all and `claude` answered "no image found in clipboard" on every image paste while the same CLI pasted fine in a terminal. Only those four names are forwarded, and only into panes this dashboard spawns — an already-running pane keeps the environment it was started with, so it needs a restart to gain one.
|
||||
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many independent terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). Layout 1 is bound to the lane strip's selection, same as always; layouts 2 and 4 give each pane its own lane picker, independent of the strip. The chosen layout and each pane's lane persist to `localStorage` (`agenthub.workspace.splitView`) across reloads.
|
||||
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, the needs-you banner, and the action row.
|
||||
- **Split view** — a layout toggle (one pane / two side by side / a 2×2 grid, drawn as icons rather than the words, with the count kept as each button's `aria-label` and tooltip) renders that many terminal panes side by side (`grid-cols-2` for 2, a 2×2 grid for 4). EVERY pane is bound to the lane strip's selection — there is no per-pane lane picker — and each pane owns its own tmux session on that lane: pane 0 is the lane's `agenthub-lane-<id>` (its `run_id`), panes 1–3 are `agenthub-lane-<id>-<slot>` (`POST /api/lanes/:id/start` with `slot`). A pane's **Active runs** leaves out the runs the other panes are showing, so one terminal is never attached twice side by side. Only the layout persists to `localStorage` (`agenthub.workspace.splitView`); a stored per-pane lane id from an older build is ignored.
|
||||
- **Lane grid** — one card per lane, 1 column, 2 at `md`, 3 at `xl`. Each card carries the lane id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the lane strip tile only — the detail card no longer repeats it as a banner.
|
||||
|
||||
Run history is per lane, queryable via `GET /api/run/history?laneId=<n>`.
|
||||
|
||||
**A console pane follows the lane it shows.** Selecting another lane — from the lane strip in layout 1, or from a pane's own lane picker in layouts 2 and 4 — swaps that pane's cwd, run history and terminal over to the new lane. If the new lane already has a live run in `GET /api/run`, the pane re-attaches to it immediately, so each lane sticks to its own `agenthub-lane-<id>` tmux session; if it has none, the pane shows that lane's setup form. Nothing of the previous lane (a half-typed prompt, its terminal) carries over.
|
||||
**A console pane follows the lane it shows.** Selecting another lane on the lane strip swaps every pane's cwd, run history and terminal over to the new lane. If the new lane already has a live session for that pane's slot in `GET /api/run`, the pane re-attaches to it immediately, so each pane sticks to its own `agenthub-lane-<id>[-<slot>]` tmux session; if it has none, the pane shows that lane's setup form. Nothing of the previous lane (a half-typed prompt, its terminal) carries over.
|
||||
|
||||
The **working directory** field tracks the selected lane's own `cwd` specifically, and re-syncs as soon as that path is known rather than only when the selection changes — a pane can render before `GET /api/lanes` has answered (split view restores its pane lanes from `localStorage`), and its lane id never changes afterwards. `RunSetup` submits that string verbatim to `POST /api/lanes/:id/start`, so a cwd left over from the previous lane or from the home default would start the run in the wrong folder. The home suggestion is used only while no lane is selected at all.
|
||||
The **working directory** field tracks the selected lane's own `cwd` specifically, and re-syncs as soon as that path is known rather than only when the selection changes — a pane can render before `GET /api/lanes` has answered (the lane list loads after the page mounts), and its lane id never changes afterwards. `RunSetup` submits that string verbatim to `POST /api/lanes/:id/start`, so a cwd left over from the previous lane or from the home default would start the run in the wrong folder. The home suggestion is used only while no lane is selected at all.
|
||||
|
||||
**Picking a session to resume starts it right away.** In the setup form's Fresh/Resume switch, choosing a session from the resume picker fires the start immediately — `POST /api/lanes/:id/start` with that `resumeSessionId` and the session's own `cwd` — and the pane switches straight to the terminal. A resume carries its own transcript, so there is nothing to type first: the prompt box stays optional for resumes (the Run button no longer requires it) and required for fresh runs. A resume also **sends no prompt at all** — whatever is left in that box is dropped rather than fired as an extra turn into the session being continued. Type into the tmux terminal once it's attached.
|
||||
|
||||
@@ -438,7 +438,7 @@ The **Active runs** button in the console header opens the merged run list. It s
|
||||
|
||||
1. live in-memory tmux runs from `GET /api/run`,
|
||||
2. persisted dashboard runs from `GET /api/run/history`,
|
||||
3. Claude Code sessions running **outside** the dashboard — `GET /api/sessions?status=active`, i.e. a `claude` the user started by hand in a terminal tab. These carry an amber `external` badge, and the button's live count includes them, so two hand-started agents read as "2 active runs".
|
||||
3. Claude Code sessions running **outside** the dashboard — `GET /api/sessions?status=active`, i.e. a `claude` the user started by hand in a terminal tab. These carry an amber `external` badge, and the button's live count includes the ones its lane owns (next paragraph), so two hand-started agents in the lane's folder read as "2 active runs".
|
||||
|
||||
An external session is deduped against a dashboard run with the same `session_id`, and sessions from a remote data source (`source !== "local"`) or without a `cwd` are skipped — neither can be resumed on this machine.
|
||||
|
||||
@@ -480,8 +480,8 @@ Two concurrent calls for the same `(cwd, tool)` pair always yield one lane: the
|
||||
|
||||
### Reading a lane's working copy: `GET /api/lanes/:id/git`
|
||||
|
||||
Each lane card shows the branch its working copy is actually on, the short
|
||||
HEAD, that commit's subject, and how much is uncommitted:
|
||||
The top of a lane's **Changes** panel shows the branch its working copy is
|
||||
actually on, the short HEAD, that commit's subject, and how much is uncommitted:
|
||||
|
||||
```bash
|
||||
curl http://localhost:4820/api/lanes/5/git
|
||||
@@ -505,7 +505,7 @@ curl http://localhost:4820/api/lanes/5/git
|
||||
|
||||
- A `cwd` that is missing, is not a git repository, or makes git fail returns
|
||||
`{"available": false}` with HTTP **200**. A lane pointing at a plain directory
|
||||
is a normal state, not a fault, and the card simply renders without a git row.
|
||||
is a normal state, not a fault, and the panel simply renders without a git line.
|
||||
- A detached checkout reports the literal `HEAD` that git returns, rather than a
|
||||
prettier invention.
|
||||
- `files` names the files the two counts came from, **capped at 20** — a lane
|
||||
@@ -520,63 +520,111 @@ curl http://localhost:4820/api/lanes/5/git
|
||||
read off disk, since a lane 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 on a
|
||||
lane card opens this list as a panel: one row per file, its status letter
|
||||
- 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
|
||||
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`,
|
||||
`head: null`, an empty `subject` and the branch its first commit will land on.
|
||||
- An unknown lane id is a `404`.
|
||||
|
||||
This is **not** folded into `GET /api/lanes` on purpose: it shells out to git
|
||||
five times, and the lane list is polled and re-broadcast on every hook-driven
|
||||
`lane_update`. Putting it there would fire a burst of subprocesses behind every
|
||||
tool call an agent makes. The browser fetches it per card instead, on mount and
|
||||
every 30 seconds, and a failed fetch is silent.
|
||||
tool call an agent makes. The Changes panel fetches it instead, on mount, every 30 seconds and after
|
||||
every git write it or its action row makes, and a failed fetch is silent.
|
||||
|
||||
The same response also carries the branch's standing against its remote:
|
||||
`hasRemote`, `upstream`, `ahead`, `behind`. A branch that has never been pushed
|
||||
reports `upstream: null` and **null** counts — not zeros, which would claim "in
|
||||
sync" about a comparison that was never possible. The card renders them as
|
||||
sync" about a comparison that was never possible. The panel renders them as
|
||||
`↑2 ↓1` beside the git actions below.
|
||||
|
||||
### Acting on a lane's working copy: the git action row
|
||||
|
||||
Under the git line, a lane's card carries the git **writes** the dashboard
|
||||
offers (`POST /api/lanes/:id/git/:action`, see `docs/API.md`):
|
||||
On the same row as the git line — as icons, each naming itself in its tooltip —
|
||||
the Changes panel carries the per-repo git **writes** the dashboard offers (`POST /api/lanes/:id/git/:action`, see `docs/API.md`):
|
||||
|
||||
| Button | Runs | Refuses when |
|
||||
| --- | --- | --- |
|
||||
| **Fetch** | `git fetch --all --prune` | the repo has no remote |
|
||||
| **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) |
|
||||
| **Push** | `git push -u origin HEAD` | the remote rejects it |
|
||||
| **Commit** | `git add -A` + `git commit -m …`, or **Commit staged** (the index as it stands) | nothing is uncommitted / nothing is staged |
|
||||
| **Push** (labelled **Publish branch** while the branch has no upstream) | `git push -u origin HEAD` | the remote rejects it |
|
||||
| **Publish…** (only when the repo has NO remote) | `git remote add origin -- <url>`, then `git push -u origin HEAD`; `origin` is removed again if the push fails | the repo has no commit yet, already has a remote, or the URL is not `https://` (no `user:token@`), `ssh://` or `user@host:path` |
|
||||
| **Branch** | `git switch …` | the tree is dirty, or another worktree holds that branch |
|
||||
| **Create** | `git branch <name>` — never switches | the branch already exists |
|
||||
| **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits |
|
||||
| **New lane here** | opens **Add lane** prefilled with this repo and the selected branch | — |
|
||||
| **Merge into current** | `git merge --no-edit <branch>` (after a confirm) | the tree is dirty, the lane is a subdirectory of its repo, or a merge is already in progress |
|
||||
| **Merge `<upstream>`** | the same, offered when the branch has diverged (ahead AND behind) | as above |
|
||||
|
||||
These sit on the card rather than in the ⋯ menu deliberately: that menu holds
|
||||
These sit in the panel rather than in the lane card's ⋯ menu deliberately: that menu holds
|
||||
the rare and dangerous (`reset worktree`), and these are the ordinary moves a
|
||||
lane needs several times an hour. The boring half of git is the whole list on
|
||||
purpose — there is no merge, rebase, reset or stash button, because a dashboard
|
||||
click is the wrong place to start an operation whose recovery needs a terminal.
|
||||
lane needs several times an hour. There is no rebase, reset or stash button,
|
||||
because a dashboard click is the wrong place to start an operation whose
|
||||
recovery needs a terminal. **Merge** is the one exception (issue #45), because
|
||||
its recovery is a button too: a merge that stops on conflicts puts a banner at
|
||||
the top of the Changes panel — **Complete merge** (enabled once nothing is
|
||||
conflicted) and **Abort merge** (two clicks; back to where the branch was) —
|
||||
and lists the conflicted files in their own **Merge conflicts** group with
|
||||
**Keep mine**, **Take theirs** and **Mark resolved** (after editing the
|
||||
`<<<<<<<`/`>>>>>>>` markers out in the file editor; refused while any are
|
||||
left). Pull and branch switch are disabled until the merge is finished or
|
||||
aborted.
|
||||
`pull --ff-only` and the dirty-tree guard on `switch` are what keep either from
|
||||
inventing a merge commit or a conflict underneath a running agent. **Create**
|
||||
deliberately does not switch: the usual reason to make a branch from a lane is
|
||||
to spawn a sibling worktree lane on it, and moving this pane's HEAD under a
|
||||
working agent is not. **New lane here** goes through the normal Add-lane flow
|
||||
with the fields prefilled — one provisioning path for every lane, not a second
|
||||
one hidden in the git bar.
|
||||
one hidden in the git bar. There is no **Commit** here: committing is the
|
||||
overview's staged-only box below, the one commit path, and it only ever takes
|
||||
what was staged.
|
||||
|
||||
**Publish…** is the one button whose destination the user types, so the server
|
||||
keeps it narrow: it only gives a remote to a repo that has none (it never
|
||||
re-points one), always names it `origin`, and accepts only `https://` without
|
||||
credentials in the URL, `ssh://` and scp-like `git@host:owner/repo.git` — never
|
||||
a local path, `file://`, `ext::` or `http://`. Credentials come from the
|
||||
machine's own credential helper, ssh agent or AgentHub's own git login store
|
||||
(the login form below), exactly as for **Push**.
|
||||
|
||||
A repo with **no commits yet** (a fresh `git init`) still gets the header: its
|
||||
branch, "no commits yet", and a hint to commit first — the network and branch
|
||||
buttons are hidden because there is nothing to push, fetch into or branch from.
|
||||
The commit box below makes the first commit, and **Publish…** appears once it
|
||||
exists. **Add lane** in Worktree mode refuses such a repo (`400 EEMPTYREPO`,
|
||||
nothing to fork a worktree from) and offers **Use as a Repo lane** instead.
|
||||
|
||||
A failed action prints git's own `stderr` verbatim — `! [rejected] … (non-fast-forward)`
|
||||
says more than any sentence the dashboard could write.
|
||||
|
||||
The exception is a remote that wants a **login** (issue #45). The dashboard
|
||||
runs git without a terminal, so where a shell would prompt, the bar opens a
|
||||
form instead — "Git needs a login for `<host>`": username, password or access
|
||||
token (on Gitea: Settings → Applications → Generate token), and **Remember for
|
||||
everyone using this AgentHub** (on by default) — and resends the same action
|
||||
with it. A saved login lives in AgentHub's own store, one per host, shared by
|
||||
every user of the install, and is managed from Settings → **Git credentials**;
|
||||
it is saved only after git accepted it. While AgentHub holds a login for a
|
||||
repo's host, that login is the only one git is given for the command — the
|
||||
machine's own credential helpers are left out, so a stale token there cannot
|
||||
win. Over plain `http://` on a LAN address the form warns that the token
|
||||
crosses the network unencrypted. See `docs/API.md` § Git credentials.
|
||||
|
||||
### The Changes panel
|
||||
|
||||
To the right of the lane detail sits a collapsible **Changes** card (its state
|
||||
persists per viewer in `localStorage`). Its header holds **one** control, which
|
||||
steps narrow → 50/50 → 70/30 → closed (`agenthub.workspace.changesSize`
|
||||
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`
|
||||
alongside `agenthub.workspace.changesOpen`); the collapsed rail reopens it
|
||||
narrow. Reading a diff and watching a console want opposite amounts of room and
|
||||
narrow. 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
|
||||
a setting — and one button beats a width button wedged beside a collapse button
|
||||
in the panel's tightest corner. It is icon-only, and the icon it draws is the
|
||||
@@ -590,7 +638,18 @@ only one that answers "show me the code" rather than "show me the change"):
|
||||
them — a folder's children render nested under it, siblings stay visible,
|
||||
and a toolbar button expands or collapses every folder at once (each
|
||||
unfetched level lazily calls `/git/tree` the first time it's opened).
|
||||
Opening a file entry shows the whole file (`/git/file`).
|
||||
Untracked files git does not ignore are listed too, and the tree is
|
||||
decorated like VS Code's: a modified file is amber with `M`, an untracked
|
||||
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`). Its **Edit** button
|
||||
(any range but a commit) turns that view into a text editor: **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 where the Edit button would be.
|
||||
- **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.
|
||||
@@ -600,6 +659,16 @@ only one that answers "show me the code" rather than "show me the change"):
|
||||
target-practice exercise against one chevron.) One toolbar button folds or
|
||||
unfolds all of them. What is remembered is the set of *collapsed* paths, so
|
||||
a file that appears later is open by default; a lane switch clears it.
|
||||
Each file's header stays **pinned** at the top while its diff scrolls past,
|
||||
and carries the same stage / unstage / discard buttons as its overview row.
|
||||
On a file with unstaged changes every hunk gets an editor-style **review
|
||||
bar**, hung just under its last changed line and pinned to the visible
|
||||
right edge: `^`/`v` to walk the hunks (each brings the next/previous `@@`
|
||||
line to the top of the ONE scroller, below the sticky file header — a
|
||||
review diff has no scroll box of its own unless it is large enough to
|
||||
window, above 500 rows), `n of N`, **Undo**
|
||||
(`discard-hunk` — asks twice, like discard) and **Keep** (`stage-hunk`).
|
||||
The single-file view offers the same bar.
|
||||
- **One file** — clicking a file's path in any list opens just that file.
|
||||
**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
|
||||
@@ -637,13 +706,19 @@ in every panel narrow enough to sit beside a console.
|
||||
|
||||
**Per-file writes are the panel's, per-repo writes are the git bar's.** Staging
|
||||
one file means nothing unless a commit can take the index as it stands, so the
|
||||
overview grows a commit box the moment anything is staged — it sends
|
||||
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. (The git bar's own **Commit staged** is the
|
||||
same call from the lane card; its plain **Commit** is still stage-everything.) **Discard** is the one
|
||||
there is nothing it could mean. 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
|
||||
already reporting as changed.
|
||||
already reporting as changed. In the `work` range the overview splits like VS
|
||||
Code's Source Control: **Staged Changes** (what the next commit takes, each row
|
||||
offering Unstage) above **Changes** (unstaged and untracked, each offering
|
||||
Stage); a partly staged file appears in both. In the tree, a changed file's
|
||||
whole row takes its status tint, not only its letter.
|
||||
|
||||
Review, single-file, history and browse do **not** poll: re-fetching under the
|
||||
reader's scroll position is worse than a slightly stale view, and those are the
|
||||
@@ -663,7 +738,7 @@ This shows each lane's:
|
||||
- **status**: `running` (a session is active), `idle` (no active session), or `provisioning`
|
||||
- **liveness**: `active` (recently updated), `dead` (silent for too long), or `idle` (not expected to be running)
|
||||
- **progress**: percentage of the pipeline completed (0–100%)
|
||||
- **needs_action**: a notification if the session is blocked waiting for input
|
||||
- **needs_action**: a notification if the session is blocked waiting for input. `needs_action_session` records which session raised it, and only that session's next hook clears it — a second session in the same cwd (a hand-started terminal beside the dashboard pane) rebinds `session_id` but cannot clear a prompt it is not the one waiting on
|
||||
|
||||
A lane row also gets a `⇢ detected:<stage>` suffix when the server's inferred
|
||||
stage (see "Stage detection" below) leads the declared `stage` shown in the
|
||||
@@ -1481,9 +1556,9 @@ Terminate the running session. If the lane has no live run, this is a no-op (no
|
||||
curl -X POST http://localhost:4820/api/lanes/5/stop
|
||||
```
|
||||
|
||||
### git: fetch / pull / push / stage / unstage / discard / commit / switch / branch-create / branch-delete
|
||||
### git: fetch / pull / push / stage / unstage / discard / commit / switch / branch-create / branch-delete / publish
|
||||
|
||||
The git writes the lane card's action row and the Changes panel offer. See
|
||||
The git writes the Changes panel and its action row offer. See
|
||||
"Acting on a lane's working copy" above for what each one refuses and why.
|
||||
|
||||
```bash
|
||||
@@ -1501,6 +1576,11 @@ curl -X POST http://localhost:4820/api/lanes/5/git/commit \
|
||||
curl -X POST http://localhost:4820/api/lanes/5/git/discard \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"paths": ["src/scratch.ts"], "confirm": true}'
|
||||
|
||||
# A repo with commits but no remote: add `origin` and push HEAD to it
|
||||
curl -X POST http://localhost:4820/api/lanes/5/git/publish \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"url": "git@github.com:owner/repo.git"}'
|
||||
```
|
||||
|
||||
### clear
|
||||
|
||||
+9
-3
@@ -128,13 +128,13 @@ check.
|
||||
milliseconds — the real work happens in a detached worker, so a session never
|
||||
waits on an install:
|
||||
|
||||
1. **Fast path** — recorded state matches this plugin build and a server is live → exit.
|
||||
1. **Fast path** — recorded state matches this plugin build and a server is live → exit. "Live" means a discovery entry whose pid answers AND whose `startedAt` is after this boot (60 s margin): an entry left over from before a reboot names a pid the OS may have handed to an unrelated process, and without systemd (macOS) nothing else clears it.
|
||||
2. **Superseded check** — if Claude Code records a DIFFERENT build of this plugin as installed, this one logs a line and does nothing else (see [A retired build never acts](#a-retired-build-never-acts)).
|
||||
3. **Node gate** — refuse below 22.5 with one line (see Prerequisites).
|
||||
4. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers.
|
||||
5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain.
|
||||
6. **Legacy hook cleanup** — see above.
|
||||
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write.
|
||||
7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade).
|
||||
8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev`, falling back to `npm install --include=dev`, then `npm run build`), landing in `runtime/client-dist` — the same directory the server serves from. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line.
|
||||
9. **Server** — on Linux with `systemd --user` available, install/refresh a
|
||||
`agenthub.service` unit (see [Auto-start on boot](#auto-start-on-boot)) instead
|
||||
@@ -235,7 +235,13 @@ server falls back to the old per-session detached spawn instead.
|
||||
|
||||
On macOS, Windows, or a Linux box without systemd, nothing changes: the
|
||||
server is a detached spawn started fresh on the next `SessionStart` after a
|
||||
reboot, same as always.
|
||||
reboot, same as always. That spawn inherits the Claude Code session's env, so
|
||||
`server/index.js` deletes the parent-session variables (`CLAUDECODE`,
|
||||
`CLAUDE_CODE_SESSION_ID`, `CLAUDE_CODE_MESSAGING_*`, `CLAUDE_PLUGIN_ROOT`, …)
|
||||
before anything else runs — otherwise tmux hands them to every lane pane. It is
|
||||
a denylist: user configuration such as `CLAUDE_CONFIG_DIR`,
|
||||
`CLAUDE_CODE_USE_BEDROCK` and `ANTHROPIC_*` passes through. There is no
|
||||
launchd agent; see [INSTALL.md § macOS](../INSTALL.md#macos).
|
||||
|
||||
### Who may stop the server
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Git credentials for dashboard fetch/pull/push (issue #45, part A)
|
||||
|
||||
## Problem
|
||||
|
||||
The dashboard runs git with no terminal and `GIT_TERMINAL_PROMPT=0`. When no
|
||||
credential helper on the machine holds a credential for the remote's host, git
|
||||
wants to prompt, cannot, and fails:
|
||||
`fatal: could not read Username for 'https://git.smartgift.io.vn': terminal prompts disabled`.
|
||||
Reproduced with `git -c credential.helper= push --dry-run`.
|
||||
|
||||
## Decisions (approved 2026-09-25)
|
||||
|
||||
- AgentHub keeps its OWN credentials, shared by everyone using the AgentHub
|
||||
install (multi-user login is planned; accounts and settings are shared).
|
||||
- The dashboard asks for a credential when git needs one, then retries.
|
||||
- Merge and conflict resolution is part B and gets its own design.
|
||||
|
||||
## Design
|
||||
|
||||
**Store** — `server/lib/git-credentials.js`, one file
|
||||
`<CLAUDE_HOME>/agenthub-git-credentials/credentials.json` (dir 0700, file 0600,
|
||||
written through a temp file), one record per `protocol + host`:
|
||||
`{protocol, host, username, password, savedAt}`. No route returns `password`;
|
||||
the list reports `hasToken`. Routes: `GET /api/git-credentials`,
|
||||
`DELETE /api/git-credentials/:protocol/:host`.
|
||||
|
||||
**Delivery** — `server/lib/git-credential-helper.js`, a git credential helper
|
||||
speaking git's own protocol. For a network action, when AgentHub holds a
|
||||
credential for one of the repo's remote hosts, or the request carries one, git
|
||||
runs with `-c credential.helper= -c credential.helper=<ours>`: ONLY our helper.
|
||||
The machine's helpers are left out on purpose — a stale token there would win
|
||||
over the one just typed, and git's post-success `store` would copy a
|
||||
"use once" token into the OS user's own helper. With nothing from AgentHub for
|
||||
the host, the command is unchanged. The secret never reaches argv: the helper
|
||||
reads the store file, or `AGENTHUB_GIT_ONCE` in that one git process's env.
|
||||
|
||||
**Flow** — an auth failure (`could not read Username|Password`,
|
||||
`Authentication failed`, `terminal prompts disabled`, `Invalid username or
|
||||
password`) answers `409 EGITAUTH` with `{protocol, host, username?}`. The git
|
||||
bar shows a form (username, password/token, "remember for everyone on this
|
||||
AgentHub", on by default) and resends the action with
|
||||
`credentials: {protocol, host, username, password, remember}`. The server
|
||||
accepts it only for a host that is one of the repo's remotes, runs the action,
|
||||
and saves the credential only after git succeeded and `remember` is set.
|
||||
|
||||
**Limits** — one credential per host, so everyone on one AgentHub pushes as
|
||||
the same account. `fetch --all` over remotes on several hosts: a host with no
|
||||
AgentHub credential gets no machine helper either while one does. Over plain
|
||||
HTTP on a LAN the token crosses the network unencrypted; the form says so.
|
||||
|
||||
## Testing
|
||||
|
||||
Store: save/list/remove, 0600, list never carries the password, bad host
|
||||
refused. Helper: real `git credential fill` through our helper, stored and
|
||||
once. Route: a local HTTP remote answering 401 → `EGITAUTH` with the host; a
|
||||
resend with credentials reaches that server as Basic auth; nothing saved on
|
||||
failure. Client: the form appears on `EGITAUTH`, resends, never renders a token.
|
||||
+50
-11
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 1.8.5
|
||||
version: 1.8.9
|
||||
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ĩ
|
||||
@@ -10636,7 +10636,7 @@ paths:
|
||||
tags:
|
||||
- Lanes
|
||||
summary: Find or adopt the lane owning a working directory
|
||||
description: 'Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner''s lane.'
|
||||
description: 'Idempotent: canonicalizes `cwd` to its real path when it exists (the same path `createLane` stores), then returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner''s lane.'
|
||||
operationId: ensureLane
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -10718,7 +10718,7 @@ paths:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
'400':
|
||||
description: sourceRepo is relative, missing, or not a git repository.
|
||||
description: 'EBADSOURCEREPO: sourceRepo is relative, missing, or not a git repository. EEMPTYREPO: it has no commits, so there is nothing to branch a worktree from (no lane row is created).'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -10807,7 +10807,7 @@ paths:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files names the files behind those counts as {path, status, added, deleted} records, capped at 20; the counts themselves are never capped. status is the two-character `git status --porcelain` code and path is the new path for a rename; added/deleted come from one `git diff --numstat HEAD` and are null where git has no delta to report (untracked, binary, renamed, or no commit to diff against).
|
||||
description: available:true with {branch, head, unborn, subject, dirty, untracked, files, hasRemote, upstream, ahead, behind}, or available:false alone. A repo with no commit yet is available with unborn:true, head:null and subject:"". files names the files behind those counts as {path, status, added, deleted} records, capped at 20; the counts themselves are never capped. status is the two-character `git status --porcelain` code and path is the new path for a rename; added/deleted come from one `git diff --numstat HEAD` and are null where git has no delta to report (untracked, binary, renamed, or no commit to diff against).
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/runtime:
|
||||
@@ -11043,7 +11043,7 @@ paths:
|
||||
tags:
|
||||
- Lanes
|
||||
summary: The lane's recent commits
|
||||
description: Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path. Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.
|
||||
description: Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path (relative, no `..`, else 400 EBADPATH). Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.
|
||||
operationId: listLaneCommits
|
||||
parameters:
|
||||
- name: id
|
||||
@@ -11064,6 +11064,8 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: '{available, commits:[{sha, short, subject, author, date, refs}]}.'
|
||||
'400':
|
||||
description: EBADPATH (file= is absolute or has a `..` segment).
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/git/file:
|
||||
@@ -11093,7 +11095,9 @@ paths:
|
||||
'200':
|
||||
description: '{available, path, rev, binary, truncated, lines}.'
|
||||
'400':
|
||||
description: EBADPATH, EBADREV or ENOFILE.
|
||||
description: EBADPATH (incl. a symlink resolving outside the lane), EBADREV or ENOFILE.
|
||||
'403':
|
||||
description: ESECRET — `.claude/settings.local.json` (the AI Router key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/git/ls:
|
||||
@@ -11143,6 +11147,24 @@ paths:
|
||||
description: EBADPATH (the path escapes the lane).
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/git/tree-all:
|
||||
get:
|
||||
tags:
|
||||
- Lanes
|
||||
summary: Every level of the tracked tree in one read
|
||||
description: 'The file explorer''s expand-all-folders read: ONE git ls-files, grouped into levels keyed by directory ("" is the root), each level shaped and capped like GET /api/lanes/{id}/git/tree''s entries. Read-only, no same-origin guard.'
|
||||
operationId: listLaneTreeAll
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: '{available, levels:{<dir>:[{name, path, dir}]}, truncated}.'
|
||||
'404':
|
||||
description: Lane not found.
|
||||
/api/lanes/{id}/git/branches:
|
||||
get:
|
||||
tags:
|
||||
@@ -11166,7 +11188,7 @@ paths:
|
||||
tags:
|
||||
- Lanes
|
||||
summary: One git write on the lane's working copy
|
||||
description: 'fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u origin HEAD, so only the branch the lane is on), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), commit (git add -A then commit -m <message>, or only:"staged" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent''s half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per lane under the lane lock. Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.'
|
||||
description: 'fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <remote> HEAD, so only the branch the lane is on; remote = the branch''s upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file; staging a conflicted file that still has conflict markers is 400 EMARKERS), discard (per file; irreversible, so it REQUIRES confirm:true), stage-hunk / discard-hunk (ONE hunk of a tracked file''s `git diff HEAD`, named by its `@@` line and re-read server-side at the given context — the applied patch is always git''s own text, never one from the request; discard-hunk REQUIRES confirm:true; 400 EUNTRACKED for an untracked file, 409 EHUNKGONE when the hunk is no longer in the diff), commit (git add -A then commit -m <message>, or only:"staged" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent''s half-finished work is never carried onto another branch), branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree), publish (the ONE write whose destination comes from the request: `git remote add origin -- <url>` then `git push -u origin HEAD`, only in a repo with a commit and NO remote — it never re-points one; the url must be https:// without userinfo, ssh:// or scp-like user@host:path, and origin is removed again if the push fails), and merge / resolve / merge-commit / merge-abort (issue #45: merge a local or remote-tracking branch that exists — clean tree and a repo-root lane only, 400 ESUBDIR otherwise — answering conflicts:[paths] when it stops on conflicts; resolve {path, side: ours|theirs|done} a conflicted path, done refusing leftover markers with 400 EMARKERS; merge-commit is 409 ECONFLICTS while anything is unmerged; merge-abort REQUIRES confirm:true; pull/switch/merge/hunk actions are 409 EMERGING mid-merge). fetch/pull/push/publish needing a login answer 409 EGITAUTH with auth:{protocol,host,username,rejected}; resend with credentials:{protocol,host,username,password,remember} (a repo remote''s host only, else 400 EBADHOST). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per git index (the lane lock keyed on the worktree''s git dir, so two lanes sharing one index cannot collide on index.lock). Deliberately no rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.'
|
||||
operationId: runLaneGitAction
|
||||
parameters:
|
||||
- name: id
|
||||
@@ -11186,10 +11208,13 @@ paths:
|
||||
- stage
|
||||
- unstage
|
||||
- discard
|
||||
- stage-hunk
|
||||
- discard-hunk
|
||||
- commit
|
||||
- switch
|
||||
- branch-create
|
||||
- branch-delete
|
||||
- publish
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
@@ -11197,6 +11222,9 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
description: publish only; https:// (no userinfo), ssh:// or user@host:path, max 1024 chars.
|
||||
message:
|
||||
type: string
|
||||
description: commit only; required, max 4000 chars.
|
||||
@@ -11219,18 +11247,29 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
description: stage, unstage, discard; each must be a path git reports changed.
|
||||
path:
|
||||
type: string
|
||||
description: stage-hunk, discard-hunk; a path git reports changed (not untracked).
|
||||
hunk:
|
||||
type: string
|
||||
description: stage-hunk, discard-hunk; the hunk's `@@` line as the diff returned it. The server re-reads the diff and applies git's own text of that hunk; a hunk no longer present is 409 EHUNKGONE.
|
||||
context:
|
||||
type: integer
|
||||
description: stage-hunk, discard-hunk; the context the hunk was read at.
|
||||
confirm:
|
||||
type: boolean
|
||||
description: discard only; the call is refused without it.
|
||||
description: discard and discard-hunk; the call is refused without it.
|
||||
responses:
|
||||
'200':
|
||||
description: '{action, ok:true, output} — git''s own stdout+stderr.'
|
||||
description: '{action, ok:true, output} — git''s own stdout+stderr; publish adds remote:"origin".'
|
||||
'400':
|
||||
description: EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY or ENOCHANGES.
|
||||
description: EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY, ENOCHANGES, ESTAGEDOUTSIDE, ENOREMOTE, EUNTRACKED, EBADURL, EREMOTEEXISTS, ENOCOMMITS, EBADHOST, EBADCRED, ESUBDIR, EBADSIDE or EMARKERS.
|
||||
'404':
|
||||
description: Lane not found.
|
||||
'409':
|
||||
description: EGIT — git ran and refused (a rejected push, a diverged --ff-only pull); its stderr is appended to error.message. EGITAUTH — the remote wants a login (carries auth). EHUNKGONE, EMERGING, ENOMERGE, ECONFLICTS.
|
||||
'500':
|
||||
description: git failed; its stderr is appended to error.message.
|
||||
description: Unexpected server-side failure.
|
||||
/api/lanes/{id}/{action}:
|
||||
post:
|
||||
tags:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.8.5",
|
||||
"version": "1.8.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "1.8.5",
|
||||
"version": "1.8.9",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "1.8.5",
|
||||
"version": "1.8.9",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
@@ -62,8 +62,8 @@
|
||||
"import-history": "node scripts/import-history.js",
|
||||
"reconcile-tokens": "node scripts/import-history.js --reconcile-tokens",
|
||||
"clear-data": "node scripts/clear-data.js",
|
||||
"test": "node --test server/__tests__/*.test.js && cd client && npm test",
|
||||
"test:server": "node --test server/__tests__/*.test.js",
|
||||
"test": "node scripts/test-server.js && cd client && npm test",
|
||||
"test:server": "node scripts/test-server.js",
|
||||
"test:client": "cd client && npm test",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
|
||||
@@ -165,7 +165,7 @@ function reloadPanes() {
|
||||
try {
|
||||
paneCmd = execFileSync(
|
||||
"tmux",
|
||||
["display-message", "-p", "-t", name, "#{pane_current_command}"],
|
||||
["display-message", "-p", "-t", `=${name}:`, "#{pane_current_command}"],
|
||||
{ encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "pipe"] }
|
||||
).trim();
|
||||
} catch {
|
||||
@@ -184,8 +184,8 @@ function reloadPanes() {
|
||||
// detached and serves no requests.
|
||||
if (!first) execFileSync("sleep", ["1"], { timeout: 15_000 });
|
||||
first = false;
|
||||
execFileSync("tmux", ["send-keys", "-t", name, "-l", command], { timeout: 15_000 });
|
||||
execFileSync("tmux", ["send-keys", "-t", name, "Enter"], { timeout: 15_000 });
|
||||
execFileSync("tmux", ["send-keys", "-t", `=${name}:`, "-l", command], { timeout: 15_000 });
|
||||
execFileSync("tmux", ["send-keys", "-t", `=${name}:`, "Enter"], { timeout: 15_000 });
|
||||
}
|
||||
reloaded.push(name);
|
||||
} catch {
|
||||
|
||||
@@ -359,13 +359,15 @@ function cliDir() {
|
||||
*
|
||||
* @returns {"written"|"exists"|"foreign"|"failed"}
|
||||
*/
|
||||
function linkCli(dir = cliDir(), root = PLUGIN_ROOT) {
|
||||
function linkCli(dir = cliDir(), root = PLUGIN_ROOT, nodePath = process.execPath) {
|
||||
const marker = "# agenthub-plugin-launcher";
|
||||
const target = path.join(dir, process.platform === "win32" ? "agenthub.cmd" : "agenthub");
|
||||
const body =
|
||||
process.platform === "win32"
|
||||
? `@rem agenthub-plugin-launcher\r\n@node "${path.join(root, "bin", "agenthub.js")}" %*\r\n`
|
||||
: `#!/bin/sh\n${marker}\nexec "${process.execPath}" "${path.join(root, "bin", "agenthub.js")}" "$@"\n`;
|
||||
: // The pinned node is a versioned path (Homebrew Cellar, nvm) that an
|
||||
// upgrade deletes; fall back to whatever `node` PATH has then.
|
||||
`#!/bin/sh\n${marker}\nN="${nodePath}"; [ -x "$N" ] || N=node\nexec "$N" "${path.join(root, "bin", "agenthub.js")}" "$@"\n`;
|
||||
try {
|
||||
if (fs.existsSync(target)) {
|
||||
const current = fs.readFileSync(target, "utf8");
|
||||
@@ -419,7 +421,13 @@ function liveServers() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(getServerInfoPath(), "utf8"));
|
||||
const servers = Array.isArray(parsed.servers) ? parsed.servers : [parsed];
|
||||
return servers.filter((s) => s && isPidAlive(s.pid));
|
||||
// An entry older than this boot is a leftover whose pid may now belong to
|
||||
// an unrelated process (macOS has no systemd to clean up after a reboot).
|
||||
// 60 s margin for clock skew; an unparseable startedAt is kept.
|
||||
// ponytail: an NTP step larger than 60 s after boot misreads a live entry
|
||||
// as stale; compare against a monotonic boot id if that ever bites.
|
||||
const bootedAt = Date.now() - os.uptime() * 1000 - 60_000;
|
||||
return servers.filter((s) => s && isPidAlive(s.pid) && !(Date.parse(s.startedAt) < bootedAt));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -316,7 +316,9 @@ function cliStatus() {
|
||||
: [
|
||||
"warn",
|
||||
"agenthub CLI",
|
||||
`${file} exists but ${dir} is not on PATH — add: export PATH="${dir}:$PATH"`,
|
||||
`${file} exists but ${dir} is not on PATH — add${
|
||||
process.platform === "darwin" ? " to ~/.zshrc" : ""
|
||||
}: export PATH="${dir}:$PATH"`,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file test-server.js
|
||||
* @description Runs the server test suite against a PRIVATE tmux server.
|
||||
* Several suites start real `agenthub-lane-<id>` tmux sessions and then kill
|
||||
* or reset them, with lane ids from their own throwaway database — on the
|
||||
* user's default tmux server those names collide with the dashboard's real
|
||||
* lane panes, and a test run kills whichever live session shares an id
|
||||
* (including the pane the test was launched from). Dropping `TMUX` and
|
||||
* pointing `TMUX_TMPDIR` at a fresh directory gives every tmux call — the
|
||||
* app's own and the tests' direct `execFileSync("tmux", …)` — its own socket.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
const dir = path.join(root, "server", "__tests__");
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => f.endsWith(".test.js"))
|
||||
.map((f) => path.join(dir, f));
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const tmuxDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-test-tmux-"));
|
||||
const env = { ...process.env, TMUX_TMPDIR: tmuxDir };
|
||||
delete env.TMUX;
|
||||
delete env.TMUX_PANE;
|
||||
|
||||
const result = spawnSync(process.execPath, ["--test", ...args, ...(args.length ? [] : files)], {
|
||||
stdio: "inherit",
|
||||
env,
|
||||
cwd: root,
|
||||
});
|
||||
|
||||
// Whatever a test leaked dies with the private server, never a real pane.
|
||||
spawnSync("tmux", ["kill-server"], { env, stdio: "ignore" });
|
||||
fs.rmSync(tmuxDir, { recursive: true, force: true });
|
||||
process.exit(result.status ?? 1);
|
||||
+8
-3
@@ -524,14 +524,18 @@ Request body shape:
|
||||
| `POST` | `/api/lanes/:id/down` | Same-origin guarded stack teardown: recorded pid trees, then a port backstop only when a pid file existed. |
|
||||
| `POST` | `/api/lanes/:id/hook/:name` | Same-origin guarded profile-hook run; `:name` must be in the fixed hook allowlist and `args` is passed as argv. Returns `202`. |
|
||||
| `GET` | `/api/lanes/:id/logs/:svc` | Tail a hook or service log, `realpath`-confined to the lane's log directory. |
|
||||
| `GET` | `/api/lanes/:id/git` | Working-copy facts: branch, head, subject, uncommitted counts and files, plus `hasRemote`/`upstream`/`ahead`/`behind` (null counts when the branch has no upstream). `{available:false}` with `200` for a cwd git cannot read. |
|
||||
| `GET` | `/api/lanes/:id/git` | Working-copy facts: branch, head, subject, uncommitted counts and files, plus `hasRemote`/`upstream`/`ahead`/`behind` (null counts when the branch has no upstream), and `unborn` (`true` with `head: null` in a repo with no commit yet). `{available:false}` with `200` for a cwd git cannot read. |
|
||||
| `GET` | `/api/lanes/:id/git/changes?range=work\|branch\|commit[&sha=][&path=][&full=1][&context=]` | The lane's code changes for the Changes panel: a file summary, one file's hunks (`path`), or every file's hunks (`full=1`); `range=commit&sha=` is one commit, `context=` (default 3, max 5000) widens each hunk. Capped at 300 files / 200 KB per file / 1 MB per full read. `400 ENOBASE`, `ENOFILECHANGE`, `EBADRANGE` or `EBADREV`. |
|
||||
| `GET` | `/api/lanes/:id/git/log?limit=&file=` | The lane's recent commits (newest first, limit max 200), optionally only those touching one path. |
|
||||
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the lane), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
|
||||
| `PUT` | `/api/lanes/:id/git/file` | Same-origin guarded save from the whole-file editor: `{path, content, baseSha}`. An existing text file within the 1 MB cap, over exactly the version opened (`baseSha` = the read's `sha`, else `409 ESTALE`); `403 ESECRET`, `400 EBADPATH`/`ENOFILE`/`EBADCONTENT`/`ETOOBIG`/`EBINARY`. |
|
||||
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
|
||||
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked files, folded. `400 EBADPATH` for a path escaping the lane. |
|
||||
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. `400 EBADPATH` for a path escaping the lane. |
|
||||
| `GET` | `/api/lanes/:id/git/branches` | The lane repo's own local branches + worktrees, for the branch switcher. The repo comes from the lane row, never the query. |
|
||||
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) and `branch-delete` (`-d`, refuses protected/current/busy branches). No merge/rebase/reset/stash by design. |
|
||||
| `GET` | `/api/git-credentials` | AgentHub's own git logins (one per host, shared by every user), `hasToken` only — never a password. |
|
||||
| `POST` | `/api/git-credentials` | Same-origin guarded save/replace: `{protocol, host, username, password}`. `400 EBADHOST`/`EBADCRED`. |
|
||||
| `DELETE` | `/api/git-credentials/:protocol/:host` | Same-origin guarded delete. `404 ENOTFOUND`. |
|
||||
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root lane, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`); `EMERGING` blocks pull/switch/merge/hunk actions mid-merge. No rebase/reset/stash by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
|
||||
|
||||
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
|
||||
an existing absolute git repository. The response lane starts as
|
||||
@@ -702,6 +706,7 @@ HTTP surface for the tmux-backed terminal run: one session per lane, named `agen
|
||||
| `GET` | `/api/run/binary[?tool=]` | Probe whether the CLI is on `PATH`. `?tool=codex` probes Codex; omitted or `?tool=claude` probes Claude; anything else is `400 EBADTOOL`. Returns `{ found, path }` |
|
||||
| `GET` | `/api/run/tmux` | `{ available }` — whether the `tmux` binary exists |
|
||||
| `GET` | `/api/run/cwds` | Suggested cwds (dashboard, home, recent from sessions) |
|
||||
| `POST` | `/api/run/drop-image` | An image dropped on the Workspace terminal. Raw body, `Content-Type` one of `image/png\|jpeg\|gif\|webp`, ≤20 MB. Saved `0600` under `<tmpdir>/agenthub-drops-<uid>/` with a RANDOM name (nothing from the request names the file); files older than a day are pruned on each write. Answers `{ path }`, which `TerminalView` pastes into the pane — what a local terminal does on a drop, and how `claude` attaches the image. 400 for any other type |
|
||||
| `GET` | `/api/run/files?cwd=…&q=…` | Fuzzy file search inside `cwd` for the run panel's `@`-file autocomplete. Skips `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc. Cwd is required and must exist; results are capped and ranked by basename match |
|
||||
| `POST` | `/api/run` | Create-or-adopt the lane's tmux session. Body: `{ laneId, cwd, initialPrompt?, model?, permissionMode?, effort?, resumeSessionId? }`. The CLI comes from the lane row, never from the body. An existing session whose pane sits at a shell prompt gets the argv typed into it; a pane already running a program is adopted unchanged |
|
||||
| `GET` | `/api/run/:id` | One run handle, or 404 once its tmux session is gone |
|
||||
|
||||
@@ -43,13 +43,13 @@ function stubTmux(sessions, panes = {}) {
|
||||
calls.push(args);
|
||||
if (args[0] === "list-sessions") return sessions.join("\n");
|
||||
if (args[0] === "has-session") {
|
||||
if (sessions.includes(args[2])) return "";
|
||||
if (sessions.includes(args[2].slice(1))) return "";
|
||||
const e = new Error("no such session");
|
||||
e.status = 1;
|
||||
throw e;
|
||||
}
|
||||
if (args[0] === "display-message") {
|
||||
const name = args[3];
|
||||
const name = args[3].slice(1, -1);
|
||||
const cmd = panes[name] ?? "claude";
|
||||
if (cmd === null) {
|
||||
const e = new Error("pane gone");
|
||||
@@ -59,7 +59,7 @@ function stubTmux(sessions, panes = {}) {
|
||||
return `${cmd}\n`;
|
||||
}
|
||||
if (args[0] === "send-keys") {
|
||||
if (panes[args[2]] === "__send-fails__") throw new Error("pane died mid-send");
|
||||
if (panes[args[2].slice(1, -1)] === "__send-fails__") throw new Error("pane died mid-send");
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @file Tests that the server drops the variables describing the Claude Code
|
||||
* session that started it (always the case on macOS, where there is no
|
||||
* systemd unit to give it a clean env) — tmux and every lane pane inherit the
|
||||
* server's env, so a leaked CLAUDECODE made each pane look like a nested
|
||||
* session. User configuration (CLAUDE_CONFIG_DIR, ANTHROPIC_*, …) must survive.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { it, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-envscrub-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.CLAUDE_HOME = path.join(TMP, "home");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
|
||||
process.env.CLAUDECODE = "1";
|
||||
process.env.CLAUDE_CODE_ENTRYPOINT = "cli";
|
||||
process.env.CLAUDE_CODE_SESSION_ID = "abc";
|
||||
process.env.CLAUDE_CODE_MESSAGING_SOCKET = "/tmp/x.sock";
|
||||
process.env.CLAUDE_PLUGIN_ROOT = "/some/cache/dir";
|
||||
process.env.CLAUDE_CONFIG_DIR = "/Users/x/.claude-work";
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
process.env.ANTHROPIC_BASE_URL = "https://example.invalid";
|
||||
|
||||
require("../index");
|
||||
const { db } = require("../db");
|
||||
|
||||
after(() => {
|
||||
db.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("strips the parent session's variables and keeps user configuration", () => {
|
||||
for (const k of [
|
||||
"CLAUDECODE",
|
||||
"CLAUDE_CODE_ENTRYPOINT",
|
||||
"CLAUDE_CODE_SESSION_ID",
|
||||
"CLAUDE_CODE_MESSAGING_SOCKET",
|
||||
"CLAUDE_PLUGIN_ROOT",
|
||||
]) {
|
||||
assert.equal(process.env[k], undefined, k);
|
||||
}
|
||||
assert.equal(process.env.CLAUDE_CONFIG_DIR, "/Users/x/.claude-work");
|
||||
assert.equal(process.env.CLAUDE_CODE_USE_BEDROCK, "1");
|
||||
assert.equal(process.env.ANTHROPIC_BASE_URL, "https://example.invalid");
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @file Route tests for git credentials (issue #45): /api/git-credentials
|
||||
* never returns a password and refuses a cross-origin write, and a lane's
|
||||
* fetch against a remote that wants a login answers 409 EGITAUTH with the
|
||||
* host the git bar's login form needs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-creds-route-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
||||
process.env.HOME = ROOT;
|
||||
|
||||
const { createApp } = require("../index");
|
||||
|
||||
let app;
|
||||
let remote;
|
||||
let base;
|
||||
let host;
|
||||
|
||||
function request(method, p, body, headers = {}) {
|
||||
return fetch(`${base}${p}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
remote = http.createServer((_req, res) => {
|
||||
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
|
||||
res.end();
|
||||
});
|
||||
await new Promise((r) => remote.listen(0, "127.0.0.1", r));
|
||||
host = `127.0.0.1:${remote.address().port}`;
|
||||
app = http.createServer(createApp());
|
||||
await new Promise((r) => app.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${app.address().port}`;
|
||||
});
|
||||
after(async () => {
|
||||
await new Promise((r) => app.close(r));
|
||||
await new Promise((r) => remote.close(r));
|
||||
});
|
||||
|
||||
describe("/api/git-credentials", () => {
|
||||
it("saves, lists without the secret, and deletes", async () => {
|
||||
const saved = await request("POST", "/api/git-credentials", {
|
||||
protocol: "https",
|
||||
host: "git.example.test",
|
||||
username: "vi",
|
||||
password: "super-secret-token",
|
||||
});
|
||||
assert.equal(saved.status, 200);
|
||||
const list = await request("GET", "/api/git-credentials");
|
||||
assert.equal(list.body.items[0].hasToken, true);
|
||||
assert.ok(!JSON.stringify(list.body).includes("super-secret-token"));
|
||||
const del = await request("DELETE", "/api/git-credentials/https/git.example.test");
|
||||
assert.deepEqual(del.body.items, []);
|
||||
});
|
||||
|
||||
it("refuses a cross-origin write", async () => {
|
||||
const r = await request(
|
||||
"POST",
|
||||
"/api/git-credentials",
|
||||
{ protocol: "https", host: "h.test", username: "u", password: "p" },
|
||||
{ Origin: "https://evil.example" }
|
||||
);
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/lanes/:id/git/fetch", () => {
|
||||
it("answers 409 EGITAUTH with the host when the remote wants a login", async () => {
|
||||
const repo = path.join(ROOT, "repo");
|
||||
execFileSync("git", ["init", "-q", repo]);
|
||||
execFileSync("git", ["-C", repo, "remote", "add", "origin", `http://${host}/o/r.git`]);
|
||||
const lane = await request("POST", "/api/lanes/ensure", { cwd: repo, tool: "claude" });
|
||||
const r = await request("POST", `/api/lanes/${lane.body.lane.id}/git/fetch`, {});
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.body.error.code, "EGITAUTH");
|
||||
assert.equal(r.body.error.auth.host, host);
|
||||
assert.equal(r.body.error.auth.rejected, false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @file Tests for AgentHub's own git credential store and the credential
|
||||
* helper that hands it to git: save/list/remove at 0600, a list that never
|
||||
* carries the secret, auth-failure detection, and a REAL `git credential fill`
|
||||
* run through the helper — stored and use-once.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-creds-"));
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
|
||||
const creds = require("../lib/git-credentials");
|
||||
|
||||
function fill(dir, cfg, env = {}) {
|
||||
// Exactly what git-ops passes, then git's own credential lookup.
|
||||
const out = execFileSync("git", [...cfg.args, "credential", "fill"], {
|
||||
cwd: dir,
|
||||
input: "protocol=https\nhost=git.example.test\n\n",
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...cfg.env, ...env },
|
||||
encoding: "utf8",
|
||||
});
|
||||
return Object.fromEntries(
|
||||
out
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((l) => [l.slice(0, l.indexOf("=")), l.slice(l.indexOf("=") + 1)])
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => fs.rmSync(path.join(ROOT, ".claude"), { recursive: true, force: true }));
|
||||
|
||||
describe("git credential store", () => {
|
||||
it("saves one record per protocol+host at 0600 and never lists the secret", () => {
|
||||
creds.save({ protocol: "https", host: "Git.Example.Test", username: "a", password: "t1" });
|
||||
creds.save({ protocol: "https", host: "git.example.test", username: "b", password: "t2" });
|
||||
const list = creds.list();
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0].host, "git.example.test");
|
||||
assert.equal(list[0].username, "b");
|
||||
assert.equal(list[0].hasToken, true);
|
||||
assert.ok(!JSON.stringify(list).includes("t2"));
|
||||
assert.equal(fs.statSync(creds.credentialsFile()).mode & 0o777, 0o600);
|
||||
assert.equal(creds.remove("https", "git.example.test"), true);
|
||||
assert.deepEqual(creds.list(), []);
|
||||
});
|
||||
|
||||
it("refuses a host that is not a host", () => {
|
||||
for (const host of ["", "a/b", "x y", "../etc", "h\nost"]) {
|
||||
assert.throws(() => creds.save({ protocol: "https", host, username: "u", password: "p" }), {
|
||||
code: "EBADHOST",
|
||||
});
|
||||
}
|
||||
assert.throws(() => creds.save({ protocol: "ssh", host: "h", username: "u", password: "p" }), {
|
||||
code: "EBADHOST",
|
||||
});
|
||||
});
|
||||
|
||||
it("recognises git's auth failures and names the host", () => {
|
||||
assert.deepEqual(
|
||||
creds.authFailure(
|
||||
"fatal: could not read Username for 'https://git.smartgift.io.vn': terminal prompts disabled"
|
||||
),
|
||||
{ protocol: "https", host: "git.smartgift.io.vn" }
|
||||
);
|
||||
assert.deepEqual(
|
||||
creds.authFailure(
|
||||
"remote: Invalid username or password.\nfatal: Authentication failed for 'http://h.local:3000/o/r.git/'"
|
||||
),
|
||||
{ protocol: "http", host: "h.local:3000" }
|
||||
);
|
||||
assert.equal(creds.authFailure("! [rejected] main -> main (fetch first)"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("git credential helper, through real git", () => {
|
||||
const repo = fs.mkdtempSync(path.join(ROOT, "repo-"));
|
||||
execFileSync("git", ["init", "-q", repo]);
|
||||
execFileSync("git", ["-C", repo, "remote", "add", "origin", "https://git.example.test/o/r.git"]);
|
||||
|
||||
it("answers from the store, and only our helper is asked", async () => {
|
||||
creds.save({ protocol: "https", host: "git.example.test", username: "u1", password: "stored" });
|
||||
const cfg = await creds.gitConfigFor(repo);
|
||||
assert.equal(cfg.args[1], "credential.helper=");
|
||||
const got = fill(repo, cfg);
|
||||
assert.equal(got.username, "u1");
|
||||
assert.equal(got.password, "stored");
|
||||
assert.ok(!cfg.args.join(" ").includes("stored"), "secret must not reach argv");
|
||||
});
|
||||
|
||||
it("prefers a use-once credential, which never touches the store", async () => {
|
||||
creds.save({ protocol: "https", host: "git.example.test", username: "u1", password: "stored" });
|
||||
const once = { protocol: "https", host: "git.example.test", username: "u2", password: "once" };
|
||||
const cfg = await creds.gitConfigFor(repo, once);
|
||||
const got = fill(repo, cfg);
|
||||
assert.equal(got.password, "once");
|
||||
assert.equal(creds.list()[0].username, "u1");
|
||||
assert.ok(!cfg.args.join(" ").includes("once"));
|
||||
});
|
||||
|
||||
it("changes nothing when AgentHub holds nothing for the repo's hosts", async () => {
|
||||
const cfg = await creds.gitConfigFor(repo);
|
||||
assert.deepEqual(cfg, { args: [], env: {} });
|
||||
});
|
||||
|
||||
it("refuses a use-once credential for a host the repo does not use", async () => {
|
||||
await assert.rejects(
|
||||
creds.gitConfigFor(repo, {
|
||||
protocol: "https",
|
||||
host: "evil.test",
|
||||
username: "u",
|
||||
password: "p",
|
||||
}),
|
||||
{ code: "EBADHOST" }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,639 @@
|
||||
/**
|
||||
* @file Regression tests for the lane git surface's hardening: the file viewer
|
||||
* and untracked-file diff never read a secret file or follow a symlink out of
|
||||
* the lane, `git status` paths survive C-quoting (non-ASCII, spaces, quotes),
|
||||
* discarding a staged rename restores the original path, and untracked reads
|
||||
* are async (no fs.*Sync on the poll path). Real git fixture, like
|
||||
* git-diff-ops.test.js.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, afterEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const { collectChanges, parseNumstat, readFileAt } = require("../lib/git-diff");
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const { gitFacts } = require("../lib/worktree");
|
||||
|
||||
let ROOT;
|
||||
let LANE;
|
||||
|
||||
const g = (cwd, ...args) => {
|
||||
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
||||
delete env.GIT_DIR;
|
||||
delete env.GIT_WORK_TREE;
|
||||
delete env.GIT_INDEX_FILE;
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8", env });
|
||||
};
|
||||
|
||||
function write(dir, file, body) {
|
||||
fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, file), body);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-githarden-"));
|
||||
LANE = path.join(ROOT, "lane");
|
||||
g(ROOT, "init", "-q", "-b", "main", LANE);
|
||||
g(LANE, "config", "user.email", "t@h");
|
||||
g(LANE, "config", "user.name", "t");
|
||||
write(LANE, "a.txt", "one\ntwo\n");
|
||||
write(LANE, "old name.txt", "keep me\nplease\n");
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "init");
|
||||
write(ROOT, "outside.txt", "TOP SECRET OUTSIDE\n");
|
||||
});
|
||||
|
||||
afterEach(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
describe("readFileAt: secret files and symlink escapes", () => {
|
||||
it("refuses .claude/settings.local.json at rev=work", async () => {
|
||||
write(LANE, ".claude/settings.local.json", '{"env":{"ANTHROPIC_API_KEY":"sk-x"}}');
|
||||
for (const p of [".claude/settings.local.json", "./.claude//settings.local.json"]) {
|
||||
await assert.rejects(readFileAt(LANE, p, "work"), { code: "ESECRET" });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses the secret file at a committed revision too", async () => {
|
||||
write(LANE, ".claude/settings.local.json", '{"env":{"ANTHROPIC_API_KEY":"sk-x"}}');
|
||||
g(LANE, "add", "-f", ".claude/settings.local.json");
|
||||
g(LANE, "commit", "-qm", "oops");
|
||||
await assert.rejects(readFileAt(LANE, ".claude/settings.local.json", "HEAD"), {
|
||||
code: "ESECRET",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a symlink whose target is outside the lane", async () => {
|
||||
fs.symlinkSync(path.join(ROOT, "outside.txt"), path.join(LANE, "link.txt"));
|
||||
await assert.rejects(readFileAt(LANE, "link.txt", "work"), { code: "EBADPATH" });
|
||||
});
|
||||
|
||||
it("refuses a symlink that points at the secret file", async () => {
|
||||
write(LANE, ".claude/settings.local.json", '{"k":"sk-x"}');
|
||||
fs.symlinkSync(".claude/settings.local.json", path.join(LANE, "innocent.json"));
|
||||
await assert.rejects(readFileAt(LANE, "innocent.json", "work"), { code: "ESECRET" });
|
||||
});
|
||||
|
||||
it("still reads a symlink that stays inside the lane", async () => {
|
||||
fs.symlinkSync("a.txt", path.join(LANE, "alias.txt"));
|
||||
const out = await readFileAt(LANE, "alias.txt", "work");
|
||||
assert.deepEqual(out.lines, ["one", "two"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectChanges: untracked content reads", () => {
|
||||
it("never shows the content of an untracked symlink pointing outside the lane", async () => {
|
||||
fs.symlinkSync(path.join(ROOT, "outside.txt"), path.join(LANE, "escape.txt"));
|
||||
const out = await collectChanges(LANE, { range: "work", full: true });
|
||||
const entry = out.files.find((f) => f.path === "escape.txt");
|
||||
assert.ok(entry, "the symlink is still listed");
|
||||
assert.equal(JSON.stringify(entry).includes("TOP SECRET"), false);
|
||||
});
|
||||
|
||||
it("never shows the secret file's content in a diff", async () => {
|
||||
write(LANE, ".claude/settings.local.json", '{"k":"sk-old"}');
|
||||
g(LANE, "add", "-f", ".claude/settings.local.json");
|
||||
g(LANE, "commit", "-qm", "tracked by mistake");
|
||||
write(LANE, ".claude/settings.local.json", '{"k":"sk-leak"}');
|
||||
const out = await collectChanges(LANE, { range: "work", full: true });
|
||||
assert.equal(JSON.stringify(out).includes("sk-leak"), false);
|
||||
});
|
||||
|
||||
it("does not use synchronous fs reads", async () => {
|
||||
write(LANE, "new.txt", "x\ny\n");
|
||||
const saved = { readFileSync: fs.readFileSync, statSync: fs.statSync };
|
||||
fs.readFileSync = () => {
|
||||
throw new Error("sync read");
|
||||
};
|
||||
fs.statSync = () => {
|
||||
throw new Error("sync stat");
|
||||
};
|
||||
let out;
|
||||
try {
|
||||
out = await collectChanges(LANE, { range: "work", full: true });
|
||||
} finally {
|
||||
Object.assign(fs, saved);
|
||||
}
|
||||
const entry = out.files.find((f) => f.path === "new.txt");
|
||||
assert.equal(entry.added, 2);
|
||||
assert.equal(entry.hunks[0].lines.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("C-quoted status paths", () => {
|
||||
const NAME = 'tài liệu "mới".txt';
|
||||
|
||||
it("collectChanges reports a non-ASCII/quoted path verbatim", async () => {
|
||||
write(LANE, NAME, "xin chào\n");
|
||||
write(LANE, "a.txt", "one\ntwo\nthree\n");
|
||||
g(LANE, "mv", "old name.txt", "mới tên.txt");
|
||||
const out = await collectChanges(LANE, { range: "work" });
|
||||
const paths = out.files.map((f) => f.path);
|
||||
assert.ok(paths.includes(NAME), paths.join("|"));
|
||||
assert.ok(paths.includes("mới tên.txt"), paths.join("|"));
|
||||
assert.equal(out.files.find((f) => f.path === NAME).added, 1);
|
||||
assert.equal(out.files.find((f) => f.path === "a.txt").added, 1);
|
||||
});
|
||||
|
||||
it("stage accepts a non-ASCII/quoted path", async () => {
|
||||
write(LANE, NAME, "xin chào\n");
|
||||
await runGitAction(LANE, "stage", { paths: [NAME] });
|
||||
assert.match(g(LANE, "diff", "--cached", "--name-only", "-z"), /mới/);
|
||||
});
|
||||
|
||||
it("branch range and commit range report renamed non-ASCII paths", async () => {
|
||||
g(LANE, "checkout", "-qb", "feat");
|
||||
g(LANE, "mv", "old name.txt", "mới tên.txt");
|
||||
g(LANE, "commit", "-qm", "rename");
|
||||
const branch = await collectChanges(LANE, { range: "branch", base: "main" });
|
||||
assert.deepEqual(
|
||||
branch.files.map((f) => f.path),
|
||||
["mới tên.txt"]
|
||||
);
|
||||
const commit = await collectChanges(LANE, { range: "commit", sha: "HEAD" });
|
||||
assert.deepEqual(
|
||||
commit.files.map((f) => f.path),
|
||||
["mới tên.txt"]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("discard of a staged rename", () => {
|
||||
it("restores the original path and removes the new one", async () => {
|
||||
g(LANE, "mv", "old name.txt", "new name.txt");
|
||||
await runGitAction(LANE, "discard", { paths: ["new name.txt"], confirm: true });
|
||||
assert.equal(fs.readFileSync(path.join(LANE, "old name.txt"), "utf8"), "keep me\nplease\n");
|
||||
assert.equal(fs.existsSync(path.join(LANE, "new name.txt")), false);
|
||||
assert.equal(g(LANE, "status", "--porcelain"), "");
|
||||
});
|
||||
|
||||
it("still requires confirm: true", async () => {
|
||||
g(LANE, "mv", "old name.txt", "new name.txt");
|
||||
await assert.rejects(runGitAction(LANE, "discard", { paths: ["new name.txt"] }), {
|
||||
code: "ECONFIRM",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/lanes/:id/git/file maps a secret to 403 ESECRET", () => {
|
||||
it("answers 403 for the router's settings file", async () => {
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "api.db");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
const { createApp, startServer } = require("../index");
|
||||
const server = await startServer(createApp(), 0);
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try {
|
||||
write(LANE, ".claude/settings.local.json", '{"k":"sk-x"}');
|
||||
const created = await fetch(`${base}/api/lanes`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cwd: LANE, title: "secret" }),
|
||||
}).then((r) => r.json());
|
||||
const id = created.lane.id;
|
||||
const r = await fetch(
|
||||
`${base}/api/lanes/${id}/git/file?path=.claude/settings.local.json&rev=work`
|
||||
);
|
||||
assert.equal(r.status, 403);
|
||||
const body = await r.json();
|
||||
assert.equal(body.error.code, "ESECRET");
|
||||
assert.equal(JSON.stringify(body).includes("sk-x"), false);
|
||||
await fetch(`${base}/api/lanes/${id}`, { method: "DELETE" });
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("unstage of a staged rename", () => {
|
||||
it("unstages both halves: nothing staged, the new file left untracked, the old one deleted in the tree only", async () => {
|
||||
g(LANE, "mv", "old name.txt", "new name.txt");
|
||||
await runGitAction(LANE, "unstage", { paths: ["new name.txt"] });
|
||||
assert.equal(g(LANE, "diff", "--cached", "--name-only"), "");
|
||||
assert.equal(fs.readFileSync(path.join(LANE, "new name.txt"), "utf8"), "keep me\nplease\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readFileAt: one lexical guard for every rev", () => {
|
||||
let SUB;
|
||||
beforeEach(() => {
|
||||
write(LANE, ".claude/settings.local.json", '{"k":"sk-parent"}');
|
||||
write(LANE, "root-only.txt", "ROOT CONTENT\n");
|
||||
write(LANE, "pkg/in.txt", "inside\n");
|
||||
write(LANE, "pkg/.claude/settings.local.json", '{"k":"sk-sub"}');
|
||||
g(LANE, "add", "-f", "-A");
|
||||
g(LANE, "commit", "-qm", "sub");
|
||||
SUB = path.join(LANE, "pkg");
|
||||
});
|
||||
|
||||
it("refuses ../ and absolute paths at a committed rev in a subdirectory lane", async () => {
|
||||
for (const p of [
|
||||
"../.claude/settings.local.json",
|
||||
"./../root-only.txt",
|
||||
"x/../../root-only.txt",
|
||||
"/etc/passwd",
|
||||
]) {
|
||||
await assert.rejects(readFileAt(SUB, p, "HEAD"), { code: "EBADPATH" }, p);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads committed paths relative to the lane, not the repo root", async () => {
|
||||
const out = await readFileAt(SUB, "./in.txt", "HEAD");
|
||||
assert.deepEqual(out.lines, ["inside"]);
|
||||
assert.equal(out.path, "in.txt");
|
||||
// A repo-root path is NOT reachable by naming it without ../
|
||||
await assert.rejects(readFileAt(SUB, "root-only.txt", "HEAD"), { code: "ENOFILE" });
|
||||
});
|
||||
|
||||
it("refuses the lane's own secret file in its normalised spellings", async () => {
|
||||
for (const p of ["./.claude/settings.local.json", ".claude//settings.local.json"]) {
|
||||
await assert.rejects(readFileAt(SUB, p, "HEAD"), { code: "ESECRET" }, p);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("paths are literal, never pathspec patterns", () => {
|
||||
it("discarding an untracked 'new*.js' deletes only that file", async () => {
|
||||
for (const f of ["new*.js", "new1.js", "new2.js"]) write(LANE, f, "x\n");
|
||||
await runGitAction(LANE, "discard", { paths: ["new*.js"], confirm: true });
|
||||
assert.equal(fs.existsSync(path.join(LANE, "new*.js")), false);
|
||||
assert.equal(fs.existsSync(path.join(LANE, "new1.js")), true);
|
||||
assert.equal(fs.existsSync(path.join(LANE, "new2.js")), true);
|
||||
});
|
||||
|
||||
it("staging 'app/[id]/page.js' does not stage app/i/page.js", async () => {
|
||||
write(LANE, "app/[id]/page.js", "a\n");
|
||||
write(LANE, "app/i/page.js", "b\n");
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "app");
|
||||
write(LANE, "app/[id]/page.js", "a2\n");
|
||||
write(LANE, "app/i/page.js", "b2\n");
|
||||
await runGitAction(LANE, "stage", { paths: ["app/[id]/page.js"] });
|
||||
assert.equal(g(LANE, "diff", "--cached", "--name-only"), "app/[id]/page.js\n");
|
||||
});
|
||||
|
||||
it("a single-file diff of 'a[b].txt' carries only that file's hunks", async () => {
|
||||
write(LANE, "a[b].txt", "one\n");
|
||||
write(LANE, "ab.txt", "one\n");
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "brackets");
|
||||
write(LANE, "a[b].txt", "one\nmine\n");
|
||||
write(LANE, "ab.txt", "one\nOTHER\n");
|
||||
const out = await collectChanges(LANE, { range: "work", file: "a[b].txt" });
|
||||
assert.equal(JSON.stringify(out.files[0].hunks).includes("OTHER"), false);
|
||||
assert.equal(out.files[0].hunks.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a lane whose cwd is a subdirectory of its repo", () => {
|
||||
let SUB;
|
||||
beforeEach(() => {
|
||||
write(LANE, "pkg/in.txt", "one\n");
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "pkg");
|
||||
SUB = path.join(LANE, "pkg");
|
||||
write(LANE, "a.txt", "one\ntwo\nOUTSIDE EDIT\n");
|
||||
write(LANE, "outside-new.txt", "no\n");
|
||||
write(SUB, "in.txt", "one\ntwo\n");
|
||||
write(SUB, "fresh.txt", "a\nb\nc\n");
|
||||
});
|
||||
|
||||
it("lists only its own changes, lane-relative, with hunks", async () => {
|
||||
const out = await collectChanges(SUB, { range: "work", full: true });
|
||||
assert.deepEqual(
|
||||
out.files.map((f) => f.path),
|
||||
["fresh.txt", "in.txt"]
|
||||
);
|
||||
const tracked = out.files.find((f) => f.path === "in.txt");
|
||||
assert.equal(tracked.added, 1);
|
||||
assert.equal(tracked.hunks.length, 1);
|
||||
const fresh = out.files.find((f) => f.path === "fresh.txt");
|
||||
assert.equal(fresh.added, 3);
|
||||
assert.equal(fresh.hunks[0].lines.length, 3);
|
||||
});
|
||||
|
||||
it("branch and commit ranges are lane-relative and lane-scoped too", async () => {
|
||||
g(LANE, "checkout", "-qb", "feat");
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "both");
|
||||
const branch = await collectChanges(SUB, { range: "branch", base: "main", full: true });
|
||||
assert.deepEqual(
|
||||
branch.files.map((f) => f.path),
|
||||
["fresh.txt", "in.txt"]
|
||||
);
|
||||
assert.equal(branch.files.find((f) => f.path === "in.txt").added, 1);
|
||||
const commit = await collectChanges(SUB, { range: "commit", sha: "HEAD", file: "in.txt" });
|
||||
assert.equal(commit.files[0].hunks.length, 1);
|
||||
});
|
||||
|
||||
it("stage, unstage and discard work on lane-relative paths", async () => {
|
||||
await runGitAction(SUB, "stage", { paths: ["in.txt", "fresh.txt"] });
|
||||
assert.equal(g(LANE, "diff", "--cached", "--name-only"), "pkg/fresh.txt\npkg/in.txt\n");
|
||||
await runGitAction(SUB, "unstage", { paths: ["in.txt"] });
|
||||
assert.equal(g(LANE, "diff", "--cached", "--name-only"), "pkg/fresh.txt\n");
|
||||
await runGitAction(SUB, "discard", { paths: ["in.txt"], confirm: true });
|
||||
assert.equal(fs.readFileSync(path.join(SUB, "in.txt"), "utf8"), "one\n");
|
||||
});
|
||||
|
||||
it("cannot name a change outside its cwd", async () => {
|
||||
for (const p of ["../a.txt", "a.txt", "../outside-new.txt"]) {
|
||||
await assert.rejects(
|
||||
runGitAction(SUB, "discard", { paths: [p], confirm: true }),
|
||||
{ code: "EBADPATH" },
|
||||
p
|
||||
);
|
||||
}
|
||||
assert.equal(fs.existsSync(path.join(LANE, "outside-new.txt")), true);
|
||||
});
|
||||
|
||||
it("gitFacts (the lane card) counts and lists only the lane's own files, cwd-relative", async () => {
|
||||
write(SUB, "mới tên.txt", "x\n");
|
||||
const facts = await gitFacts(SUB);
|
||||
const paths = facts.files.map((f) => f.path).sort();
|
||||
assert.deepEqual(paths, ["fresh.txt", "in.txt", "mới tên.txt"]);
|
||||
assert.equal(facts.dirty, 1);
|
||||
assert.equal(facts.untracked, 2);
|
||||
assert.equal(facts.files.find((f) => f.path === "in.txt").added, 1);
|
||||
});
|
||||
|
||||
it("commit sweeps in only the lane's own files", async () => {
|
||||
await runGitAction(SUB, "commit", { message: "lane only" });
|
||||
assert.equal(
|
||||
g(LANE, "show", "--name-only", "--format=", "HEAD"),
|
||||
"pkg/fresh.txt\npkg/in.txt\n"
|
||||
);
|
||||
assert.match(g(LANE, "status", "--porcelain"), /a\.txt/);
|
||||
});
|
||||
|
||||
it("refuses to commit while something OUTSIDE the lane is staged, and leaves it staged", async () => {
|
||||
const before = g(LANE, "rev-parse", "HEAD");
|
||||
g(LANE, "add", "a.txt");
|
||||
for (const only of [undefined, "staged"]) {
|
||||
g(SUB, "add", "in.txt");
|
||||
await assert.rejects(runGitAction(SUB, "commit", { message: "lane only", only }), {
|
||||
code: "ESTAGEDOUTSIDE",
|
||||
});
|
||||
}
|
||||
assert.match(g(LANE, "diff", "--cached", "--name-only"), /^a\.txt$/m);
|
||||
assert.equal(g(LANE, "rev-parse", "HEAD"), before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reads larger than the git() buffer report truncated, not missing", () => {
|
||||
const BIG = `${"x".repeat(99)}\n`.repeat(100_000); // 10 MB
|
||||
|
||||
it("readFileAt at a rev", async () => {
|
||||
write(LANE, "big.txt", BIG);
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "big");
|
||||
const out = await readFileAt(LANE, "big.txt", "HEAD");
|
||||
assert.equal(out.truncated, true);
|
||||
assert.ok(out.lines.length > 1000);
|
||||
});
|
||||
|
||||
it("a single-file diff", async () => {
|
||||
write(LANE, "big.txt", "seed\n");
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "seed");
|
||||
write(LANE, "big.txt", BIG);
|
||||
const out = await collectChanges(LANE, { range: "work", file: "big.txt" });
|
||||
assert.equal(out.files[0].truncated, true);
|
||||
assert.ok(out.files[0].hunks[0].lines.length > 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loop 2: secret file at any depth, symlinked .claude", () => {
|
||||
const KEY = '{"env":{"ANTHROPIC_API_KEY":"sk-deep"}}';
|
||||
|
||||
it("a root lane cannot read a subdir lane's settings.local.json (work, rev, hunks)", async () => {
|
||||
write(LANE, "sub/.claude/settings.local.json", KEY);
|
||||
for (const p of ["sub/.claude/settings.local.json", "SUB/.Claude/Settings.Local.json"]) {
|
||||
await assert.rejects(readFileAt(LANE, p, "work"), { code: "ESECRET" }, p);
|
||||
}
|
||||
const untracked = await collectChanges(LANE, { range: "work", full: true });
|
||||
assert.equal(JSON.stringify(untracked).includes("sk-deep"), false);
|
||||
g(LANE, "add", "-f", "sub/.claude/settings.local.json");
|
||||
g(LANE, "commit", "-qm", "oops");
|
||||
await assert.rejects(readFileAt(LANE, "sub/.claude/settings.local.json", "HEAD"), {
|
||||
code: "ESECRET",
|
||||
});
|
||||
write(LANE, "sub/.claude/settings.local.json", KEY.replace("deep", "deep2"));
|
||||
const tracked = await collectChanges(LANE, { range: "work", full: true });
|
||||
assert.equal(JSON.stringify(tracked).includes("sk-deep"), false);
|
||||
const commit = await collectChanges(LANE, { range: "commit", sha: "HEAD", full: true });
|
||||
assert.equal(JSON.stringify(commit).includes("sk-deep"), false);
|
||||
});
|
||||
|
||||
it("a symlinked .claude does not expose the key under its target's name", async () => {
|
||||
write(LANE, "conf/settings.local.json", KEY);
|
||||
fs.symlinkSync("conf", path.join(LANE, ".claude"));
|
||||
await assert.rejects(readFileAt(LANE, "conf/settings.local.json", "work"), {
|
||||
code: "ESECRET",
|
||||
});
|
||||
const untracked = await collectChanges(LANE, { range: "work", full: true });
|
||||
assert.equal(JSON.stringify(untracked).includes("sk-deep"), false);
|
||||
g(LANE, "add", "conf/settings.local.json");
|
||||
g(LANE, "commit", "-qm", "conf");
|
||||
await assert.rejects(readFileAt(LANE, "conf/settings.local.json", "HEAD"), {
|
||||
code: "ESECRET",
|
||||
});
|
||||
write(LANE, "conf/settings.local.json", KEY.replace("deep", "deep2"));
|
||||
const tracked = await collectChanges(LANE, { range: "work", full: true });
|
||||
assert.equal(JSON.stringify(tracked).includes("sk-deep"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loop 2: subdir lane boundaries", () => {
|
||||
let SUB;
|
||||
beforeEach(() => {
|
||||
write(LANE, "pkg/in.txt", "one\n");
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "pkg");
|
||||
SUB = path.join(LANE, "pkg");
|
||||
});
|
||||
|
||||
it("listCommits refuses a ?file= outside the lane", async () => {
|
||||
const { listCommits } = require("../lib/git-diff");
|
||||
for (const p of ["../a.txt", "/etc/passwd"]) {
|
||||
await assert.rejects(listCommits(SUB, { file: p }), { code: "EBADPATH" }, p);
|
||||
}
|
||||
assert.equal((await listCommits(SUB, { file: "in.txt" })).length, 1);
|
||||
});
|
||||
|
||||
it("commit (all) in a clean subdir lane of a dirty repo is ENOCHANGES", async () => {
|
||||
write(LANE, "a.txt", "dirty outside\n");
|
||||
await assert.rejects(runGitAction(SUB, "commit", { message: "x" }), { code: "ENOCHANGES" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("loop 2: readFileAt rev=work reads at most the cap", () => {
|
||||
it("never loads a whole huge file, still reports truncated", async () => {
|
||||
write(LANE, "huge.txt", `${"y".repeat(99)}\n`.repeat(30_000)); // 3 MB
|
||||
const orig = fs.promises.readFile;
|
||||
fs.promises.readFile = async () => {
|
||||
throw new Error("whole-file read");
|
||||
};
|
||||
try {
|
||||
const out = await readFileAt(LANE, "huge.txt", "work");
|
||||
assert.equal(out.truncated, true);
|
||||
assert.ok(out.lines.length > 1000);
|
||||
assert.ok(out.lines.join("\n").length <= 1_000_000);
|
||||
} finally {
|
||||
fs.promises.readFile = orig;
|
||||
}
|
||||
const small = await readFileAt(LANE, "a.txt", "work");
|
||||
assert.deepEqual(small.lines, ["one", "two"]);
|
||||
assert.equal(small.truncated, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loop 2: push picks the right remote", () => {
|
||||
const bare = (name) => {
|
||||
const dir = path.join(ROOT, `${name}.git`);
|
||||
g(ROOT, "init", "-q", "--bare", dir);
|
||||
return dir;
|
||||
};
|
||||
const head = () => g(LANE, "rev-parse", "HEAD").trim();
|
||||
|
||||
it("pushes to the sole remote even when it is not origin", async () => {
|
||||
const dir = bare("only");
|
||||
g(LANE, "remote", "add", "backup", dir);
|
||||
await runGitAction(LANE, "push");
|
||||
assert.equal(g(dir, "rev-parse", "main").trim(), head());
|
||||
});
|
||||
|
||||
it("pushes to the branch's upstream remote over origin", async () => {
|
||||
const o = bare("o");
|
||||
const u = bare("u");
|
||||
g(LANE, "remote", "add", "origin", o);
|
||||
g(LANE, "remote", "add", "fork", u);
|
||||
g(LANE, "push", "-q", "-u", "fork", "HEAD");
|
||||
write(LANE, "a.txt", "new\n");
|
||||
g(LANE, "commit", "-qam", "next");
|
||||
await runGitAction(LANE, "push");
|
||||
assert.equal(g(u, "rev-parse", "main").trim(), head());
|
||||
assert.throws(() => g(o, "rev-parse", "--verify", "-q", "main"));
|
||||
});
|
||||
|
||||
it("falls back to origin among several remotes", async () => {
|
||||
const o = bare("o");
|
||||
g(LANE, "remote", "add", "fork", bare("f"));
|
||||
g(LANE, "remote", "add", "origin", o);
|
||||
await runGitAction(LANE, "push");
|
||||
assert.equal(g(o, "rev-parse", "main").trim(), head());
|
||||
});
|
||||
|
||||
it("ENOREMOTE with no remote, or several and no origin", async () => {
|
||||
await assert.rejects(runGitAction(LANE, "push"), { code: "ENOREMOTE" });
|
||||
g(LANE, "remote", "add", "a", bare("a"));
|
||||
g(LANE, "remote", "add", "b", bare("b"));
|
||||
await assert.rejects(runGitAction(LANE, "push"), { code: "ENOREMOTE" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("loop 2: a claude and a codex lane on one cwd share one git-write lock", () => {
|
||||
it("concurrent writes from both lanes never collide on index.lock", async () => {
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "api2.db");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
const { createApp, startServer } = require("../index");
|
||||
const server = await startServer(createApp(), 0);
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const post = (url, body) =>
|
||||
fetch(`${base}${url}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
try {
|
||||
const ids = [];
|
||||
for (const tool of ["claude", "codex"]) {
|
||||
const r = await post("/api/lanes/ensure", { cwd: LANE, tool, title: tool }).then((x) =>
|
||||
x.json()
|
||||
);
|
||||
ids.push(r.lane.id);
|
||||
}
|
||||
assert.notEqual(ids[0], ids[1]);
|
||||
// A clean filter runs while `git add` HOLDS index.lock, so a write
|
||||
// from the sibling lane in that window fails unless serialized.
|
||||
g(LANE, "config", "filter.slow.clean", "sleep 1; cat");
|
||||
write(LANE, ".gitattributes", "slow.txt filter=slow\n");
|
||||
write(LANE, "slow.txt", "s\n");
|
||||
write(LANE, "b.txt", "new\n");
|
||||
const slow = post(`/api/lanes/${ids[0]}/git/stage`, { paths: ["slow.txt"] });
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const other = await post(`/api/lanes/${ids[1]}/git/stage`, { paths: ["b.txt"] });
|
||||
const first = await slow;
|
||||
assert.equal(first.status, 200, await first.text());
|
||||
assert.equal(other.status, 200, await other.text());
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("loop 3: a subdirectory lane's key behind its own symlinked .claude", () => {
|
||||
it("is refused to a root lane by its target name, on disk and at a committed rev", async () => {
|
||||
write(LANE, "sub/conf/settings.local.json", '{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-SUB"}}');
|
||||
fs.symlinkSync("conf", path.join(LANE, "sub/.claude"));
|
||||
await assert.rejects(readFileAt(LANE, "sub/conf/settings.local.json", "work"), {
|
||||
code: "ESECRET",
|
||||
});
|
||||
g(LANE, "add", "-f", "sub/conf/settings.local.json");
|
||||
g(LANE, "commit", "-qm", "oops");
|
||||
fs.rmSync(path.join(LANE, "sub"), { recursive: true });
|
||||
await assert.rejects(readFileAt(LANE, "sub/conf/settings.local.json", "HEAD"), {
|
||||
code: "ESECRET",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("loop 3: a staged rename INTO a subdirectory lane", () => {
|
||||
it("is refused by ESTAGEDOUTSIDE, since it also deletes the old path outside the lane", async () => {
|
||||
write(LANE, "shared/x.js", "export const x = 1;\n");
|
||||
write(LANE, "pkg/keep.txt", "k\n");
|
||||
g(LANE, "add", "-A");
|
||||
g(LANE, "commit", "-qm", "base");
|
||||
g(LANE, "mv", "shared/x.js", "pkg/x.js");
|
||||
await assert.rejects(
|
||||
runGitAction(path.join(LANE, "pkg"), "commit", { message: "move", only: "staged" }),
|
||||
{ code: "ESTAGEDOUTSIDE" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("one numstat parser for the Changes panel and the lane card", () => {
|
||||
it("keys a rename by its new path and a binary file with null counts", () => {
|
||||
// Real `git diff --numstat -z` shapes: a rename is "a\td\t\0old\0new\0",
|
||||
// a binary file is "-\t-\tpath\0". `git show --format=` adds a leading \n.
|
||||
const stats = parseNumstat("\n1\t2\t\0old name.txt\0new name.txt\0-\t-\tlogo.png\0");
|
||||
assert.deepEqual([...stats.keys()], ["new name.txt", "logo.png"]);
|
||||
assert.deepEqual(stats.get("new name.txt"), { added: 1, deleted: 2, binary: false });
|
||||
assert.deepEqual(stats.get("logo.png"), { added: null, deleted: null, binary: true });
|
||||
});
|
||||
|
||||
it("gitFacts lists a staged rename and a binary file with the same counts, no extra field", async () => {
|
||||
g(LANE, "mv", "old name.txt", "new name.txt");
|
||||
write(LANE, "new name.txt", "keep me\nplease\nmore\n");
|
||||
fs.writeFileSync(path.join(LANE, "logo.png"), Buffer.from([0, 1, 2, 0, 255]));
|
||||
g(LANE, "add", "-A");
|
||||
const { files } = await gitFacts(LANE);
|
||||
const byPath = Object.fromEntries(files.map((f) => [f.path, f]));
|
||||
assert.deepEqual(byPath["new name.txt"], {
|
||||
path: "new name.txt",
|
||||
status: "R ",
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
});
|
||||
assert.deepEqual(byPath["logo.png"], {
|
||||
path: "logo.png",
|
||||
status: "A ",
|
||||
added: null,
|
||||
deleted: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,10 +19,11 @@ const {
|
||||
listCommits,
|
||||
listPaths,
|
||||
listTree,
|
||||
listTreeAll,
|
||||
parseUnifiedDiff,
|
||||
readFileAt,
|
||||
} = require("../lib/git-diff");
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const { runGitAction, validateRemoteUrl } = require("../lib/git-ops");
|
||||
const { gitFacts } = require("../lib/worktree");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-gitdiff-"));
|
||||
@@ -456,6 +457,49 @@ describe("listCommits / readFileAt / listPaths / listTree", () => {
|
||||
);
|
||||
await assert.rejects(() => listTree(LANE, { subdir: "../.." }), { code: "EBADPATH" });
|
||||
});
|
||||
|
||||
it("lists untracked-but-not-ignored files and marks what changed, folders included", async () => {
|
||||
write(LANE, "src/main.ts", "m\n");
|
||||
write(LANE, ".gitignore", "*.log\n");
|
||||
gc(LANE, "add", "-A");
|
||||
gc(LANE, "commit", "-qm", "src");
|
||||
write(LANE, "a.txt", "modified\n");
|
||||
write(LANE, "new.txt", "fresh\n");
|
||||
write(LANE, "gen/deep/out.txt", "wholly untracked folder\n");
|
||||
write(LANE, "noise.log", "ignored\n");
|
||||
|
||||
const root = await listTree(LANE, {});
|
||||
const by = Object.fromEntries(root.entries.map((e) => [e.name, e]));
|
||||
assert.equal(by["a.txt"].status, " M");
|
||||
assert.equal(by["new.txt"].status, "??");
|
||||
assert.equal(by["keep.txt"].status, undefined);
|
||||
assert.equal(by["noise.log"], undefined, "an ignored file stays out of the tree");
|
||||
assert.equal(by.gen.changed, true);
|
||||
assert.equal(by.src.changed, undefined);
|
||||
|
||||
// Inside a folder git reports only as "gen/": still untracked, still marked.
|
||||
const deep = await listTree(LANE, { subdir: "gen/deep" });
|
||||
assert.equal(deep.entries[0].status, "??");
|
||||
const all = await listTreeAll(LANE);
|
||||
assert.equal(all.levels["gen"][0].changed, true);
|
||||
assert.deepEqual(all.levels[""], root.entries);
|
||||
});
|
||||
|
||||
it("reads every level of the tree in one pass, grouped like listTree", async () => {
|
||||
write(LANE, "src/api/x.ts", "x\n");
|
||||
write(LANE, "src/main.ts", "m\n");
|
||||
gc(LANE, "add", "-A");
|
||||
gc(LANE, "commit", "-qm", "src");
|
||||
|
||||
const all = await listTreeAll(LANE);
|
||||
assert.equal(all.truncated, false);
|
||||
// Each level equals what the one-level read returns for it.
|
||||
for (const level of ["", "src", "src/api"]) {
|
||||
const one = await listTree(LANE, { subdir: level });
|
||||
assert.deepEqual(all.levels[level], one.entries, level);
|
||||
}
|
||||
assert.deepEqual(Object.keys(all.levels).sort(), ["", "src", "src/api"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runGitAction — per-file staging, discard and branches", () => {
|
||||
@@ -552,3 +596,204 @@ describe("runGitAction — per-file staging, discard and branches", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runGitAction — one hunk at a time", () => {
|
||||
beforeEach(freshFixture);
|
||||
|
||||
/** A 20-line file edited at both ends: two hunks at the default context. */
|
||||
function twoHunks() {
|
||||
const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`);
|
||||
write(LANE, "big.txt", lines.join("\n") + "\n");
|
||||
gc(LANE, "add", "big.txt");
|
||||
gc(LANE, "commit", "-qm", "big");
|
||||
lines[0] = "FIRST";
|
||||
lines[19] = "LAST";
|
||||
write(LANE, "big.txt", lines.join("\n") + "\n");
|
||||
}
|
||||
const headers = async () =>
|
||||
(await collectChanges(LANE, { range: "work", file: "big.txt" })).files[0].hunks.map(
|
||||
(h) => h.header
|
||||
);
|
||||
|
||||
it("stages ONE hunk, leaving the other in the working tree", async () => {
|
||||
twoHunks();
|
||||
const [first] = await headers();
|
||||
await runGitAction(LANE, "stage-hunk", { path: "big.txt", hunk: first });
|
||||
const staged = g(LANE, "diff", "--cached", "--", "big.txt");
|
||||
const unstaged = g(LANE, "diff", "--", "big.txt");
|
||||
assert.match(staged, /\+FIRST/);
|
||||
assert.doesNotMatch(staged, /LAST/);
|
||||
assert.match(unstaged, /\+LAST/);
|
||||
assert.doesNotMatch(unstaged, /FIRST/);
|
||||
});
|
||||
|
||||
it("undoes ONE hunk in the working tree, only with confirm", async () => {
|
||||
twoHunks();
|
||||
const [, second] = await headers();
|
||||
await assert.rejects(
|
||||
() => runGitAction(LANE, "discard-hunk", { path: "big.txt", hunk: second }),
|
||||
{ code: "ECONFIRM" }
|
||||
);
|
||||
await runGitAction(LANE, "discard-hunk", { path: "big.txt", hunk: second, confirm: true });
|
||||
const text = fs.readFileSync(path.join(LANE, "big.txt"), "utf8");
|
||||
assert.match(text, /^FIRST\n/);
|
||||
assert.match(text, /line 20\n$/);
|
||||
});
|
||||
|
||||
it("refuses a hunk that is no longer in the diff, and a path that is not changed", async () => {
|
||||
twoHunks();
|
||||
await assert.rejects(
|
||||
() => runGitAction(LANE, "stage-hunk", { path: "big.txt", hunk: "@@ -1,9 +1,9 @@" }),
|
||||
{ code: "EHUNKGONE" }
|
||||
);
|
||||
await assert.rejects(
|
||||
() => runGitAction(LANE, "stage-hunk", { path: "keep.txt", hunk: "@@ -1 +1 @@" }),
|
||||
{ code: "EBADPATH" }
|
||||
);
|
||||
});
|
||||
|
||||
it("sends an untracked file to the per-file actions", async () => {
|
||||
write(LANE, "new.txt", "x\n");
|
||||
await assert.rejects(
|
||||
() => runGitAction(LANE, "stage-hunk", { path: "new.txt", hunk: "@@ -0,0 +1,1 @@" }),
|
||||
{ code: "EUNTRACKED" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an unborn repo and `publish`", () => {
|
||||
const EMPTY = path.join(ROOT, "empty");
|
||||
const URL_OK = "https://example.test/owner/r.git";
|
||||
|
||||
/** A `git init`ed repo with no commit and no remote. */
|
||||
function unborn() {
|
||||
fs.rmSync(EMPTY, { recursive: true, force: true });
|
||||
g(ROOT, "init", "-q", "-b", "main", EMPTY);
|
||||
g(EMPTY, "config", "user.email", "t@h");
|
||||
g(EMPTY, "config", "user.name", "t");
|
||||
}
|
||||
|
||||
function firstCommit() {
|
||||
write(EMPTY, "x.txt", "x\n");
|
||||
gc(EMPTY, "add", "-A");
|
||||
gc(EMPTY, "commit", "-qm", "first");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
freshFixture();
|
||||
unborn();
|
||||
});
|
||||
|
||||
it("reports an unborn repo instead of failing to read it", async () => {
|
||||
write(EMPTY, "x.txt", "x\n");
|
||||
const facts = await gitFacts(EMPTY);
|
||||
assert.equal(facts.unborn, true);
|
||||
assert.equal(facts.head, null);
|
||||
assert.equal(facts.branch, "main");
|
||||
assert.equal(facts.subject, "");
|
||||
assert.equal(facts.untracked, 1);
|
||||
assert.equal(facts.hasRemote, false);
|
||||
assert.equal((await gitFacts(LANE)).unborn, false);
|
||||
});
|
||||
|
||||
it("makes the first commit of an unborn repo", async () => {
|
||||
write(EMPTY, "x.txt", "x\n");
|
||||
const out = await runGitAction(EMPTY, "commit", { message: "first" });
|
||||
assert.equal(out.ok, true);
|
||||
assert.equal((await gitFacts(EMPTY)).unborn, false);
|
||||
});
|
||||
|
||||
it("refuses to publish before the first commit", async () => {
|
||||
await assert.rejects(() => runGitAction(EMPTY, "publish", { url: URL_OK }), {
|
||||
code: "ENOCOMMITS",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to publish a repo that already has a remote", async () => {
|
||||
await assert.rejects(() => runGitAction(LANE, "publish", { url: URL_OK }), {
|
||||
code: "EREMOTEEXISTS",
|
||||
});
|
||||
assert.equal(g(LANE, "remote", "get-url", "origin").trim(), ORIGIN);
|
||||
});
|
||||
|
||||
it("refuses every URL outside https-without-userinfo, ssh:// and scp-like", async () => {
|
||||
firstCommit();
|
||||
for (const url of [
|
||||
ORIGIN, // a local path
|
||||
"/tmp/x",
|
||||
"../sibling.git",
|
||||
"file:///tmp/x.git",
|
||||
"ext::sh -c id",
|
||||
"ext::sh",
|
||||
"fd::3",
|
||||
"git://host/r.git",
|
||||
"http://host/r.git",
|
||||
"-oProxyCommand=x",
|
||||
"--upload-pack=x",
|
||||
"https://u:p@host/r.git",
|
||||
"https://token@host/r.git",
|
||||
"https://host/r .git",
|
||||
"https://host/r.git\n",
|
||||
"ssh://-oProxyCommand=x/r.git",
|
||||
// WHATWG parses these as https://host/x, but git sees no "://" and reads
|
||||
// them as scp-like ssh to a host named "https" — the checked string and
|
||||
// the used string must be the same string.
|
||||
"https:/host/r.git",
|
||||
"https:host/r.git",
|
||||
"https:\\\\host\\r.git",
|
||||
// Percent-encoded userinfo/host that git decodes into a leading "-".
|
||||
"ssh://%2DoProxyCommand=x@host/r.git",
|
||||
"ssh://git@%2Dhost/r.git",
|
||||
"-git@host:r.git",
|
||||
"",
|
||||
42,
|
||||
undefined,
|
||||
]) {
|
||||
await assert.rejects(
|
||||
() => runGitAction(EMPTY, "publish", { url }),
|
||||
{ code: "EBADURL" },
|
||||
`accepted ${JSON.stringify(url)}`
|
||||
);
|
||||
}
|
||||
assert.equal(g(EMPTY, "remote").trim(), "");
|
||||
});
|
||||
|
||||
it("accepts https, ssh:// and scp-like URLs", () => {
|
||||
for (const url of [
|
||||
URL_OK,
|
||||
"https://host:8443/owner/r",
|
||||
"ssh://git@host:2222/owner/r.git",
|
||||
"ssh://host/r.git",
|
||||
"git@github.com:owner/r.git",
|
||||
]) {
|
||||
assert.equal(validateRemoteUrl(url), url);
|
||||
}
|
||||
});
|
||||
|
||||
it("adds origin and pushes HEAD with an upstream", async () => {
|
||||
firstCommit();
|
||||
const bare = path.join(ROOT, "published.git");
|
||||
g(ROOT, "init", "-q", "--bare", bare);
|
||||
// Repo-local insteadOf maps the https URL onto the bare fixture, so the
|
||||
// real https branch of the allowlist is exercised without a network.
|
||||
g(EMPTY, "config", `url.${bare}.insteadOf`, URL_OK);
|
||||
|
||||
const out = await runGitAction(EMPTY, "publish", { url: URL_OK });
|
||||
assert.equal(out.ok, true);
|
||||
assert.equal(out.remote, "origin");
|
||||
// `remote add origin -- <url>` stores the URL itself, not "--".
|
||||
assert.equal(g(EMPTY, "config", "remote.origin.url").trim(), URL_OK);
|
||||
assert.equal(g(bare, "rev-parse", "main").trim(), g(EMPTY, "rev-parse", "HEAD").trim());
|
||||
const facts = await gitFacts(EMPTY);
|
||||
assert.equal(facts.upstream, "origin/main");
|
||||
assert.equal(facts.ahead, 0);
|
||||
});
|
||||
|
||||
it("removes the remote again when the push fails", async () => {
|
||||
firstCommit();
|
||||
g(EMPTY, "config", `url.${path.join(ROOT, "nowhere.git")}.insteadOf`, URL_OK);
|
||||
|
||||
await assert.rejects(() => runGitAction(EMPTY, "publish", { url: URL_OK }));
|
||||
assert.equal(g(EMPTY, "remote").trim(), "");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @file Issue #45: a dashboard fetch/pull/push against a remote that needs a
|
||||
* login. A local HTTP "remote" answers 401 to every request and records the
|
||||
* Authorization header it was sent, so the tests see exactly what git sent:
|
||||
* nothing → EGITAUTH naming the host; a typed credential → that credential,
|
||||
* as Basic auth, to that host; saved only when git succeeded AND asked to.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-ops-auth-"));
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
||||
// The machine's own helpers must not answer for the fake host.
|
||||
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
||||
process.env.HOME = ROOT;
|
||||
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const creds = require("../lib/git-credentials");
|
||||
|
||||
let server;
|
||||
let host;
|
||||
const seen = [];
|
||||
const LANE = path.join(ROOT, "lane");
|
||||
|
||||
before(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
seen.push(req.headers.authorization || null);
|
||||
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
|
||||
res.end();
|
||||
});
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
host = `127.0.0.1:${server.address().port}`;
|
||||
execFileSync("git", ["init", "-q", LANE]);
|
||||
execFileSync("git", ["-C", LANE, "remote", "add", "origin", `http://${host}/o/r.git`]);
|
||||
});
|
||||
after(() => new Promise((r) => server.close(r)));
|
||||
beforeEach(() => {
|
||||
seen.length = 0;
|
||||
fs.rmSync(process.env.CLAUDE_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("git auth on fetch", () => {
|
||||
it("answers EGITAUTH naming the host when git has no credential", async () => {
|
||||
await assert.rejects(runGitAction(LANE, "fetch"), (e) => {
|
||||
assert.equal(e.code, "EGITAUTH");
|
||||
assert.deepEqual(e.auth, { protocol: "http", host, username: null, rejected: false });
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it("sends a typed credential as Basic auth, and does not save a failed one", async () => {
|
||||
await assert.rejects(
|
||||
runGitAction(LANE, "fetch", {
|
||||
credentials: { protocol: "http", host, username: "vi", password: "tok", remember: true },
|
||||
}),
|
||||
(e) => e.code === "EGITAUTH" && e.auth.rejected === true
|
||||
);
|
||||
const basic = `Basic ${Buffer.from("vi:tok").toString("base64")}`;
|
||||
assert.ok(seen.includes(basic), `expected ${basic} in ${JSON.stringify(seen)}`);
|
||||
assert.deepEqual(creds.list(), [], "a credential git rejected is never saved");
|
||||
});
|
||||
|
||||
it("uses a stored credential without being asked", async () => {
|
||||
creds.save({ protocol: "http", host, username: "saved", password: "s3" });
|
||||
await assert.rejects(runGitAction(LANE, "fetch"), { code: "EGITAUTH" });
|
||||
assert.ok(seen.includes(`Basic ${Buffer.from("saved:s3").toString("base64")}`));
|
||||
});
|
||||
|
||||
it("refuses a credential for a host that is not this repo's remote", async () => {
|
||||
await assert.rejects(
|
||||
runGitAction(LANE, "fetch", {
|
||||
credentials: { protocol: "https", host: "evil.test", username: "u", password: "p" },
|
||||
}),
|
||||
{ code: "EBADHOST" }
|
||||
);
|
||||
assert.equal(seen.length, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @file Issue #45 part B: merge and conflict resolution from the dashboard,
|
||||
* against a REAL repo whose `main` and `feat` edit the same line. Pins the
|
||||
* guards (clean tree, repo-root lane, a ref that exists, one merge at a time,
|
||||
* a path that IS conflicted, markers gone before "resolved", confirm on
|
||||
* abort) and the happy paths (ours, theirs, hand-edited, commit, abort).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const { gitFacts } = require("../lib/worktree");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-merge-"));
|
||||
const REPO = path.join(ROOT, "repo");
|
||||
|
||||
const g = (...args) => {
|
||||
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
||||
delete env.GIT_DIR;
|
||||
delete env.GIT_WORK_TREE;
|
||||
delete env.GIT_INDEX_FILE;
|
||||
return execFileSync("git", args, { cwd: REPO, encoding: "utf8", env });
|
||||
};
|
||||
const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body);
|
||||
const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8");
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(REPO, { recursive: true, force: true });
|
||||
fs.mkdirSync(REPO, { recursive: true });
|
||||
g("init", "-q", "-b", "main");
|
||||
g("config", "user.email", "t@h");
|
||||
g("config", "user.name", "t");
|
||||
fs.mkdirSync(path.join(REPO, "sub"));
|
||||
write("a.txt", "base\n");
|
||||
write("sub/s.txt", "s\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-qm", "init");
|
||||
g("switch", "-qc", "feat");
|
||||
write("a.txt", "theirs\n");
|
||||
write("b.txt", "only on feat\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-qm", "feat");
|
||||
g("switch", "-q", "main");
|
||||
write("a.txt", "ours\n");
|
||||
g("commit", "-qam", "main");
|
||||
});
|
||||
|
||||
const merge = () => runGitAction(REPO, "merge", { branch: "feat" });
|
||||
|
||||
describe("merge", () => {
|
||||
it("reports conflicts instead of failing, and gitFacts shows the merge", async () => {
|
||||
const r = await merge();
|
||||
assert.equal(r.ok, true);
|
||||
assert.deepEqual(r.conflicts, ["a.txt"]);
|
||||
const facts = await gitFacts(REPO);
|
||||
assert.equal(facts.merging, true);
|
||||
assert.equal(facts.conflicts, 1);
|
||||
});
|
||||
|
||||
it("merges cleanly when there is nothing to resolve", async () => {
|
||||
write("a.txt", "theirs\n");
|
||||
g("commit", "-qam", "agree");
|
||||
const r = await merge();
|
||||
assert.deepEqual(r.conflicts, []);
|
||||
assert.equal(read("b.txt"), "only on feat\n");
|
||||
assert.equal((await gitFacts(REPO)).merging, false);
|
||||
});
|
||||
|
||||
it("refuses a dirty tree, an unknown ref, a subdirectory lane and a second merge", async () => {
|
||||
write("a.txt", "wip\n");
|
||||
await assert.rejects(merge(), { code: "EDIRTY" });
|
||||
g("checkout", "--", "a.txt");
|
||||
await assert.rejects(runGitAction(REPO, "merge", { branch: "nope" }), { code: "ENOBRANCH" });
|
||||
await assert.rejects(runGitAction(REPO, "merge", { branch: "--help" }), { code: "EBADBRANCH" });
|
||||
await assert.rejects(runGitAction(path.join(REPO, "sub"), "merge", { branch: "feat" }), {
|
||||
code: "ESUBDIR",
|
||||
});
|
||||
await merge();
|
||||
await assert.rejects(merge(), { code: "EMERGING" });
|
||||
await assert.rejects(runGitAction(REPO, "pull"), { code: "EMERGING" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolve", () => {
|
||||
it("takes our side or theirs, and only for a conflicted path", async () => {
|
||||
await merge();
|
||||
await assert.rejects(runGitAction(REPO, "resolve", { path: "b.txt", side: "ours" }), {
|
||||
code: "EBADPATH",
|
||||
});
|
||||
await assert.rejects(runGitAction(REPO, "resolve", { path: "a.txt", side: "mine" }), {
|
||||
code: "EBADSIDE",
|
||||
});
|
||||
await runGitAction(REPO, "resolve", { path: "a.txt", side: "theirs" });
|
||||
assert.equal(read("a.txt"), "theirs\n");
|
||||
assert.equal((await gitFacts(REPO)).conflicts, 0);
|
||||
});
|
||||
|
||||
it("marks a hand-edited file resolved only once its markers are gone", async () => {
|
||||
await merge();
|
||||
await assert.rejects(runGitAction(REPO, "resolve", { path: "a.txt", side: "done" }), {
|
||||
code: "EMARKERS",
|
||||
});
|
||||
await assert.rejects(runGitAction(REPO, "stage", { paths: ["a.txt"] }), { code: "EMARKERS" });
|
||||
write("a.txt", "both\n");
|
||||
await runGitAction(REPO, "resolve", { path: "a.txt", side: "done" });
|
||||
assert.equal((await gitFacts(REPO)).conflicts, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("finish or abort", () => {
|
||||
it("commits the merge only when nothing is left unresolved", async () => {
|
||||
await merge();
|
||||
await assert.rejects(runGitAction(REPO, "merge-commit"), { code: "ECONFLICTS" });
|
||||
await runGitAction(REPO, "resolve", { path: "a.txt", side: "ours" });
|
||||
await runGitAction(REPO, "merge-commit");
|
||||
assert.equal((await gitFacts(REPO)).merging, false);
|
||||
assert.equal(g("rev-list", "--parents", "-n1", "HEAD").trim().split(" ").length, 3);
|
||||
assert.equal(read("a.txt"), "ours\n");
|
||||
await assert.rejects(runGitAction(REPO, "merge-commit"), { code: "ENOMERGE" });
|
||||
});
|
||||
|
||||
it("aborts only with confirm, back to where it started", async () => {
|
||||
await merge();
|
||||
await assert.rejects(runGitAction(REPO, "merge-abort"), { code: "ECONFIRM" });
|
||||
await runGitAction(REPO, "merge-abort", { confirm: true });
|
||||
assert.equal((await gitFacts(REPO)).merging, false);
|
||||
assert.equal(read("a.txt"), "ours\n");
|
||||
assert.equal(fs.existsSync(path.join(REPO, "b.txt")), false);
|
||||
});
|
||||
});
|
||||
@@ -486,14 +486,20 @@ describe("managed worktree provisioning", () => {
|
||||
});
|
||||
|
||||
it("marks the lane failed with git's message when provisioning fails", async () => {
|
||||
const emptyRepo = path.join(ROOT, "empty-repo");
|
||||
fs.mkdirSync(emptyRepo, { recursive: true });
|
||||
g(emptyRepo, "init", "-b", "main");
|
||||
// A branch named `feat` makes `feat/<slug>` impossible to create — a git
|
||||
// failure the route cannot see coming. (An unborn repo used to serve here;
|
||||
// the route now refuses that one up front with EEMPTYREPO.)
|
||||
const clashRepo = path.join(ROOT, "clash-repo");
|
||||
fs.mkdirSync(clashRepo, { recursive: true });
|
||||
g(clashRepo, "init", "-b", "main");
|
||||
g(clashRepo, "-c", "user.email=t@h", "-c", "user.name=t", "commit", "--allow-empty", "-m", "i");
|
||||
g(clashRepo, "branch", "feat");
|
||||
|
||||
const created = await request("POST", "/api/lanes/worktree", {
|
||||
sourceRepo: emptyRepo,
|
||||
title: "Unborn Repo",
|
||||
sourceRepo: clashRepo,
|
||||
title: "Ref Clash",
|
||||
base: "main",
|
||||
branch: "feat/clash",
|
||||
});
|
||||
|
||||
assert.equal(created.status, 202);
|
||||
@@ -848,17 +854,12 @@ describe("destructive lane lifecycle actions", () => {
|
||||
const runId = started.body.lane.run_id;
|
||||
|
||||
// Mock tmux so has-session always returns 0 (session exists), simulating a stuck session
|
||||
tmux.__setExecImpl(async (cmd, args) => {
|
||||
if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (cmd === "tmux" && args[0] === "list-sessions") {
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (cmd === "tmux" && args[0] === "kill-session" && args[1] === "-t" && args[2] === runId) {
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
return { code: 1, stdout: "", stderr: "" };
|
||||
// Same shape as tmux.js's own exec: sync, argv only, throws on failure.
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session" && args[2] === `=${runId}`) return "";
|
||||
if (args[0] === "list-sessions") return `${runId}\n`;
|
||||
if (args[0] === "kill-session" && args[2] === `=${runId}`) return "";
|
||||
throw Object.assign(new Error("mocked tmux failure"), { status: 1 });
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -948,7 +949,12 @@ describe("destructive lane lifecycle actions", () => {
|
||||
|
||||
// Mock tmux so the session appears live
|
||||
tmux.__setExecImpl(async (cmd, args) => {
|
||||
if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
|
||||
if (
|
||||
cmd === "tmux" &&
|
||||
args[0] === "has-session" &&
|
||||
args[1] === "-t" &&
|
||||
args[2] === `=${runId}`
|
||||
) {
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (cmd === "tmux" && args[0] === "list-sessions") {
|
||||
@@ -1217,7 +1223,12 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
||||
|
||||
// Mock tmux so the run appears to be live.
|
||||
tmux.__setExecImpl(async (cmd, args) => {
|
||||
if (cmd === "tmux" && args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
|
||||
if (
|
||||
cmd === "tmux" &&
|
||||
args[0] === "has-session" &&
|
||||
args[1] === "-t" &&
|
||||
args[2] === `=${runId}`
|
||||
) {
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (cmd === "tmux" && args[0] === "list-sessions") {
|
||||
@@ -1263,7 +1274,7 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
||||
|
||||
// Mock tmux so the session appears to be gone (has-session fails with status 1).
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session" && args[1] === "-t" && args[2] === runId) {
|
||||
if (args[0] === "has-session" && args[1] === "-t" && args[2] === `=${runId}`) {
|
||||
const e = new Error("no such session");
|
||||
e.status = 1;
|
||||
throw e;
|
||||
|
||||
@@ -321,6 +321,27 @@ describe("/api/lanes", () => {
|
||||
await request("DELETE", `/api/lanes/${id}`);
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("router apply asks for a restart only while the lane's pane is live", async () => {
|
||||
const tmux = require("../lib/tmux");
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-live-"));
|
||||
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
|
||||
try {
|
||||
for (const live of [false, true]) {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session" && !live) throw new Error("no such session");
|
||||
return "";
|
||||
});
|
||||
const r = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.restart_required, live);
|
||||
assert.equal(r.body.run_id, live ? `agenthub-lane-${id}` : null);
|
||||
}
|
||||
} finally {
|
||||
tmux.__reset();
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook → lane binding", () => {
|
||||
@@ -396,7 +417,7 @@ describe("hook → lane binding", () => {
|
||||
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-b");
|
||||
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");
|
||||
|
||||
// Session B's second PostToolUse clears the flag (B is now bound)
|
||||
// B is bound now, but it still did not raise the flag: A is still waiting.
|
||||
await request("POST", "/api/hooks/event", {
|
||||
hook_type: "PostToolUse",
|
||||
data: {
|
||||
@@ -406,6 +427,18 @@ describe("hook → lane binding", () => {
|
||||
tool_use_id: "t2",
|
||||
},
|
||||
});
|
||||
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");
|
||||
|
||||
// A answering its own prompt is what clears it.
|
||||
await request("POST", "/api/hooks/event", {
|
||||
hook_type: "PostToolUse",
|
||||
data: {
|
||||
session_id: "sess-a",
|
||||
cwd: "/tmp/lane-cross-session",
|
||||
tool_name: "Read",
|
||||
tool_use_id: "t3",
|
||||
},
|
||||
});
|
||||
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, null);
|
||||
|
||||
await request("DELETE", `/api/lanes/${id}`);
|
||||
@@ -921,6 +954,13 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
|
||||
assert.equal(bogus.body.error.code, "EBADACTION");
|
||||
});
|
||||
|
||||
it("400s a publish to a url that is not https, ssh or scp-like", async () => {
|
||||
const r = await request("POST", `/api/lanes/${laneId}/git/publish`, { url: "file:///tmp/x" });
|
||||
assert.equal(r.status, 400);
|
||||
assert.equal(r.body.error.code, "EBADURL");
|
||||
assert.equal(g(dir, "remote").trim(), "");
|
||||
});
|
||||
|
||||
it("reads the log, one commit's diff, a whole file, the path list and the tree", async () => {
|
||||
const log = await request("GET", `/api/lanes/${laneId}/git/log?limit=5`);
|
||||
assert.equal(log.status, 200);
|
||||
@@ -959,6 +999,47 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
|
||||
assert.ok(tree.body.entries.some((e) => e.name === "a.txt" && e.dir === false));
|
||||
});
|
||||
|
||||
it("saves a file from the editor over the exact bytes it loaded, and nothing else", async () => {
|
||||
const opened = await request("GET", `/api/lanes/${laneId}/git/file?path=a.txt&rev=work`);
|
||||
assert.equal(typeof opened.body.sha, "string");
|
||||
|
||||
const saved = await request("PUT", `/api/lanes/${laneId}/git/file`, {
|
||||
path: "a.txt",
|
||||
content: "one\nTWO\nthree",
|
||||
baseSha: opened.body.sha,
|
||||
});
|
||||
assert.equal(saved.status, 200, JSON.stringify(saved.body));
|
||||
// The final newline the file had survives an editor that drops it.
|
||||
assert.equal(fs.readFileSync(path.join(dir, "a.txt"), "utf8"), "one\nTWO\nthree\n");
|
||||
assert.notEqual(saved.body.sha, opened.body.sha);
|
||||
|
||||
// Saving over the OLD stamp is a conflict, never a silent overwrite.
|
||||
const stale = await request("PUT", `/api/lanes/${laneId}/git/file`, {
|
||||
path: "a.txt",
|
||||
content: "clobbered",
|
||||
baseSha: opened.body.sha,
|
||||
});
|
||||
assert.equal(stale.status, 409);
|
||||
assert.equal(stale.body.error.code, "ESTALE");
|
||||
assert.equal(fs.readFileSync(path.join(dir, "a.txt"), "utf8"), "one\nTWO\nthree\n");
|
||||
|
||||
const escape = await request("PUT", `/api/lanes/${laneId}/git/file`, {
|
||||
path: "../../etc/passwd",
|
||||
content: "x",
|
||||
baseSha: "0".repeat(40),
|
||||
});
|
||||
assert.equal(escape.status, 400);
|
||||
assert.equal(escape.body.error.code, "EBADPATH");
|
||||
|
||||
const secret = await request("PUT", `/api/lanes/${laneId}/git/file`, {
|
||||
path: ".claude/settings.local.json",
|
||||
content: "{}",
|
||||
baseSha: "0".repeat(40),
|
||||
});
|
||||
assert.equal(secret.status, 403);
|
||||
assert.equal(secret.body.error.code, "ESECRET");
|
||||
});
|
||||
|
||||
it("stages one file, commits only the index, and refuses an unchanged path", async () => {
|
||||
fs.writeFileSync(path.join(dir, "a.txt"), "one\nTHREE\n");
|
||||
fs.writeFileSync(path.join(dir, "b.txt"), "left behind\n");
|
||||
@@ -1022,6 +1103,22 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
|
||||
assert.equal(g(dir, "branch", "--list", "feat/sibling").trim(), "");
|
||||
});
|
||||
|
||||
it("maps a git exit status to 409 EGIT with git's stderr in the message", async () => {
|
||||
// A remote that is not a repository: `git push` exits 128, and execFile
|
||||
// reports that as a NUMERIC err.code. It must never reach the client as
|
||||
// `code: 128`, nor as a 500 — git refusing is not a server fault.
|
||||
g(dir, "remote", "add", "origin", path.join(ROOT, "no-such-remote"));
|
||||
try {
|
||||
const r = await request("POST", `/api/lanes/${laneId}/git/push`, {});
|
||||
assert.equal(r.status, 409, JSON.stringify(r.body));
|
||||
assert.equal(r.body.error.code, "EGIT");
|
||||
assert.match(r.body.error.message, /^git push failed\n/);
|
||||
assert.match(r.body.error.message, /no-such-remote/);
|
||||
} finally {
|
||||
g(dir, "remote", "remove", "origin");
|
||||
}
|
||||
});
|
||||
|
||||
it("404s every git route for a lane that does not exist", async () => {
|
||||
for (const url of [
|
||||
"/api/lanes/999999/git/changes",
|
||||
@@ -1385,6 +1482,23 @@ describe("/api/lanes tool tests", () => {
|
||||
assert.equal(second.body.lane.id, first.body.lane.id);
|
||||
});
|
||||
|
||||
it("POST /api/lanes/ensure resolves a symlinked path, existing or not, to one lane", async () => {
|
||||
const real = fs.mkdtempSync(path.join(os.tmpdir(), "lane-real-"));
|
||||
const link = `${real}-link`;
|
||||
fs.symlinkSync(real, link);
|
||||
try {
|
||||
for (const sub of ["", "/not-yet"]) {
|
||||
const first = await request("POST", "/api/lanes/ensure", { cwd: real + sub });
|
||||
const second = await request("POST", "/api/lanes/ensure", { cwd: link + sub });
|
||||
assert.equal(second.body.created, false, sub);
|
||||
assert.equal(second.body.lane.id, first.body.lane.id, sub);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(link);
|
||||
fs.rmSync(real, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /api/lanes/ensure re-ensuring with the same tool is a no-op", async () => {
|
||||
const first = await request("POST", "/api/lanes/ensure", {
|
||||
cwd: "/tmp/same-tool-lane",
|
||||
@@ -1497,6 +1611,11 @@ describe("/api/lanes tool tests", () => {
|
||||
const fs = require("fs");
|
||||
fs.mkdirSync(sourceRepo, { recursive: true });
|
||||
require("child_process").execSync("git init", { cwd: sourceRepo });
|
||||
// A worktree needs a commit to branch from (an unborn repo is EEMPTYREPO).
|
||||
require("child_process").execSync(
|
||||
"git -c user.email=t@h -c user.name=t commit -q --allow-empty -m init",
|
||||
{ cwd: sourceRepo }
|
||||
);
|
||||
|
||||
const res = await request("POST", "/api/lanes/worktree", {
|
||||
sourceRepo,
|
||||
@@ -1517,6 +1636,24 @@ describe("/api/lanes tool tests", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /api/lanes/worktree refuses a repo with no commits, creating no row", async () => {
|
||||
const sourceRepo = require("path").join(
|
||||
require("os").tmpdir(),
|
||||
`agenthub-worktree-empty-${Date.now()}`
|
||||
);
|
||||
const fs = require("fs");
|
||||
fs.mkdirSync(sourceRepo, { recursive: true });
|
||||
require("child_process").execSync("git init -q", { cwd: sourceRepo });
|
||||
const before = (await request("GET", "/api/lanes")).body.lanes.length;
|
||||
|
||||
const res = await request("POST", "/api/lanes/worktree", { sourceRepo, slug: "empty" });
|
||||
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.body.error.code, "EEMPTYREPO");
|
||||
assert.equal((await request("GET", "/api/lanes")).body.lanes.length, before);
|
||||
fs.rmSync(sourceRepo, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("POST /api/lanes/worktree defaults to 'claude' tool when tool is not specified", async () => {
|
||||
const sourceRepo = require("path").join(
|
||||
require("os").tmpdir(),
|
||||
@@ -1525,6 +1662,11 @@ describe("/api/lanes tool tests", () => {
|
||||
const fs = require("fs");
|
||||
fs.mkdirSync(sourceRepo, { recursive: true });
|
||||
require("child_process").execSync("git init", { cwd: sourceRepo });
|
||||
// A worktree needs a commit to branch from (an unborn repo is EEMPTYREPO).
|
||||
require("child_process").execSync(
|
||||
"git -c user.email=t@h -c user.name=t commit -q --allow-empty -m init",
|
||||
{ cwd: sourceRepo }
|
||||
);
|
||||
|
||||
const res = await request("POST", "/api/lanes/worktree", {
|
||||
sourceRepo,
|
||||
|
||||
@@ -458,9 +458,17 @@ describe("agenthub lanes add", () => {
|
||||
});
|
||||
|
||||
it("reports the failure notes, not success, when provisioning fails", async () => {
|
||||
const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cli-unborn-repo-"));
|
||||
// A branch named `feat` makes the default `feat/<slug>` impossible to
|
||||
// create, so provisioning fails inside git (an unborn repo is now refused
|
||||
// up front with EEMPTYREPO and never reaches provisioning).
|
||||
const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cli-clash-repo-"));
|
||||
try {
|
||||
await git(["init", "-b", "main"], emptyRepo);
|
||||
await git(
|
||||
["-c", "user.email=t@h", "-c", "user.name=t", "commit", "--allow-empty", "-m", "i"],
|
||||
emptyRepo
|
||||
);
|
||||
await git(["branch", "feat"], emptyRepo);
|
||||
const r = await cli(
|
||||
["lanes", "add", "--repo", emptyRepo, "--title", "CLI Unborn"],
|
||||
emptyRepo
|
||||
|
||||
@@ -85,6 +85,17 @@ describe("legacy lanes_new rebuild (pre-tool, single-column cwd UNIQUE DB)", ()
|
||||
assert.equal(row.tool, "claude");
|
||||
});
|
||||
|
||||
it("keeps every column added before the rebuild, router_profile included", () => {
|
||||
const { db } = require("../db");
|
||||
const cols = db
|
||||
.prepare("PRAGMA table_info(lanes)")
|
||||
.all()
|
||||
.map((c) => c.name);
|
||||
for (const name of ["router_profile", "needs_action_session"]) {
|
||||
assert.ok(cols.includes(name), `expected ${name} to survive the rebuild`);
|
||||
}
|
||||
});
|
||||
|
||||
it("recreates idx_lanes_session and idx_lanes_slot", () => {
|
||||
const { db } = require("../db");
|
||||
for (const name of ["idx_lanes_session", "idx_lanes_slot"]) {
|
||||
|
||||
@@ -1269,3 +1269,21 @@ describe("the forward-only hold is short enough for one work session", () => {
|
||||
assert.equal(result, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createLane — canonical cwd (macOS /tmp -> /private/tmp class)", () => {
|
||||
it("stores the realpath of a symlinked dir, and the given string when it does not exist yet", () => {
|
||||
const fs = require("node:fs");
|
||||
const base = fs.realpathSync(fs.mkdtempSync(pathMod.join(os.tmpdir(), "lane-realpath-")));
|
||||
const target = pathMod.join(base, "real");
|
||||
const link = pathMod.join(base, "link");
|
||||
fs.mkdirSync(target);
|
||||
fs.symlinkSync(target, link);
|
||||
try {
|
||||
assert.equal(lanes.createLane({ cwd: link + "/" }).cwd, target);
|
||||
const missing = pathMod.join(base, "not-yet");
|
||||
assert.equal(lanes.createLane({ cwd: missing }).cwd, missing);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,16 @@ describe("serverIsLive", () => {
|
||||
assert.equal(boot.serverIsLive(), true);
|
||||
assert.deepEqual(boot.liveServers(), [{ port: 4820, pid: process.pid, startedAt: "x" }]);
|
||||
});
|
||||
|
||||
it("ignores an entry started before this boot, even when its pid is alive (pid reuse)", () => {
|
||||
const beforeBoot = new Date(Date.now() - os.uptime() * 1000 - 3600_000).toISOString();
|
||||
fs.writeFileSync(
|
||||
infoPath,
|
||||
JSON.stringify({ servers: [{ port: 4820, pid: process.pid, startedAt: beforeBoot }] })
|
||||
);
|
||||
assert.equal(boot.serverIsLive(), false);
|
||||
assert.deepEqual(boot.liveServers(), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime location", () => {
|
||||
@@ -419,6 +429,26 @@ describe("agenthub CLI launcher", () => {
|
||||
assert.equal(boot.linkCli(bin), "foreign");
|
||||
assert.match(fs.readFileSync(target, "utf8"), /my\/checkout/);
|
||||
});
|
||||
|
||||
it(
|
||||
"falls back to `node` on PATH when the pinned node binary is gone (brew upgrade)",
|
||||
{ skip: process.platform === "win32" },
|
||||
() => {
|
||||
const root = path.join(TMP_HOME, "fake-root");
|
||||
fs.mkdirSync(path.join(root, "bin"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "bin", "agenthub.js"),
|
||||
"console.log('ran:' + process.argv.slice(2).join(','));\n"
|
||||
);
|
||||
assert.equal(boot.linkCli(bin, root, "/nonexistent/Cellar/node/0.0.0/bin/node"), "written");
|
||||
assert.match(fs.readFileSync(target, "utf8"), /\/nonexistent\/Cellar/);
|
||||
const out = require("child_process").execFileSync("sh", [target, "a", "b c"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, PATH: `${path.dirname(process.execPath)}:/usr/bin:/bin` },
|
||||
});
|
||||
assert.equal(out.trim(), "ran:a,b c");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("plugin-open / plugin-bootstrap require cycle", () => {
|
||||
|
||||
@@ -324,3 +324,42 @@ describe("calculateCost — date-effective (intro) pricing", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_PRICING — Opus 5.5", () => {
|
||||
it("prices claude-opus-5-5 at its own rates, not Opus 5's", () => {
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(
|
||||
os.tmpdir(),
|
||||
`pricing-opus55-${Date.now()}-${process.pid}.db`
|
||||
);
|
||||
const { DEFAULT_PRICING } = require("../db");
|
||||
const rules = DEFAULT_PRICING.map(([p, n, i, o, cr, cw, cw1h, fi, fo]) => ({
|
||||
model_pattern: p,
|
||||
display_name: n,
|
||||
input_per_mtok: i,
|
||||
output_per_mtok: o,
|
||||
cache_read_per_mtok: cr,
|
||||
cache_write_per_mtok: cw,
|
||||
cache_write_1h_per_mtok: cw1h,
|
||||
fast_input_per_mtok: fi,
|
||||
fast_output_per_mtok: fo,
|
||||
}));
|
||||
// A real session: Claude Code's /cost reported $2.72 for exactly these
|
||||
// counts (all cache writes at the 1h tier); Opus 5's rates gave $4.86.
|
||||
const r = calculateCost(
|
||||
[
|
||||
bucket({
|
||||
model: "claude-opus-5-5",
|
||||
input_tokens: 102,
|
||||
output_tokens: 25_408,
|
||||
cache_read_tokens: 5_866_977,
|
||||
cache_write_tokens: 129_432,
|
||||
cache_write_1h_tokens: 129_432,
|
||||
}),
|
||||
],
|
||||
rules
|
||||
);
|
||||
assert.equal(r.total_cost.toFixed(2), "2.72");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @file Tests for the automatic pricing sync: a model seen in token_usage with
|
||||
* no row of its own gets one from the price list, an edited row is never
|
||||
* overwritten, and a bad list or a failed fetch changes nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
process.env.DASHBOARD_DB_PATH = path.join(
|
||||
os.tmpdir(),
|
||||
`pricing-sync-${Date.now()}-${process.pid}.db`
|
||||
);
|
||||
const { db } = require("../db");
|
||||
const { syncPricing, familyOf, displayName, _resetCache } = require("../lib/pricing-sync");
|
||||
|
||||
// The real LiteLLM entry for Opus 5.5, trimmed.
|
||||
const LIST = {
|
||||
"claude-opus-9-9": {
|
||||
litellm_provider: "anthropic",
|
||||
input_cost_per_token: 0.000004,
|
||||
output_cost_per_token: 0.00002,
|
||||
cache_read_input_token_cost: 2e-7,
|
||||
cache_creation_input_token_cost: 0.000005,
|
||||
cache_creation_input_token_cost_above_1hr: 0.000008,
|
||||
supports_fast_mode: true,
|
||||
provider_specific_entry: { fast: 2 },
|
||||
},
|
||||
};
|
||||
|
||||
function useModel(model) {
|
||||
db.prepare("INSERT OR IGNORE INTO sessions (id, status, started_at) VALUES (?, 'active', ?)").run(
|
||||
`s-${model}`,
|
||||
new Date().toISOString()
|
||||
);
|
||||
db.prepare("INSERT INTO token_usage (session_id, model) VALUES (?, ?)").run(`s-${model}`, model);
|
||||
}
|
||||
const row = (p) => db.prepare("SELECT * FROM model_pricing WHERE model_pattern = ?").get(p);
|
||||
|
||||
beforeEach(() => {
|
||||
_resetCache();
|
||||
db.prepare("DELETE FROM token_usage").run();
|
||||
db.prepare("DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-opus-9%'").run();
|
||||
});
|
||||
|
||||
describe("pricing sync", () => {
|
||||
it("reduces an id to its family and names it", () => {
|
||||
assert.equal(familyOf("claude-opus-5-5-20260101"), "claude-opus-5-5");
|
||||
assert.equal(familyOf("gpt-4"), null);
|
||||
assert.equal(familyOf("claude-x%"), null);
|
||||
assert.equal(displayName("claude-opus-5-5"), "Claude Opus 5.5");
|
||||
});
|
||||
|
||||
it("adds a row for a used model that only a broader pattern matched", async () => {
|
||||
db.prepare(
|
||||
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok) VALUES ('claude-opus-9%', 'Broad', 5)"
|
||||
).run();
|
||||
useModel("claude-opus-9-9-20260101");
|
||||
const added = await syncPricing(db, { fetchList: async () => LIST });
|
||||
assert.deepEqual(added, ["claude-opus-9-9%"]);
|
||||
const r = row("claude-opus-9-9%");
|
||||
assert.equal(r.display_name, "Claude Opus 9.9");
|
||||
assert.deepEqual([r.input_per_mtok, r.output_per_mtok, r.cache_read_per_mtok], [4, 20, 0.2]);
|
||||
assert.deepEqual([r.cache_write_per_mtok, r.cache_write_1h_per_mtok], [5, 8]);
|
||||
assert.deepEqual([r.fast_input_per_mtok, r.fast_output_per_mtok], [8, 40]);
|
||||
});
|
||||
|
||||
it("never overwrites a row that exists, and fetches nothing when none is missing", async () => {
|
||||
db.prepare(
|
||||
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok) VALUES ('claude-opus-9-9%', 'Mine', 1)"
|
||||
).run();
|
||||
useModel("claude-opus-9-9");
|
||||
let fetched = false;
|
||||
await syncPricing(db, {
|
||||
fetchList: async () => {
|
||||
fetched = true;
|
||||
return LIST;
|
||||
},
|
||||
});
|
||||
assert.equal(fetched, false);
|
||||
assert.equal(row("claude-opus-9-9%").input_per_mtok, 1);
|
||||
});
|
||||
|
||||
it("skips absurd rates and survives a failed fetch", async () => {
|
||||
useModel("claude-opus-9-9");
|
||||
const bad = { "claude-opus-9-9": { ...LIST["claude-opus-9-9"], input_cost_per_token: 5 } };
|
||||
assert.deepEqual(await syncPricing(db, { fetchList: async () => bad }), []);
|
||||
_resetCache();
|
||||
const failed = await syncPricing(db, {
|
||||
fetchList: async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
});
|
||||
assert.deepEqual(failed, []);
|
||||
assert.equal(row("claude-opus-9-9%"), undefined);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,8 @@ describe("pty-attach", () => {
|
||||
|
||||
it("accepts a well-formed runId", () => {
|
||||
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42"));
|
||||
assert.doesNotThrow(() => ptyAttach.validateRunId("agenthub-lane-42-3"));
|
||||
assert.throws(() => ptyAttach.validateRunId("agenthub-lane-42-4"), /EBADRUNID/);
|
||||
});
|
||||
|
||||
it("turns tmux mouse mode on for the session it attaches to", () => {
|
||||
@@ -64,7 +66,7 @@ describe("pty-attach", () => {
|
||||
return "";
|
||||
});
|
||||
ptyAttach.attach(makeFakeWs(), "agenthub-lane-1", { cols: 80, rows: 24 });
|
||||
assert.deepEqual(calls[0], ["set-option", "-t", "agenthub-lane-1", "mouse", "on"]);
|
||||
assert.deepEqual(calls[0], ["set-option", "-t", "=agenthub-lane-1:", "mouse", "on"]);
|
||||
});
|
||||
|
||||
it("still attaches even if the mouse-mode set-option call fails", () => {
|
||||
|
||||
@@ -58,6 +58,26 @@ describe("pty-run", () => {
|
||||
assert.ok(newSessionCall.includes("opus"));
|
||||
});
|
||||
|
||||
it("spawnRun names a split pane's session agenthub-lane-<id>-<slot>, and refuses a slot past 3", () => {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") {
|
||||
const e = new Error("no such session");
|
||||
e.status = 1;
|
||||
throw e;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const handle = pty.spawnRun({ laneId: 42, slot: 2, cwd: "/tmp/repo" });
|
||||
assert.equal(handle.id, "agenthub-lane-42-2");
|
||||
assert.equal(handle.laneId, 42);
|
||||
assert.equal(handle.slot, 2);
|
||||
assert.equal(pty.laneIdFromRunId("agenthub-lane-42-2"), 42);
|
||||
assert.throws(
|
||||
() => pty.spawnRun({ laneId: 42, slot: 4, cwd: "/tmp/repo" }),
|
||||
(err) => err.code === "EBADSLOT"
|
||||
);
|
||||
});
|
||||
|
||||
it("spawnRun refuses with ETMUXMISSING, naming the fix, when tmux is not installed", () => {
|
||||
// Before this guard the ENOENT escaped from three calls down as
|
||||
// `spawnSync tmux ENOENT` and the route answered 400 — blaming the request
|
||||
@@ -221,7 +241,18 @@ describe("pty-run", () => {
|
||||
return "";
|
||||
});
|
||||
assert.equal(pty.killRun("agenthub-lane-5"), true);
|
||||
assert.ok(calls.some((c) => c[0] === "kill-session" && c[2] === "agenthub-lane-5"));
|
||||
assert.ok(calls.some((c) => c[0] === "kill-session" && c[2] === "=agenthub-lane-5"));
|
||||
});
|
||||
|
||||
it("never touches a tmux session that is not a lane pane", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
return "";
|
||||
});
|
||||
assert.equal(pty.killRun("main"), false);
|
||||
assert.equal(pty.getRun("main"), null);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
it("listRuns reflects live tmux-session state, not cached memory", () => {
|
||||
|
||||
@@ -79,7 +79,9 @@ const RULES = [
|
||||
// a Gitea /raw/branch/<ref>/ URL instead.
|
||||
re: /raw\.githubusercontent\.com\/[\w-]+\/[\w.-]+/g,
|
||||
fix: "use a same-origin path, or a Gitea /raw/branch/<ref>/ URL",
|
||||
skip: [],
|
||||
// A third-party project that really is on GitHub: the public model price
|
||||
// list server/lib/pricing-sync.js reads.
|
||||
skip: [/raw\.githubusercontent\.com\/BerriAI\/litellm$/],
|
||||
},
|
||||
{
|
||||
name: "GitHub Pages host for a Smartgift site",
|
||||
|
||||
@@ -26,7 +26,11 @@ function fetchJson(p, opts = {}) {
|
||||
const url = new URL(p, BASE);
|
||||
const headers = { ...(opts.headers || {}) };
|
||||
let body;
|
||||
if (opts.body !== undefined) {
|
||||
if (opts.raw) {
|
||||
body = opts.raw.buf;
|
||||
headers["Content-Type"] = opts.raw.type;
|
||||
headers["Content-Length"] = body.length;
|
||||
} else if (opts.body !== undefined) {
|
||||
body = Buffer.from(JSON.stringify(opts.body));
|
||||
headers["Content-Type"] = "application/json";
|
||||
headers["Content-Length"] = body.length;
|
||||
@@ -203,3 +207,45 @@ describe("/api/run", () => {
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/run/drop-image", () => {
|
||||
let srv;
|
||||
before(async () => {
|
||||
srv = http.createServer(createApp());
|
||||
await new Promise((r) => srv.listen(0, r));
|
||||
BASE = `http://127.0.0.1:${srv.address().port}`;
|
||||
});
|
||||
after(() => new Promise((r) => srv.close(r)));
|
||||
|
||||
const PNG = Buffer.from("89504e470d0a1a0a0000", "hex");
|
||||
|
||||
it("saves the image under a random name, owner-only, and returns its path", async () => {
|
||||
const r = await fetchJson("/api/run/drop-image", {
|
||||
method: "POST",
|
||||
raw: { type: "image/png", buf: PNG },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(path.basename(r.body.path), /^[0-9a-f]{16}\.png$/);
|
||||
assert.ok(r.body.path.startsWith(os.tmpdir()));
|
||||
assert.deepEqual(fs.readFileSync(r.body.path), PNG);
|
||||
assert.equal(fs.statSync(r.body.path).mode & 0o777, 0o600);
|
||||
fs.unlinkSync(r.body.path);
|
||||
});
|
||||
|
||||
it("refuses anything that is not one of the image types", async () => {
|
||||
const r = await fetchJson("/api/run/drop-image", {
|
||||
method: "POST",
|
||||
raw: { type: "text/x-shellscript", buf: Buffer.from("rm -rf ~") },
|
||||
});
|
||||
assert.equal(r.status, 400);
|
||||
});
|
||||
|
||||
it("refuses a cross-origin browser request", async () => {
|
||||
const r = await fetchJson("/api/run/drop-image", {
|
||||
method: "POST",
|
||||
headers: { Origin: "https://evil.example" },
|
||||
raw: { type: "image/png", buf: PNG },
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -393,3 +393,55 @@ describe("watchdog liveness reap", () => {
|
||||
assert.equal(stmts.getSession.get(sid).status, "completed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeLiveCwds — darwin branch (ps + lsof, stubbed)", () => {
|
||||
const realPlatform = process.platform;
|
||||
const PS =
|
||||
" 101 /opt/homebrew/bin/claude --resume x\n 102 node /Users/x/claude-dashboard/index.js\n 103 claude\n";
|
||||
const LSOF = "p101\nfcwd\nn/Users/x/proj\np103\nfcwd\nn/private/tmp/other\n";
|
||||
let calls;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.DASHBOARD_LIVENESS_PROBE;
|
||||
process.env.AGENTHUB_FORCE_HOST = "1";
|
||||
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
|
||||
calls = [];
|
||||
});
|
||||
after(() => {
|
||||
Object.defineProperty(process, "platform", { value: realPlatform, configurable: true });
|
||||
liveness.__setExecImpl(null);
|
||||
delete process.env.AGENTHUB_FORCE_HOST;
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
});
|
||||
|
||||
it("reads each claude pid's cwd from lsof -Fn records", () => {
|
||||
liveness.__setExecImpl((bin, args) => {
|
||||
calls.push([bin, args]);
|
||||
return bin === "ps" ? PS : LSOF;
|
||||
});
|
||||
const r = realProbe();
|
||||
assert.equal(r.available, true);
|
||||
assert.deepEqual([...r.cwds].sort(), ["/Users/x/proj", "/private/tmp/other"]);
|
||||
assert.deepEqual(calls[1], ["lsof", ["-a", "-p", "101,103", "-d", "cwd", "-Fn"]]);
|
||||
});
|
||||
|
||||
it("keeps lsof's partial output when it exits 1 because a pid vanished", () => {
|
||||
liveness.__setExecImpl((bin) => {
|
||||
if (bin === "ps") return PS;
|
||||
const err = new Error("exit 1");
|
||||
err.stdout = "p101\nfcwd\nn/Users/x/proj\n";
|
||||
throw err;
|
||||
});
|
||||
const r = realProbe();
|
||||
assert.equal(r.available, true);
|
||||
assert.deepEqual([...r.cwds], ["/Users/x/proj"]);
|
||||
});
|
||||
|
||||
it("reports unavailable when lsof fails with no output", () => {
|
||||
liveness.__setExecImpl((bin) => {
|
||||
if (bin === "ps") return PS;
|
||||
throw Object.assign(new Error("ENOENT"), { stdout: "" });
|
||||
});
|
||||
assert.equal(realProbe().available, false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("tmux wrapper", () => {
|
||||
"--model",
|
||||
"opus",
|
||||
]);
|
||||
assert.deepEqual(calls[1], ["set-option", "-t", "agenthub-lane-1", "mouse", "on"]);
|
||||
assert.deepEqual(calls[1], ["set-option", "-t", "=agenthub-lane-1:", "mouse", "on"]);
|
||||
});
|
||||
|
||||
it("sendText types the literal string and presses Enter, with no quoting", () => {
|
||||
@@ -64,11 +64,25 @@ describe("tmux wrapper", () => {
|
||||
assert.deepEqual(calls[0], [
|
||||
"send-keys",
|
||||
"-t",
|
||||
"agenthub-lane-1",
|
||||
"=agenthub-lane-1:",
|
||||
"-l",
|
||||
'Reminder: run "agenthub stage implement"',
|
||||
]);
|
||||
assert.deepEqual(calls[1], ["send-keys", "-t", "agenthub-lane-1", "Enter"]);
|
||||
assert.deepEqual(calls[1], ["send-keys", "-t", "=agenthub-lane-1:", "Enter"]);
|
||||
});
|
||||
|
||||
it("targets a session exactly, so lane 1 never resolves to lane 12 by prefix", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
return "";
|
||||
});
|
||||
tmux.hasSession("agenthub-lane-1");
|
||||
tmux.killSession("agenthub-lane-1");
|
||||
assert.deepEqual(calls, [
|
||||
["has-session", "-t", "=agenthub-lane-1"],
|
||||
["kill-session", "-t", "=agenthub-lane-1"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("killSession never throws when the session is already gone", () => {
|
||||
|
||||
+18
-1
@@ -645,6 +645,8 @@ try {
|
||||
db.exec(`
|
||||
PRAGMA foreign_keys = OFF;
|
||||
BEGIN;
|
||||
-- Every column the probes ABOVE this block add must be listed here too,
|
||||
-- or the rebuild silently drops it (router_profile once was).
|
||||
CREATE TABLE lanes_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
@@ -669,6 +671,7 @@ try {
|
||||
base_branch TEXT,
|
||||
slug TEXT,
|
||||
tool TEXT NOT NULL DEFAULT 'claude',
|
||||
router_profile TEXT,
|
||||
detected_stage TEXT,
|
||||
detected_signal TEXT,
|
||||
detected_at TEXT,
|
||||
@@ -679,7 +682,7 @@ try {
|
||||
INSERT INTO lanes_new SELECT
|
||||
id, title, cwd, branch, pipeline, session_id, run_id, stage, stage_since,
|
||||
status, gate_decision, ci_status, needs_action, links, stages, notes,
|
||||
created_at, updated_at, kind, source_repo, base_branch, slug, tool,
|
||||
created_at, updated_at, kind, source_repo, base_branch, slug, tool, router_profile,
|
||||
detected_stage, detected_signal, detected_at, slot, ports, active_feature_id
|
||||
FROM lanes;
|
||||
DROP TABLE lanes;
|
||||
@@ -695,6 +698,17 @@ try {
|
||||
}
|
||||
db.prepare("CREATE UNIQUE INDEX IF NOT EXISTS idx_lanes_cwd_tool ON lanes(cwd, tool)").run();
|
||||
|
||||
// Migrate: which session raised `needs_action`. Two sessions in one lane's cwd
|
||||
// (a hand-started terminal beside the dashboard pane) rebind `session_id` on
|
||||
// every hook, so "the bound session" is not "the session that is waiting".
|
||||
// Additive and nullable; null (a flag raised before this column) falls back to
|
||||
// the bound session. Placed after the rebuild above, which lists its columns.
|
||||
try {
|
||||
db.prepare("SELECT needs_action_session FROM lanes LIMIT 1").get();
|
||||
} catch {
|
||||
db.prepare("ALTER TABLE lanes ADD COLUMN needs_action_session TEXT").run();
|
||||
}
|
||||
|
||||
// Seed lane_stage_events from the snapshots that predate it, so upgrading does
|
||||
// not read as "every pipeline has never run". Each lane's live `stages` blob
|
||||
// becomes one run; each archived feature becomes one more. That is all the
|
||||
@@ -834,6 +848,9 @@ const DEFAULT_PRICING = [
|
||||
["claude-fable-5%", "Claude Fable 5", 10, 50, 1, 12.5, 20, 0, 0],
|
||||
["claude-mythos-5%", "Claude Mythos 5", 10, 50, 1, 12.5, 20, 0, 0],
|
||||
// Opus family (fast mode available on 5 / 4.6 / 4.7 / 4.8)
|
||||
// 5.5 is cheaper than 5 and "claude-opus-5%" would swallow it — own row
|
||||
// (longest pattern wins in calculateCost).
|
||||
["claude-opus-5-5%", "Claude Opus 5.5", 4, 20, 0.2, 5, 8, 8, 40],
|
||||
["claude-opus-5%", "Claude Opus 5", 5, 25, 0.5, 6.25, 10, 10, 50],
|
||||
["claude-opus-4-8%", "Claude Opus 4.8", 5, 25, 0.5, 6.25, 10, 10, 50],
|
||||
["claude-opus-4-7%", "Claude Opus 4.7", 5, 25, 0.5, 6.25, 10, 30, 150],
|
||||
|
||||
@@ -5,6 +5,21 @@
|
||||
|
||||
if (!process.env.NODE_ENV) process.env.NODE_ENV = "production";
|
||||
|
||||
// A server started from inside a Claude Code session (the bootstrap fallback,
|
||||
// always the case on macOS) inherits that session's own variables, and tmux
|
||||
// hands them to every lane pane — whose `claude` then believes it is nested.
|
||||
// ponytail: a DENYLIST of parent-session variables only, so user config
|
||||
// (CLAUDE_CONFIG_DIR, CLAUDE_CODE_USE_BEDROCK, ANTHROPIC_*) passes through;
|
||||
// it must grow when Claude Code adds a new per-session variable.
|
||||
const PARENT_SESSION_VARS =
|
||||
/^(CLAUDECODE|AI_AGENT|CLAUDE_PID|CLAUDE_PROJECT_DIR|CLAUDE_ENV_FILE|CLAUDE_PLUGIN_(ROOT|DATA)|CLAUDE_CODE_(ENTRYPOINT|SESSION_ID|CHILD_SESSION|SESSION_ATTENDED|EXECPATH|MESSAGING_.*))$/;
|
||||
for (const k of Object.keys(process.env)) {
|
||||
if (PARENT_SESSION_VARS.test(k)) delete process.env[k];
|
||||
}
|
||||
// Claude Code sets GIT_EDITOR=true so its own commits never block; a user's
|
||||
// real editor setting is anything else and stays.
|
||||
if (process.env.GIT_EDITOR === "true") delete process.env.GIT_EDITOR;
|
||||
|
||||
// Load .env file (simple key=value, no external dependency needed)
|
||||
(function loadDotEnv() {
|
||||
const fs = require("fs");
|
||||
@@ -74,6 +89,7 @@ const updatesRouter = require("./routes/updates");
|
||||
const ccConfigRouter = require("./routes/cc-config");
|
||||
const codexConfigRouter = require("./routes/codex-config");
|
||||
const aiRoutersRouter = require("./routes/ai-routers");
|
||||
const gitCredentialsRouter = require("./routes/git-credentials");
|
||||
const runRouter = require("./routes/run");
|
||||
const alertsRouter = require("./routes/alerts");
|
||||
const webhooksRouter = require("./routes/webhooks");
|
||||
@@ -117,6 +133,7 @@ function createApp() {
|
||||
app.use("/api/cc-config", ccConfigRouter);
|
||||
app.use("/api/codex-config", codexConfigRouter);
|
||||
app.use("/api/ai-routers", aiRoutersRouter);
|
||||
app.use("/api/git-credentials", gitCredentialsRouter);
|
||||
app.use("/api/run", runRouter);
|
||||
app.use("/api/lanes", lanesRouter);
|
||||
app.use("/api/locks", locksRouter);
|
||||
@@ -325,6 +342,9 @@ function startBackgroundServices() {
|
||||
// One-time legacy-session backfill (a no-op once its marker file exists).
|
||||
autoImportLegacySessions();
|
||||
|
||||
// Rates for models seen in token_usage with no pricing row of their own.
|
||||
require("./lib/pricing-sync").startPricingSync(require("./db").db);
|
||||
|
||||
// A provisioning job belongs to the previous Node process and cannot survive
|
||||
// a restart. Recover those rows now so the dashboard never shows a permanent
|
||||
// provisioning spinner for work that was interrupted by the restart.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file The git credential helper AgentHub installs on its own network git
|
||||
* commands (`-c credential.helper=!node <this>`, see git-credentials.js).
|
||||
* Speaks git's credential protocol: `get` reads `key=value` lines on stdin
|
||||
* and answers `username=` / `password=` for a matching protocol + host —
|
||||
* first from `AGENTHUB_GIT_ONCE` (a credential typed for this one command),
|
||||
* then from the store file named by `AGENTHUB_GIT_CRED_FILE`. `store` and
|
||||
* `erase` do nothing: AgentHub saves only what the user asked it to keep, and
|
||||
* only after git has proved it works. Prints nothing else, ever.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
|
||||
if (process.argv[2] !== "get") process.exit(0);
|
||||
|
||||
let input = "";
|
||||
process.stdin.on("data", (c) => (input += c));
|
||||
process.stdin.on("end", () => {
|
||||
const want = {};
|
||||
for (const line of input.split("\n")) {
|
||||
const i = line.indexOf("=");
|
||||
if (i > 0) want[line.slice(0, i)] = line.slice(i + 1);
|
||||
}
|
||||
const protocol = String(want.protocol || "").toLowerCase();
|
||||
const host = String(want.host || "").toLowerCase();
|
||||
const matches = (r) => r && r.protocol === protocol && r.host === host && r.password;
|
||||
|
||||
let hit = null;
|
||||
try {
|
||||
const once = JSON.parse(process.env.AGENTHUB_GIT_ONCE || "null");
|
||||
if (matches(once)) hit = once;
|
||||
} catch {
|
||||
/* malformed: fall through to the store */
|
||||
}
|
||||
if (!hit && process.env.AGENTHUB_GIT_CRED_FILE) {
|
||||
try {
|
||||
const rows = JSON.parse(fs.readFileSync(process.env.AGENTHUB_GIT_CRED_FILE, "utf8"));
|
||||
hit = (Array.isArray(rows) ? rows : []).find(matches) || null;
|
||||
} catch {
|
||||
/* no store yet */
|
||||
}
|
||||
}
|
||||
// A helper that answers nothing lets git fail with its own auth error,
|
||||
// which the dashboard turns into the login form.
|
||||
if (hit) process.stdout.write(`username=${hit.username}\npassword=${hit.password}\n`);
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @file AgentHub's own git credentials — what a dashboard fetch/pull/push
|
||||
* authenticates with when the machine's git has nothing (issue #45). The
|
||||
* dashboard runs git with no terminal, so without this a remote needing a
|
||||
* login fails with "could not read Username … terminal prompts disabled".
|
||||
*
|
||||
* One JSON file under `<CLAUDE_HOME>/agenthub-git-credentials/` (dir 0700,
|
||||
* file 0600, written through a temp file), one record per protocol + host,
|
||||
* shared by everyone using this AgentHub. Like the AI Router store, **no route
|
||||
* ever returns a password** — `list` reports `hasToken`.
|
||||
*
|
||||
* `gitConfigFor` is how a credential reaches git: `-c` args that make OUR
|
||||
* helper (git-credential-helper.js) the ONLY one, plus the env it reads. Only
|
||||
* ours, because a stale token in the machine's helper would win over the one
|
||||
* just typed, and git's post-success `store` would copy a use-once token into
|
||||
* the OS user's own helper. The secret itself never reaches argv.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
|
||||
const HOST_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d{1,5})?$/;
|
||||
const PROTOCOLS = new Set(["https", "http"]);
|
||||
const HELPER = path.join(__dirname, "git-credential-helper.js");
|
||||
|
||||
function err(code, message) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
function credentialsFile() {
|
||||
return path.join(getClaudeHome(), "agenthub-git-credentials", "credentials.json");
|
||||
}
|
||||
|
||||
/** `{protocol, host}` normalised, or EBADHOST — a host is also a lookup key. */
|
||||
function key(protocol, host) {
|
||||
const p = String(protocol || "").toLowerCase();
|
||||
const h = String(host || "").toLowerCase();
|
||||
if (!PROTOCOLS.has(p) || !HOST_RE.test(h)) {
|
||||
throw err("EBADHOST", "protocol must be http(s) and host a plain host[:port]");
|
||||
}
|
||||
return { protocol: p, host: h };
|
||||
}
|
||||
|
||||
function readAll() {
|
||||
try {
|
||||
const rows = JSON.parse(fs.readFileSync(credentialsFile(), "utf8"));
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeAll(rows) {
|
||||
const file = credentialsFile();
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${file}.agenthub-tmp`;
|
||||
fs.writeFileSync(tmp, `${JSON.stringify(rows, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
function list() {
|
||||
return readAll().map(({ protocol, host, username, savedAt }) => ({
|
||||
protocol,
|
||||
host,
|
||||
username,
|
||||
savedAt,
|
||||
hasToken: true,
|
||||
}));
|
||||
}
|
||||
|
||||
function find(protocol, host) {
|
||||
const k = key(protocol, host);
|
||||
return readAll().find((r) => r.protocol === k.protocol && r.host === k.host) || null;
|
||||
}
|
||||
|
||||
function save({ protocol, host, username, password }) {
|
||||
const k = key(protocol, host);
|
||||
if (typeof username !== "string" || !username.trim() || /[\r\n]/.test(username)) {
|
||||
throw err("EBADCRED", "username is required");
|
||||
}
|
||||
if (typeof password !== "string" || !password || /[\r\n]/.test(password)) {
|
||||
throw err("EBADCRED", "password or access token is required");
|
||||
}
|
||||
const rows = readAll().filter((r) => !(r.protocol === k.protocol && r.host === k.host));
|
||||
rows.push({ ...k, username: username.trim(), password, savedAt: new Date().toISOString() });
|
||||
writeAll(rows);
|
||||
}
|
||||
|
||||
function remove(protocol, host) {
|
||||
const k = key(protocol, host);
|
||||
const rows = readAll();
|
||||
const kept = rows.filter((r) => !(r.protocol === k.protocol && r.host === k.host));
|
||||
if (kept.length === rows.length) return false;
|
||||
writeAll(kept);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* git's wording for "I needed a login and did not get one (or it was wrong)",
|
||||
* and the URL it names. null for every other failure.
|
||||
*/
|
||||
function authFailure(stderr) {
|
||||
const text = String(stderr || "");
|
||||
if (
|
||||
!/could not read (Username|Password)|Authentication failed|terminal prompts disabled|Invalid username or password/i.test(
|
||||
text
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const m = text.match(/'(https?):\/\/(?:[^@'/]*@)?([^/'\s]+)/i);
|
||||
if (!m) return null;
|
||||
try {
|
||||
return key(m[1], m[2]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The http(s) hosts a repo's remotes point at. */
|
||||
async function remoteHosts(dir) {
|
||||
// Required lazily: worktree.js is heavy and only this path needs it.
|
||||
const { git } = require("./worktree");
|
||||
const out = await git(dir, ["remote", "-v"]).then(
|
||||
(r) => r.stdout,
|
||||
() => ""
|
||||
);
|
||||
const hosts = new Map();
|
||||
// A username in the URL (https://vi@host/…) pre-fills the login form; a
|
||||
// password in it (user:pass@) is never read back out.
|
||||
for (const m of out.matchAll(/\t(https?):\/\/(?:([^@\s/:]*)(?::[^@\s/]*)?@)?([^/\s]+)/gi)) {
|
||||
try {
|
||||
const k = key(m[1], m[3]);
|
||||
hosts.set(`${k.protocol}://${k.host}`, {
|
||||
...k,
|
||||
username: m[2] ? decodeURIComponent(m[2]) : null,
|
||||
});
|
||||
} catch {
|
||||
/* not a host we can hold a credential for */
|
||||
}
|
||||
}
|
||||
return [...hosts.values()];
|
||||
}
|
||||
|
||||
const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
||||
|
||||
/**
|
||||
* The `-c` args and env a network git command runs with. `once` is a
|
||||
* credential from the request, accepted only for one of the repo's own
|
||||
* remote hosts — a key typed for one server is never sent to another.
|
||||
*/
|
||||
async function gitConfigFor(dir, once) {
|
||||
const hosts = await remoteHosts(dir);
|
||||
const env = {};
|
||||
if (once) {
|
||||
const k = key(once.protocol, once.host);
|
||||
if (!hosts.some((h) => h.protocol === k.protocol && h.host === k.host)) {
|
||||
throw err("EBADHOST", `${k.host} is not a remote of this repository`);
|
||||
}
|
||||
env.AGENTHUB_GIT_ONCE = JSON.stringify({
|
||||
...k,
|
||||
username: String(once.username || ""),
|
||||
password: String(once.password || ""),
|
||||
});
|
||||
} else if (!hosts.some((h) => find(h.protocol, h.host))) {
|
||||
return { args: [], env: {} };
|
||||
}
|
||||
// ponytail: while AgentHub answers for one host, a remote on ANOTHER host
|
||||
// gets no machine helper either (see the file header); chain the machine's
|
||||
// helpers for unknown hosts if mixed-host `fetch --all` ever matters.
|
||||
env.AGENTHUB_GIT_CRED_FILE = credentialsFile();
|
||||
return {
|
||||
args: [
|
||||
"-c",
|
||||
"credential.helper=",
|
||||
"-c",
|
||||
`credential.helper=!${shq(process.execPath)} ${shq(HELPER)}`,
|
||||
],
|
||||
env,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
credentialsFile,
|
||||
list,
|
||||
find,
|
||||
save,
|
||||
remove,
|
||||
authFailure,
|
||||
remoteHosts,
|
||||
gitConfigFor,
|
||||
};
|
||||
+411
-77
@@ -16,13 +16,40 @@
|
||||
* total) because this rides an HTTP response a browser has to render: a lane
|
||||
* mid-`npm install` or holding a committed lockfile would otherwise ship
|
||||
* megabytes of patch to a React list. A cap that bites sets `truncated`.
|
||||
*
|
||||
* The lane's cwd is the boundary, even when it is a SUBDIRECTORY of its repo:
|
||||
* every path in and out is cwd-relative, status entries outside the cwd are
|
||||
* dropped, and every git call treats a path as a literal file name, never a
|
||||
* pathspec glob (`GIT_LITERAL_PATHSPECS`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { git } = require("./worktree");
|
||||
const worktree = require("./worktree");
|
||||
|
||||
/** Every git call here: argv only, and a path argument is a literal name. */
|
||||
const git = (dir, args, opts) => worktree.git(dir, args, { literal: true, ...opts });
|
||||
|
||||
/**
|
||||
* A git READ whose output this module caps itself: the buffer is sized past
|
||||
* the cap (x4, a UTF-8 char is at most 4 bytes, plus headroom) so git's own
|
||||
* buffer can never be what fails first, and an overflow comes back as the
|
||||
* partial output — longer than `capChars`, so the caller's cap marks it
|
||||
* truncated — instead of an error read as "missing".
|
||||
*/
|
||||
async function readCapped(dir, args, capChars) {
|
||||
try {
|
||||
return (await git(dir, args, { maxBuffer: capChars * 4 + 64 * 1024 })).stdout;
|
||||
} catch (err) {
|
||||
if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" && err.git?.stdout) {
|
||||
return String(err.git.stdout);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Most files one response names. Beyond this the list is `truncated`. */
|
||||
const MAX_FILES = 300;
|
||||
@@ -36,6 +63,8 @@ const MAX_UNTRACKED_BYTES = 1_000_000;
|
||||
const MAX_FILE_VIEW_BYTES = 1_000_000;
|
||||
/** Most paths `listPaths`/`listTree` answer with. */
|
||||
const MAX_PATHS = 500;
|
||||
/** Characters of `ls-files -z` read before the listing counts as truncated. */
|
||||
const MAX_LS_CHARS = 16 * 1024 * 1024;
|
||||
/** Lines of unchanged context around a hunk, and the ceiling "expand all"
|
||||
* is clamped to — a whole large file rendered as context is the one way this
|
||||
* panel can be made to ship more bytes than the caps above allow. */
|
||||
@@ -47,6 +76,104 @@ const VALID_RANGES = new Set(["work", "branch", "commit"]);
|
||||
* ref name without the characters that would let it read as an option or a
|
||||
* pathspec. `git` is called with an argv array, so this is belt-and-braces. */
|
||||
const SAFE_REV = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,200}$/;
|
||||
/** The file whose CONTENT this module never returns, at any rev and at ANY
|
||||
* depth: `settings.local.json` is where router-apply.js writes a lane's AI
|
||||
* Router API key, and key material never leaves the server. Any depth,
|
||||
* because a lane at the repo root sees a subdirectory lane's key as
|
||||
* `sub/.claude/settings.local.json`. Lower-cased: a case-insensitive
|
||||
* filesystem (macOS) opens it under any casing. */
|
||||
const SECRET_FILE = ".claude/settings.local.json";
|
||||
const SECRET_BASENAME = path.posix.basename(SECRET_FILE);
|
||||
|
||||
/** Whether a lane-relative path names a secret file, however it is spelled. */
|
||||
function isSecret(rel) {
|
||||
const norm = path.posix
|
||||
.normalize(String(rel).replace(/\\/g, "/"))
|
||||
.replace(/^\.\//, "")
|
||||
.toLowerCase();
|
||||
// By BASENAME, in any directory: a `.claude` symlinked to `conf/` stores the
|
||||
// key as `conf/settings.local.json`, and a lane above that one (or a commit
|
||||
// of that target) never spells it through `.claude/`. A file with this name
|
||||
// is Claude Code's local settings in practice, so nothing legitimate is lost.
|
||||
return norm === SECRET_FILE || path.posix.basename(norm) === SECRET_BASENAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `file` in the lane is a secret by what it RESOLVES to, not by how
|
||||
* it is spelled: a symlinked `.claude` (→ `conf/`) makes the key file
|
||||
* `conf/settings.local.json`, which no name check catches. Secret when the
|
||||
* real path, relative to the lane's real root, is one by `isSecret`, or when
|
||||
* it is the very file `<lane>/.claude/settings.local.json` resolves to. A
|
||||
* path that does not exist on disk is judged by its name alone.
|
||||
*/
|
||||
async function isSecretOnDisk(dir, file) {
|
||||
if (isSecret(file)) return true;
|
||||
let root;
|
||||
let real;
|
||||
try {
|
||||
root = await fs.promises.realpath(dir);
|
||||
real = await fs.promises.realpath(path.resolve(root, file));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (isSecret(path.relative(root, real))) return true;
|
||||
try {
|
||||
return real === (await fs.promises.realpath(path.join(root, SECRET_FILE)));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE lexical guard for a caller-supplied path, at every rev: a relative
|
||||
* path with no `..` segment, normalised (`./a//b` → `a/b`) so the secret
|
||||
* check and git both see the lane-relative spelling. Throws EBADPATH.
|
||||
*/
|
||||
function lanePath(file) {
|
||||
if (typeof file !== "string" || !file || file.includes("\0")) {
|
||||
throw badRequest("EBADPATH", "path must be a relative path inside the lane");
|
||||
}
|
||||
// A backslash is an ordinary file-name byte to git on POSIX, not a separator.
|
||||
if (file.startsWith("/") || file.split("/").includes("..")) {
|
||||
throw badRequest("EBADPATH", "path must be a relative path inside the lane");
|
||||
}
|
||||
const norm = path.posix
|
||||
.normalize(file)
|
||||
.replace(/^(\.\/)+/, "")
|
||||
.replace(/\/+$/, "");
|
||||
if (!norm || norm === ".") throw badRequest("EBADPATH", "path must name a file");
|
||||
return norm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `file` inside the lane for a CONTENT read: realpath-confined to the
|
||||
* lane's own realpath, so neither `..` nor a symlink can walk out of it, and
|
||||
* refused when either the requested or the resolved path is a secret file.
|
||||
* Throws EBADPATH / ESECRET / ENOFILE.
|
||||
*/
|
||||
async function resolveInLane(dir, file) {
|
||||
if (isSecret(file))
|
||||
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
|
||||
const escapes = (base, target) => {
|
||||
const r = path.relative(base, target);
|
||||
return !r || r === ".." || r.startsWith(`..${path.sep}`) || path.isAbsolute(r);
|
||||
};
|
||||
// Lexically first, so `../x` is EBADPATH whether or not x exists.
|
||||
if (escapes(path.resolve(dir), path.resolve(dir, file))) {
|
||||
throw badRequest("EBADPATH", "path escapes the lane directory");
|
||||
}
|
||||
const root = await fs.promises.realpath(dir);
|
||||
let real;
|
||||
try {
|
||||
real = await fs.promises.realpath(path.resolve(root, file));
|
||||
} catch {
|
||||
throw badRequest("ENOFILE", "no such file in the working tree");
|
||||
}
|
||||
if (escapes(root, real)) throw badRequest("EBADPATH", "path escapes the lane directory");
|
||||
if (await isSecretOnDisk(root, file))
|
||||
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
|
||||
return real;
|
||||
}
|
||||
|
||||
/** Throw a coded error the routes map onto a 400. */
|
||||
function badRequest(code, message) {
|
||||
@@ -131,15 +258,26 @@ async function resolveBaseRef(dir, base) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `git diff --numstat` output → Map<path, {added, deleted, binary}>. */
|
||||
/**
|
||||
* `git diff --numstat -z` output → Map<path, {added, deleted, binary}>. `-z`
|
||||
* because without it git C-quotes any path with a space, a quote or a
|
||||
* non-ASCII byte (`"t\303\240i.txt"`), which then keys nothing. A rename is
|
||||
* `a\td\t\0old\0new\0`; the path that exists now is the key. Shared with
|
||||
* worktree.js's `laneFiles` (the lane card), so the card and the Changes panel
|
||||
* count a file the same way.
|
||||
*/
|
||||
function parseNumstat(stdout) {
|
||||
const stats = new Map();
|
||||
for (const line of stdout.split("\n")) {
|
||||
const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
|
||||
const tokens = stdout.split("\0");
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const m = tokens[i].replace(/^\n/, "").match(/^(\d+|-)\t(\d+|-)\t(.*)$/s);
|
||||
if (!m) continue;
|
||||
// A rename reports "old => new" (or "dir/{a => b}/file"); the path that
|
||||
// exists now is what every other source here is keyed by.
|
||||
const file = m[3].includes(" => ") ? renameTarget(m[3]) : m[3];
|
||||
let file = m[3];
|
||||
if (!file) {
|
||||
file = tokens[i + 2];
|
||||
i += 2;
|
||||
}
|
||||
if (!file) continue;
|
||||
stats.set(file, {
|
||||
added: m[1] === "-" ? null : Number(m[1]),
|
||||
deleted: m[2] === "-" ? null : Number(m[2]),
|
||||
@@ -149,11 +287,48 @@ function parseNumstat(stdout) {
|
||||
return stats;
|
||||
}
|
||||
|
||||
/** `dir/{old => new}/f.js` or `old.js => new.js` → the post-rename path. */
|
||||
function renameTarget(spec) {
|
||||
const braced = spec.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
|
||||
if (braced) return `${braced[1]}${braced[3]}${braced[4]}`.replace(/\/\//g, "/");
|
||||
return spec.split(" => ").pop();
|
||||
/**
|
||||
* `git status --porcelain=v1 -z` → [{code, path, from}]. Each record is
|
||||
* `XY path\0`; a rename/copy carries its ORIGINAL path as the next record.
|
||||
* Shared with git-ops.js so the paths a write may name are the paths shown.
|
||||
*/
|
||||
function parsePorcelainZ(stdout) {
|
||||
const entries = [];
|
||||
const tokens = stdout.split("\0");
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const rec = tokens[i];
|
||||
if (rec.length < 4) continue;
|
||||
const code = rec.slice(0, 2);
|
||||
const entry = { code, path: rec.slice(3), from: null };
|
||||
if (code[0] === "R" || code[0] === "C" || code[1] === "R" || code[1] === "C") {
|
||||
entry.from = tokens[++i] || null;
|
||||
}
|
||||
entries.push(entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* `git status` for the LANE: porcelain paths are always repo-ROOT-relative,
|
||||
* while every pathspec and every fs read here resolves from the lane's cwd.
|
||||
* So entries are cut down to the ones inside the cwd and re-spelled relative
|
||||
* to it — a subdirectory lane neither shows nor can name a change outside
|
||||
* itself. A rename whose original path is outside keeps `from: null`.
|
||||
* Shared with git-ops.js so the paths a write may name are the paths shown.
|
||||
*/
|
||||
async function laneStatus(dir) {
|
||||
const [status, prefix] = await Promise.all([
|
||||
git(dir, ["status", "--porcelain=v1", "-z", "--untracked-files=normal"]),
|
||||
git(dir, ["rev-parse", "--show-prefix"]),
|
||||
]);
|
||||
const pre = prefix.stdout.trim();
|
||||
const inside = (p) => (p && p.startsWith(pre) ? p.slice(pre.length) || null : null);
|
||||
const entries = [];
|
||||
for (const e of parsePorcelainZ(status.stdout)) {
|
||||
const rel = inside(e.path);
|
||||
if (rel) entries.push({ code: e.code, path: rel, from: inside(e.from) });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,22 +340,20 @@ const CONFLICT_CODES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
|
||||
|
||||
/** Working-tree summary: porcelain status, plus numstat deltas for tracked files. */
|
||||
async function summarizeWork(dir) {
|
||||
const [status, numstat] = await Promise.all([
|
||||
git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]),
|
||||
git(dir, ["diff", "--numstat", "HEAD"]).catch(() => ({ stdout: "" })),
|
||||
const [entries, numstat] = await Promise.all([
|
||||
laneStatus(dir),
|
||||
// --relative: cwd-scoped, cwd-relative paths, so they key the status ones.
|
||||
git(dir, ["diff", "--numstat", "-z", "--relative", "HEAD"]).catch(() => ({ stdout: "" })),
|
||||
]);
|
||||
const stats = parseNumstat(numstat.stdout);
|
||||
const files = [];
|
||||
for (const line of status.stdout.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
const code = line.slice(0, 2);
|
||||
const raw = line.slice(3).trimEnd();
|
||||
const file = raw.split(" -> ").pop().replace(/^"|"$/g, "");
|
||||
for (const { code, path: file } of entries) {
|
||||
const untracked = code === "??";
|
||||
const stat = stats.get(file);
|
||||
// One read per untracked file, not two: its line count and its
|
||||
// binary-ness are the same buffer answered twice.
|
||||
const fresh = untracked ? untrackedFacts(dir, file) : null;
|
||||
// Sequential on purpose: `normal` folds untracked dirs, so this is short.
|
||||
const fresh = untracked ? await untrackedFacts(dir, file) : null;
|
||||
files.push({
|
||||
path: file,
|
||||
status: code,
|
||||
@@ -208,11 +381,12 @@ async function summarizeWork(dir) {
|
||||
* everywhere else in this response. A file over the cap counts as binary so
|
||||
* nothing downstream tries to render it.
|
||||
*/
|
||||
function untrackedFacts(dir, file) {
|
||||
async function untrackedFacts(dir, file) {
|
||||
try {
|
||||
const abs = path.join(dir, file);
|
||||
if (fs.statSync(abs).size > MAX_UNTRACKED_BYTES) return { lines: null, binary: true };
|
||||
const buf = fs.readFileSync(abs);
|
||||
const abs = await resolveInLane(dir, file);
|
||||
if ((await fs.promises.stat(abs)).size > MAX_UNTRACKED_BYTES)
|
||||
return { lines: null, binary: true };
|
||||
const buf = await fs.promises.readFile(abs);
|
||||
if (looksBinary(buf)) return { lines: null, binary: true };
|
||||
const text = buf.toString("utf8");
|
||||
if (!text) return { lines: 0, binary: false };
|
||||
@@ -220,7 +394,8 @@ function untrackedFacts(dir, file) {
|
||||
return { lines, binary: false };
|
||||
} catch {
|
||||
// A directory entry (git reports untracked DIRS with a trailing slash), a
|
||||
// dangling symlink, a file deleted between the status call and this read.
|
||||
// dangling symlink, a file deleted between the status call and this read,
|
||||
// or one resolveInLane refuses (a secret, a symlink out of the lane).
|
||||
return { lines: null, binary: false };
|
||||
}
|
||||
}
|
||||
@@ -229,8 +404,8 @@ function untrackedFacts(dir, file) {
|
||||
async function summarizeBranch(dir, baseRef) {
|
||||
const spec = `${baseRef}...HEAD`;
|
||||
const [nameStatus, numstat] = await Promise.all([
|
||||
git(dir, ["diff", "--name-status", spec]),
|
||||
git(dir, ["diff", "--numstat", spec]),
|
||||
git(dir, ["diff", "--name-status", "-z", "--relative", spec]),
|
||||
git(dir, ["diff", "--numstat", "-z", "--relative", spec]),
|
||||
]);
|
||||
return parseNameStatus(nameStatus.stdout, parseNumstat(numstat.stdout));
|
||||
}
|
||||
@@ -242,24 +417,26 @@ async function summarizeBranch(dir, baseRef) {
|
||||
* a reader scrolling a branch's history.
|
||||
*/
|
||||
async function summarizeCommit(dir, sha) {
|
||||
const base = ["show", "--format=", "-m", "--first-parent", sha];
|
||||
const base = ["show", "--format=", "-m", "--first-parent", "--relative", sha];
|
||||
const [nameStatus, numstat] = await Promise.all([
|
||||
git(dir, [...base, "--name-status"]),
|
||||
git(dir, [...base, "--numstat"]),
|
||||
git(dir, [...base, "--name-status", "-z"]),
|
||||
git(dir, [...base, "--numstat", "-z"]),
|
||||
]);
|
||||
return parseNameStatus(nameStatus.stdout, parseNumstat(numstat.stdout));
|
||||
}
|
||||
|
||||
/** Shared `--name-status` reader for the ranges that have no working tree. */
|
||||
/** Shared `--name-status -z` reader for the ranges that have no working tree. */
|
||||
function parseNameStatus(stdout, stats) {
|
||||
const files = [];
|
||||
const seen = new Set();
|
||||
for (const line of stdout.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
const parts = line.split("\t");
|
||||
const letter = parts[0][0];
|
||||
// A rename/copy line is "R100\told\tnew" — the new path is the live one.
|
||||
const file = parts.length > 2 ? parts[2] : parts[1];
|
||||
const tokens = stdout.split("\0");
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const status = tokens[i].replace(/^\n/, "");
|
||||
if (!/^[A-Z]/.test(status)) continue;
|
||||
const letter = status[0];
|
||||
// A rename/copy is "R100\0old\0new" — the new path is the live one.
|
||||
const file = letter === "R" || letter === "C" ? tokens[(i += 2)] : tokens[++i];
|
||||
if (!file) continue;
|
||||
// `-m` prints one diff per parent for a merge; --first-parent keeps it to
|
||||
// one, but a defensive de-dup costs nothing and a doubled row is a lie.
|
||||
if (seen.has(file)) continue;
|
||||
@@ -282,6 +459,9 @@ function parseNameStatus(stdout, stats) {
|
||||
|
||||
/** One file's patch text, for any range. Empty string when there is none. */
|
||||
async function patchFor(dir, { range, baseRef, sha, file, untracked, context }) {
|
||||
// A secret file may be tracked by mistake; its hunks would carry the key —
|
||||
// under its own name, or under a symlinked `.claude`'s target name.
|
||||
if (await isSecretOnDisk(dir, file)) return "";
|
||||
if (untracked) return synthesizeAddedPatch(dir, file);
|
||||
const u = `-U${context}`;
|
||||
const args =
|
||||
@@ -290,10 +470,7 @@ async function patchFor(dir, { range, baseRef, sha, file, untracked, context })
|
||||
: range === "branch"
|
||||
? ["diff", u, `${baseRef}...HEAD`, "--", file]
|
||||
: ["diff", u, "HEAD", "--", file];
|
||||
const result = await git(dir, [...args, "--no-color", "--no-ext-diff"]).catch(() => ({
|
||||
stdout: "",
|
||||
}));
|
||||
return result.stdout;
|
||||
return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_FILE_BYTES).catch(() => "");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,10 +479,10 @@ async function patchFor(dir, { range, baseRef, sha, file, untracked, context })
|
||||
* --no-index /dev/null <file>` would print, without the non-zero exit that
|
||||
* wrapper treats as a failure.
|
||||
*/
|
||||
function synthesizeAddedPatch(dir, file) {
|
||||
async function synthesizeAddedPatch(dir, file) {
|
||||
let buf;
|
||||
try {
|
||||
buf = fs.readFileSync(path.join(dir, file));
|
||||
buf = await fs.promises.readFile(await resolveInLane(dir, file));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
@@ -440,7 +617,9 @@ 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`];
|
||||
if (file) args.push("--", file);
|
||||
// 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));
|
||||
const result = await git(dir, args);
|
||||
const commits = [];
|
||||
for (const record of result.stdout.split("\x1e")) {
|
||||
@@ -471,42 +650,64 @@ async function listCommits(dir, { limit = 30, file = null } = {}) {
|
||||
* `rev` is `work` (the file on disk, including an untracked one), `HEAD`, or a
|
||||
* commit-ish. The path is resolved against git's own index/tree rather than
|
||||
* trusted: for `work` it is realpath-confined to the lane directory, because
|
||||
* that is the one branch that reads the filesystem directly.
|
||||
* that is the one branch that reads the filesystem directly. A SECRET_PATHS
|
||||
* file is refused (ESECRET) at every rev.
|
||||
*/
|
||||
async function readFileAt(dir, file, rev = "work") {
|
||||
if (typeof file !== "string" || !file || file.startsWith("/") || file.includes("\0")) {
|
||||
throw badRequest("EBADPATH", "path must be a relative path inside the lane");
|
||||
}
|
||||
async function readFileAt(dir, requested, rev = "work") {
|
||||
// Lexical guard FIRST and for every rev: `git show <rev>:<path>` reads the
|
||||
// whole repo, so a `../` here is a committed file outside the lane.
|
||||
const file = lanePath(requested);
|
||||
if (rev !== "work" && !SAFE_REV.test(rev)) {
|
||||
throw badRequest("EBADREV", "rev is not a valid revision");
|
||||
}
|
||||
// By resolved path too, at every rev: the key committed under a symlinked
|
||||
// `.claude`'s target name is still the key.
|
||||
if (await isSecretOnDisk(dir, file)) {
|
||||
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
|
||||
}
|
||||
|
||||
let text;
|
||||
let binary = false;
|
||||
let sha;
|
||||
if (rev === "work") {
|
||||
const abs = path.resolve(dir, file);
|
||||
// `..` in the request must not walk out of the lane. Compare resolved
|
||||
// paths, not the string the caller sent.
|
||||
const root = path.resolve(dir);
|
||||
if (abs !== root && !abs.startsWith(root + path.sep)) {
|
||||
throw badRequest("EBADPATH", "path escapes the lane directory");
|
||||
}
|
||||
// Neither `..` nor a symlink may walk out of the lane: resolveInLane
|
||||
// compares REAL paths, not the string the caller sent.
|
||||
const abs = await resolveInLane(dir, file);
|
||||
let buf;
|
||||
try {
|
||||
buf = fs.readFileSync(abs);
|
||||
// Only cap+1 bytes: a multi-gigabyte log in the lane must not be read
|
||||
// whole to answer with its first megabyte. The extra byte is what
|
||||
// tells the cap below the file was longer.
|
||||
const fh = await fs.promises.open(abs, "r");
|
||||
try {
|
||||
const size = Math.min((await fh.stat()).size, MAX_FILE_VIEW_BYTES + 1);
|
||||
buf = Buffer.alloc(size);
|
||||
let off = 0;
|
||||
while (off < size) {
|
||||
const { bytesRead } = await fh.read(buf, off, size - off, off);
|
||||
if (!bytesRead) break;
|
||||
off += bytesRead;
|
||||
}
|
||||
buf = buf.subarray(0, off);
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
} catch {
|
||||
throw badRequest("ENOFILE", "no such file in the working tree");
|
||||
}
|
||||
binary = looksBinary(buf);
|
||||
text = binary ? "" : buf.toString("utf8");
|
||||
// What an editor must send back to save over THIS content and nothing
|
||||
// newer (lane-file-write.js). Only for a whole, textual file.
|
||||
if (!binary && buf.length <= MAX_FILE_VIEW_BYTES) sha = contentSha(buf);
|
||||
} else {
|
||||
let out;
|
||||
try {
|
||||
out = await git(dir, ["show", `${rev}:${file}`]);
|
||||
// `./`: resolve from the lane's cwd, not the repo root (a bare
|
||||
// `<rev>:<path>` is root-relative) — `file` has no `..`, so it stays in.
|
||||
text = await readCapped(dir, ["show", `${rev}:./${file}`], MAX_FILE_VIEW_BYTES);
|
||||
} catch {
|
||||
throw badRequest("ENOFILE", "no such file at that revision");
|
||||
}
|
||||
text = out.stdout;
|
||||
binary = text.includes("\0");
|
||||
if (binary) text = "";
|
||||
}
|
||||
@@ -519,7 +720,12 @@ async function readFileAt(dir, file, rev = "work") {
|
||||
}
|
||||
const lines = text ? text.split("\n") : [];
|
||||
if (lines[lines.length - 1] === "") lines.pop();
|
||||
return { path: file, rev, binary, truncated, lines };
|
||||
return { path: file, rev, binary, truncated, lines, ...(sha ? { sha } : {}) };
|
||||
}
|
||||
|
||||
/** The version stamp of a file's bytes, for a stale-save check. */
|
||||
function contentSha(buf) {
|
||||
return crypto.createHash("sha1").update(buf).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -530,12 +736,11 @@ async function readFileAt(dir, file, rev = "work") {
|
||||
* skip-list has to be maintained here.
|
||||
*/
|
||||
async function listPaths(dir, { q = "", limit = MAX_PATHS } = {}) {
|
||||
const result = await git(dir, ["ls-files", "-z"]);
|
||||
const { all, cut } = await lsFiles(dir, []);
|
||||
const needle = String(q || "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
const cap = Math.min(Math.max(Number(limit) || MAX_PATHS, 1), MAX_PATHS);
|
||||
const all = result.stdout.split("\0").filter(Boolean);
|
||||
const hits = needle ? all.filter((p) => p.toLowerCase().includes(needle)) : all;
|
||||
// A basename hit beats a directory hit: typing "api" wants `api.ts`, not
|
||||
// every file under `src/api/`.
|
||||
@@ -546,48 +751,177 @@ async function listPaths(dir, { q = "", limit = MAX_PATHS } = {}) {
|
||||
return ab - bb || a.length - b.length || a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
return { paths: hits.slice(0, cap), truncated: hits.length > cap, total: hits.length };
|
||||
return { paths: hits.slice(0, cap), truncated: cut || hits.length > cap, total: hits.length };
|
||||
}
|
||||
|
||||
/** `git ls-files -z [args]` → paths, and whether a huge listing was cut short. */
|
||||
async function lsFiles(dir, args) {
|
||||
const out = await readCapped(dir, ["ls-files", "-z", ...args], MAX_LS_CHARS);
|
||||
const all = out.split("\0");
|
||||
// A complete listing ends in NUL (last token ""); a cut one ends mid-path.
|
||||
const cut = all.pop() !== "";
|
||||
return { all: all.filter(Boolean), cut };
|
||||
}
|
||||
|
||||
/** What the explorer lists: tracked files plus untracked ones git does not
|
||||
* ignore — the new file an agent just wrote is the one you want to open. */
|
||||
const TREE_LS = ["--cached", "--others", "--exclude-standard"];
|
||||
|
||||
/**
|
||||
* A lane's working-copy status for decorating the explorer, from ONE
|
||||
* `laneStatus`: `fileStatus(path)` is the porcelain code of a changed file
|
||||
* (untracked inside a folded untracked dir included), `dirChanged(path)` is
|
||||
* whether anything under a folder is.
|
||||
*/
|
||||
async function treeStatus(dir) {
|
||||
const codes = new Map();
|
||||
const folded = []; // `normal` reports a wholly untracked folder as "dir/"
|
||||
const dirs = new Set();
|
||||
for (const { code, path: p } of await laneStatus(dir).catch(() => [])) {
|
||||
if (p.endsWith("/")) folded.push(p);
|
||||
else codes.set(p, code);
|
||||
const parts = p.replace(/\/$/, "").split("/");
|
||||
for (let i = 1; i <= parts.length - (p.endsWith("/") ? 0 : 1); i++) {
|
||||
dirs.add(parts.slice(0, i).join("/"));
|
||||
}
|
||||
}
|
||||
const inFolded = (p) => folded.some((f) => p.startsWith(f));
|
||||
return {
|
||||
fileStatus: (p) => codes.get(p) ?? (inFolded(p) ? "??" : undefined),
|
||||
dirChanged: (p) => dirs.has(p) || inFolded(`${p}/`),
|
||||
};
|
||||
}
|
||||
|
||||
/** A folded level's entries, each carrying its status (see `treeStatus`). */
|
||||
function treeEntries(prefix, dirNames, files, st) {
|
||||
return [
|
||||
...[...dirNames].sort().map((name) => {
|
||||
const p = `${prefix}${name}`;
|
||||
return { name, path: p, dir: true, ...(st.dirChanged(p) ? { changed: true } : {}) };
|
||||
}),
|
||||
...files
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((f) => {
|
||||
const status = st.fileStatus(f.path);
|
||||
return status ? { ...f, status } : f;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* One directory level of the working tree, for the file explorer. Tracked
|
||||
* entries only (same `.gitignore` reasoning as `listPaths`), folded into
|
||||
* One directory level of the working tree, for the file explorer: tracked
|
||||
* files plus untracked-but-not-ignored ones (`TREE_LS`), folded into
|
||||
* directories at this level — a tree that lists a repo's every file at once is
|
||||
* both slow to render and not what "expand a folder" means.
|
||||
* both slow to render and not what "expand a folder" means. A changed file
|
||||
* carries its porcelain `status`, a folder holding one `changed: true`.
|
||||
*/
|
||||
async function listTree(dir, { subdir = "" } = {}) {
|
||||
const clean = String(subdir || "")
|
||||
.replace(/^\/+|\/+$/g, "")
|
||||
.trim();
|
||||
if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the lane");
|
||||
const args = ["ls-files", "-z"];
|
||||
if (clean) args.push("--", `${clean}/`);
|
||||
const result = await git(dir, args);
|
||||
const [{ all, cut }, st] = await Promise.all([
|
||||
lsFiles(dir, clean ? [...TREE_LS, "--", `${clean}/`] : TREE_LS),
|
||||
treeStatus(dir),
|
||||
]);
|
||||
const prefix = clean ? `${clean}/` : "";
|
||||
const dirs = new Set();
|
||||
const files = [];
|
||||
for (const p of result.stdout.split("\0").filter(Boolean)) {
|
||||
for (const p of all) {
|
||||
const rest = p.slice(prefix.length);
|
||||
const slash = rest.indexOf("/");
|
||||
if (slash === -1) files.push({ name: rest, path: p, dir: false });
|
||||
else dirs.add(rest.slice(0, slash));
|
||||
}
|
||||
const entries = [
|
||||
...[...dirs].sort().map((name) => ({ name, path: `${prefix}${name}`, dir: true })),
|
||||
...files.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
];
|
||||
const entries = treeEntries(prefix, dirs, files, st);
|
||||
return {
|
||||
path: clean,
|
||||
entries: entries.slice(0, MAX_PATHS),
|
||||
truncated: entries.length > MAX_PATHS,
|
||||
truncated: cut || entries.length > MAX_PATHS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ONE hunk of a tracked file's working-copy diff (`git diff HEAD`, the same
|
||||
* read the Changes panel shows), as a standalone patch `git apply` accepts:
|
||||
* the file's own header lines plus that hunk. The hunk is named by its `@@`
|
||||
* line and matched against a FRESH diff at the caller's context, so what gets
|
||||
* applied is always git's own text of the current tree — never a patch a
|
||||
* request carried. Null when no hunk matches (the tree moved on, or the file
|
||||
* is a secret, binary or too large to read whole).
|
||||
*/
|
||||
async function hunkPatch(dir, file, header, context) {
|
||||
if (!header || (await isSecretOnDisk(dir, file))) return null;
|
||||
const u = `-U${normalizeContext(context)}`;
|
||||
const raw = await readCapped(
|
||||
dir,
|
||||
["diff", u, "HEAD", "--no-color", "--no-ext-diff", "--", file],
|
||||
MAX_FILE_BYTES
|
||||
).catch(() => "");
|
||||
// A capped read may end mid-hunk; applying half a hunk is worse than none.
|
||||
if (!raw || raw.length >= MAX_FILE_BYTES) return null;
|
||||
const at = raw.indexOf("\n@@ ");
|
||||
if (at < 0) return null;
|
||||
const head = raw.slice(0, at + 1);
|
||||
// A content line starts with " ", "+", "-" or "\\" — never "@@" — so every
|
||||
// line that does is a hunk boundary.
|
||||
const hunk = raw
|
||||
.slice(at + 1)
|
||||
.split(/^(?=@@ )/m)
|
||||
.find((h) => h.slice(0, h.indexOf("\n")) === header);
|
||||
return hunk ? head + (hunk.endsWith("\n") ? hunk : `${hunk}\n`) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every level of the explorer tree (tracked + untracked-not-ignored) from ONE `git ls-files`, keyed by directory
|
||||
* path ("" is the root), each level shaped and capped exactly like
|
||||
* `listTree`'s. "Expand all folders" used to be one request — one ls-files —
|
||||
* per folder, which on a large repo is thousands of git processes.
|
||||
*/
|
||||
async function listTreeAll(dir) {
|
||||
const [{ all, cut }, st] = await Promise.all([lsFiles(dir, TREE_LS), treeStatus(dir)]);
|
||||
const byDir = new Map();
|
||||
const level = (d) => {
|
||||
let l = byDir.get(d);
|
||||
if (!l) byDir.set(d, (l = { dirs: new Set(), files: [] }));
|
||||
return l;
|
||||
};
|
||||
level("");
|
||||
for (const p of all) {
|
||||
const parts = p.split("/");
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
level(parts.slice(0, i).join("/")).dirs.add(parts[i]);
|
||||
}
|
||||
const parent = parts.slice(0, -1).join("/");
|
||||
level(parent).files.push({ name: parts[parts.length - 1], path: p, dir: false });
|
||||
}
|
||||
const levels = {};
|
||||
let truncated = cut;
|
||||
for (const [d, { dirs, files }] of byDir) {
|
||||
const entries = treeEntries(d ? `${d}/` : "", dirs, files, st);
|
||||
if (entries.length > MAX_PATHS) truncated = true;
|
||||
levels[d] = entries.slice(0, MAX_PATHS);
|
||||
}
|
||||
return { levels, truncated };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectChanges,
|
||||
CONFLICT_CODES,
|
||||
contentSha,
|
||||
isSecretOnDisk,
|
||||
lanePath,
|
||||
looksBinary,
|
||||
resolveInLane,
|
||||
MAX_FILE_VIEW_BYTES,
|
||||
hunkPatch,
|
||||
laneStatus,
|
||||
listCommits,
|
||||
listPaths,
|
||||
listTree,
|
||||
listTreeAll,
|
||||
parseNumstat,
|
||||
parsePorcelainZ,
|
||||
parseUnifiedDiff,
|
||||
readFileAt,
|
||||
resolveBaseRef,
|
||||
|
||||
+376
-36
@@ -1,37 +1,58 @@
|
||||
/**
|
||||
* @file The git WRITES the dashboard offers on a lane: fetch, pull, push,
|
||||
* stage/unstage a file, commit, discard a file, switch branch, and create or
|
||||
* delete a branch. Everything here is deliberately the boring half of git —
|
||||
* there is no merge, no rebase, no reset, no stash and no conflict
|
||||
* resolution, because a dashboard button is the wrong place to start an
|
||||
* stage/unstage a file, commit, discard a file, switch branch, create or
|
||||
* delete a branch, publish a repo that has no remote yet, and merge a branch
|
||||
* with its conflict resolution (issue #45: merge, resolve ours/theirs/
|
||||
* hand-edited, merge-commit, merge-abort). There is no rebase, no reset and
|
||||
* no stash, because a dashboard button is the wrong place to start an
|
||||
* operation whose recovery needs a terminal (`reset` stays where it was:
|
||||
* behind the three-check destroy guard in worktree.js).
|
||||
* behind the three-check destroy guard in worktree.js). A merge is the
|
||||
* exception because its recovery is ONE button here too — `merge-abort` puts
|
||||
* the branch back where it was.
|
||||
*
|
||||
* Four rules hold for every action:
|
||||
* - argv only, via worktree.js's `git()` — never a shell string.
|
||||
* - the lane's own working directory is the only cwd.
|
||||
* - the lane's own working directory is the only cwd, and its boundary:
|
||||
* in a lane that is a subdirectory of its repo, paths are cwd-relative
|
||||
* and nothing outside the cwd can be named or swept into a commit.
|
||||
* - every path is a LITERAL file name, never a pathspec glob — `new*.js`
|
||||
* must not also discard new1.js (`literal: true` on every call).
|
||||
* - a caller-supplied path is never trusted as a path: it must be one
|
||||
* `git status` is reporting as changed right now (`resolvePaths`), so no
|
||||
* `..`, no absolute path and no untouched file is reachable.
|
||||
* - `pull` is `--ff-only` and `switch` demands a clean tree, so neither can
|
||||
* invent a merge commit or a conflict underneath a running agent.
|
||||
* invent a merge commit or a conflict underneath a running agent. A merge
|
||||
* happens only when asked for by name (`merge`), on a clean tree, from a
|
||||
* lane that IS the repo root (a merge is repo-wide), one at a time.
|
||||
*
|
||||
* `discard` is the ONE irreversible action here — it throws away work git has
|
||||
* no copy of — so it additionally demands an explicit `confirm: true` in the
|
||||
* body. Do not add a second destructive action without the same gate.
|
||||
* `discard`, `discard-hunk` and `merge-abort` throw away work git has no copy
|
||||
* of, so each demands an explicit `confirm: true` in the body. Do not add
|
||||
* another destructive action without the same gate.
|
||||
*
|
||||
* `publish` is the ONE action whose destination comes from the request (a
|
||||
* remote URL), so it is gated differently: only when `git remote` prints
|
||||
* nothing, always named `origin`, the URL allowlisted by `validateRemoteUrl`,
|
||||
* and the remote removed again if the push fails.
|
||||
*
|
||||
* Network actions carry a timeout: a lane pointed at an unreachable remote
|
||||
* should fail in half a minute, not hold an HTTP request open.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
git,
|
||||
git: rawGit,
|
||||
isValidBranchName,
|
||||
listBranches,
|
||||
branchCheckedOutAt,
|
||||
statusCounts,
|
||||
} = require("./worktree");
|
||||
const { hunkPatch, laneStatus, CONFLICT_CODES } = require("./git-diff");
|
||||
const gitCredentials = require("./git-credentials");
|
||||
|
||||
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
|
||||
|
||||
/** How long a fetch/pull/push may run before it is killed. */
|
||||
const NETWORK_TIMEOUT_MS = 60_000;
|
||||
@@ -47,10 +68,29 @@ const ACTIONS = new Set([
|
||||
"stage",
|
||||
"unstage",
|
||||
"discard",
|
||||
"stage-hunk",
|
||||
"discard-hunk",
|
||||
"branch-create",
|
||||
"branch-delete",
|
||||
"publish",
|
||||
"merge",
|
||||
"resolve",
|
||||
"merge-commit",
|
||||
"merge-abort",
|
||||
]);
|
||||
|
||||
/** Actions that would tangle with a merge in progress. */
|
||||
const BLOCKED_WHILE_MERGING = new Set(["pull", "switch", "merge", "stage-hunk", "discard-hunk"]);
|
||||
|
||||
/** A line git writes to open or close a conflict block. */
|
||||
const MARKER_RE = /^(<{7} |>{7} |={7}$)/m;
|
||||
|
||||
/** A remote URL is at most this long — a forge URL, not a payload. */
|
||||
const MAX_URL_LEN = 1024;
|
||||
/** `user@host:path`, git's scp-like ssh form. Nothing may start with `-`. */
|
||||
const SCP_LIKE =
|
||||
/^[A-Za-z0-9._][A-Za-z0-9._-]*@[A-Za-z0-9][A-Za-z0-9.-]*:[A-Za-z0-9._~/][A-Za-z0-9._~/-]*$/;
|
||||
|
||||
/** Branches this module refuses to delete, whatever the request says. */
|
||||
const PROTECTED = new Set(["main", "master", "development", "develop"]);
|
||||
|
||||
@@ -60,6 +100,43 @@ function bad(code, message) {
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* The destination `publish` may push to, or an EBADURL throw.
|
||||
*
|
||||
* An allowlist, because this is the one write whose destination comes from
|
||||
* the request: `https://` with NO userinfo (a token typed into the URL would
|
||||
* land in .git/config in plain text), `ssh://`, and scp-like `user@host:path`.
|
||||
* Everything else is refused — a local path or `file://` (a push into some
|
||||
* other directory on this machine), `ext::` (git runs it as a command),
|
||||
* `fd::`, `git://` and `http://` (unauthenticated / cleartext), and anything
|
||||
* starting with `-` that could be read as an option.
|
||||
*/
|
||||
function validateRemoteUrl(url) {
|
||||
const refuse = () => bad("EBADURL", "url must be https://, ssh:// or user@host:path");
|
||||
if (typeof url !== "string" || !url || url.length > MAX_URL_LEN) throw refuse();
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\s\x00-\x1f\x7f]/.test(url) || url.startsWith("-")) throw refuse();
|
||||
if (SCP_LIKE.test(url)) return url;
|
||||
// WHATWG is more forgiving than git: it reads `https:host/x` or `https:\\host`
|
||||
// as https://host/x, where git sees no "://" and dials an ssh host named
|
||||
// "https". So the scheme must be spelled out literally, and the authority
|
||||
// may carry no `%` (git decodes `%2D` into the leading "-" refused below)
|
||||
// and no backslash — the string checked is the string git gets.
|
||||
const scheme = ["https://", "ssh://"].find((p) => url.startsWith(p));
|
||||
if (!scheme || /[%\\]/.test(url.slice(scheme.length).split("/")[0])) throw refuse();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw refuse();
|
||||
}
|
||||
const { protocol, username, password, hostname } = parsed;
|
||||
if (!hostname || hostname.startsWith("-") || password) throw refuse();
|
||||
if (protocol === "https:" && !username) return url;
|
||||
if (protocol === "ssh:" && !username.startsWith("-")) return url;
|
||||
throw refuse();
|
||||
}
|
||||
|
||||
/** Whether the working tree has anything uncommitted (tracked or not). */
|
||||
async function isDirty(dir) {
|
||||
const { dirty, untracked } = await statusCounts(dir);
|
||||
@@ -68,7 +145,9 @@ async function isDirty(dir) {
|
||||
|
||||
/**
|
||||
* The paths a per-file action may name — exactly the ones `git status` is
|
||||
* reporting as changed right now, keyed by their porcelain code.
|
||||
* reporting as changed right now, keyed by path → `{code, from}` (`from` is a
|
||||
* rename's original path). `-z`, so a path git would C-quote (non-ASCII, a
|
||||
* space, a quote) matches the literal path the panel sends.
|
||||
*
|
||||
* This is the whole authorization story for `stage`/`unstage`/`discard`: the
|
||||
* request cannot reach a path git is not already talking about, so no `..`,
|
||||
@@ -76,18 +155,56 @@ async function isDirty(dir) {
|
||||
* that is not in this map is a 400, never a silent no-op.
|
||||
*/
|
||||
async function changedPaths(dir) {
|
||||
const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
|
||||
const map = new Map();
|
||||
for (const line of result.stdout.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
const code = line.slice(0, 2);
|
||||
const raw = line.slice(3).trimEnd();
|
||||
const file = raw.split(" -> ").pop().replace(/^"|"$/g, "");
|
||||
map.set(file, code);
|
||||
}
|
||||
for (const { code, path, from } of await laneStatus(dir)) map.set(path, { code, from });
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote a push goes to — never a name from the request: the current
|
||||
* branch's configured upstream remote, else the ONLY remote, else `origin`.
|
||||
* Anything else is ambiguous, so ENOREMOTE rather than a guess.
|
||||
*/
|
||||
async function pushRemote(dir) {
|
||||
const remotes = (await git(dir, ["remote"])).stdout.split("\n").filter(Boolean);
|
||||
const branch = (
|
||||
await git(dir, ["symbolic-ref", "--short", "-q", "HEAD"]).catch(() => ({ stdout: "" }))
|
||||
).stdout.trim();
|
||||
if (branch) {
|
||||
const upstream = (
|
||||
await git(dir, ["config", "--get", `branch.${branch}.remote`]).catch(() => ({ stdout: "" }))
|
||||
).stdout.trim();
|
||||
if (remotes.includes(upstream)) return upstream;
|
||||
}
|
||||
if (remotes.length === 1) return remotes[0];
|
||||
if (remotes.includes("origin")) return "origin";
|
||||
throw bad(
|
||||
"ENOREMOTE",
|
||||
remotes.length
|
||||
? "several remotes and no upstream or origin: set this branch's upstream first"
|
||||
: "this repository has no remote to push to"
|
||||
);
|
||||
}
|
||||
|
||||
/** Throw ESTAGEDOUTSIDE when the index holds a change outside the lane's cwd. */
|
||||
async function assertNothingStagedOutside(dir) {
|
||||
const prefix = (await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim();
|
||||
if (!prefix) return; // the lane IS the repo root: nothing is outside it
|
||||
// No --relative: root-relative names, so outside entries are visible.
|
||||
// --no-renames: a staged move INTO the lane is listed as its new path only
|
||||
// otherwise, and the deletion of the old path outside would slip through.
|
||||
const staged = (await git(dir, ["diff", "--cached", "--name-only", "--no-renames", "-z"])).stdout
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
const outside = staged.find((p) => !p.startsWith(prefix));
|
||||
if (outside) {
|
||||
throw bad(
|
||||
"ESTAGEDOUTSIDE",
|
||||
`refusing to commit: ${outside} is staged outside this lane's directory`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate `body.paths` against what git reports as changed. */
|
||||
async function resolvePaths(dir, body) {
|
||||
const wanted = Array.isArray(body.paths)
|
||||
@@ -104,7 +221,50 @@ async function resolvePaths(dir, body) {
|
||||
if (unknown.length) {
|
||||
throw bad("EBADPATH", `not a changed path: ${unknown[0]}`);
|
||||
}
|
||||
return wanted.map((p) => ({ path: p, code: changed.get(p) }));
|
||||
return wanted.map((p) => ({ path: p, ...changed.get(p) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* A fetch/pull/push, authenticated with AgentHub's own git credentials
|
||||
* (git-credentials.js): a stored one for the remote's host, or
|
||||
* `body.credentials` typed for this one call. A login failure becomes
|
||||
* EGITAUTH carrying the host, which the git bar turns into a login form; a
|
||||
* typed credential is saved only after git accepted it and only if asked.
|
||||
*/
|
||||
async function networkGit(dir, args, body = {}) {
|
||||
const typed = body.credentials && typeof body.credentials === "object" ? body.credentials : null;
|
||||
const cfg = await gitCredentials.gitConfigFor(dir, typed);
|
||||
try {
|
||||
const out = await git(dir, [...cfg.args, ...args], {
|
||||
timeout: NETWORK_TIMEOUT_MS,
|
||||
env: cfg.env,
|
||||
});
|
||||
if (typed?.remember) gitCredentials.save(typed);
|
||||
return out;
|
||||
} catch (e) {
|
||||
const auth = gitCredentials.authFailure(e.git?.stderr);
|
||||
if (!auth) throw e;
|
||||
const remote = (await gitCredentials.remoteHosts(dir)).find(
|
||||
(h) => h.protocol === auth.protocol && h.host === auth.host
|
||||
);
|
||||
const stored = gitCredentials.find(auth.protocol, auth.host);
|
||||
const wrong = Boolean(typed) || Boolean(stored);
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
wrong
|
||||
? `${auth.host} rejected the username or token`
|
||||
: `${auth.host} needs a username and password or access token`
|
||||
),
|
||||
{
|
||||
code: "EGITAUTH",
|
||||
auth: {
|
||||
...auth,
|
||||
username: typed?.username || stored?.username || remote?.username || null,
|
||||
rejected: wrong,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,32 +280,61 @@ async function resolvePaths(dir, body) {
|
||||
*/
|
||||
async function runGitAction(dir, action, body = {}) {
|
||||
if (!ACTIONS.has(action)) throw bad("EBADACTION", `unknown git action ${action}`);
|
||||
if (BLOCKED_WHILE_MERGING.has(action) && (await isMerging(dir))) {
|
||||
throw bad("EMERGING", "a merge is in progress: finish or abort it first");
|
||||
}
|
||||
if (action.startsWith("merge") || action === "resolve") return mergeAction(dir, action, body);
|
||||
|
||||
if (action === "fetch") {
|
||||
return done(
|
||||
action,
|
||||
await git(dir, ["fetch", "--all", "--prune"], { timeout: NETWORK_TIMEOUT_MS })
|
||||
);
|
||||
return done(action, await networkGit(dir, ["fetch", "--all", "--prune"], body));
|
||||
}
|
||||
|
||||
if (action === "pull") {
|
||||
// --ff-only on purpose: a divergent branch is reported, never merged. The
|
||||
// user (or the agent in the pane) decides how to reconcile it.
|
||||
return done(action, await git(dir, ["pull", "--ff-only"], { timeout: NETWORK_TIMEOUT_MS }));
|
||||
return done(action, await networkGit(dir, ["pull", "--ff-only"], body));
|
||||
}
|
||||
|
||||
if (action === "push") {
|
||||
// HEAD, not a branch name from the request: the lane pushes the branch it
|
||||
// is actually on, and -u makes the first push set the upstream the
|
||||
// ahead/behind counts then read.
|
||||
return done(
|
||||
action,
|
||||
await git(dir, ["push", "-u", "origin", "HEAD"], { timeout: NETWORK_TIMEOUT_MS })
|
||||
);
|
||||
const remote = await pushRemote(dir);
|
||||
return done(action, await networkGit(dir, ["push", "-u", remote, "HEAD"], body));
|
||||
}
|
||||
|
||||
if (action === "publish") {
|
||||
// Gives a remote to a repo that has NONE, then does what `push` does. It
|
||||
// never re-points an existing remote, so it cannot redirect a repo that
|
||||
// already publishes somewhere, and the remote name is fixed like `push`'s.
|
||||
if (!(await hasHead(dir))) throw bad("ENOCOMMITS", "make the first commit before publishing");
|
||||
if ((await git(dir, ["remote"])).stdout.trim()) {
|
||||
throw bad("EREMOTEEXISTS", "this repo already has a remote");
|
||||
}
|
||||
const url = validateRemoteUrl(body.url);
|
||||
await git(dir, ["remote", "add", "origin", "--", url]);
|
||||
let result;
|
||||
try {
|
||||
result = await networkGit(dir, ["push", "-u", "origin", "HEAD"], body);
|
||||
} catch (err) {
|
||||
// A remote left behind by a failed push (a typo, a missing credential)
|
||||
// would trip EREMOTEEXISTS forever and need a terminal to undo.
|
||||
await git(dir, ["remote", "remove", "origin"]).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
return { ...done(action, result), remote: "origin" };
|
||||
}
|
||||
|
||||
if (action === "stage") {
|
||||
const paths = await resolvePaths(dir, body);
|
||||
// Staging a conflicted file marks it resolved: not while git's markers
|
||||
// are still in it (the same check `resolve` makes).
|
||||
for (const p of paths.filter((p) => CONFLICT_CODES.has(p.code))) {
|
||||
const file = path.join(dir, p.path);
|
||||
if (fs.existsSync(file) && MARKER_RE.test(await fs.promises.readFile(file, "utf8"))) {
|
||||
throw bad("EMARKERS", `${p.path} still has conflict markers`);
|
||||
}
|
||||
}
|
||||
// `add` covers every case the panel can show: a modification, a new file
|
||||
// and a deletion all stage with the same verb.
|
||||
return done(action, await git(dir, ["add", "--", ...paths.map((p) => p.path)]));
|
||||
@@ -159,7 +348,14 @@ async function runGitAction(dir, action, body = {}) {
|
||||
const args =
|
||||
untracked.length === paths.length && !(await hasHead(dir))
|
||||
? ["rm", "--cached", "-r", "--", ...paths.map((p) => p.path)]
|
||||
: ["restore", "--staged", "--", ...paths.map((p) => p.path)];
|
||||
: // A staged rename's ORIGINAL path rides along, as in `discard`:
|
||||
// restoring only the new one leaves the old path's deletion staged.
|
||||
[
|
||||
"restore",
|
||||
"--staged",
|
||||
"--",
|
||||
...paths.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path])),
|
||||
];
|
||||
return done(action, await git(dir, args));
|
||||
}
|
||||
|
||||
@@ -167,7 +363,11 @@ async function runGitAction(dir, action, body = {}) {
|
||||
if (body.confirm !== true) throw bad("ECONFIRM", "discard requires confirm: true");
|
||||
const paths = await resolvePaths(dir, body);
|
||||
const untracked = paths.filter((p) => p.code === "??").map((p) => p.path);
|
||||
const tracked = paths.filter((p) => p.code !== "??").map((p) => p.path);
|
||||
// A rename's ORIGINAL path rides along: restoring only the new one would
|
||||
// drop it from the index and the tree, and leave the old one deleted.
|
||||
const tracked = paths
|
||||
.filter((p) => p.code !== "??")
|
||||
.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]));
|
||||
const out = [];
|
||||
// Untracked files are deleted (git has no copy to restore); tracked ones
|
||||
// go back to HEAD in both gutters, so a staged-then-edited file lands in
|
||||
@@ -181,20 +381,63 @@ async function runGitAction(dir, action, body = {}) {
|
||||
return done(action, mergeResults(out));
|
||||
}
|
||||
|
||||
if (action === "stage-hunk" || action === "discard-hunk") {
|
||||
// Undoing a hunk is as irreversible as discarding a file: same gate.
|
||||
if (action === "discard-hunk" && body.confirm !== true) {
|
||||
throw bad("ECONFIRM", "discard-hunk requires confirm: true");
|
||||
}
|
||||
const [target] = await resolvePaths(dir, { path: body.path });
|
||||
if (target.code === "??") {
|
||||
throw bad("EUNTRACKED", "an untracked file has no hunks; stage or discard the whole file");
|
||||
}
|
||||
const patch = await hunkPatch(
|
||||
dir,
|
||||
target.path,
|
||||
typeof body.hunk === "string" ? body.hunk : "",
|
||||
body.context
|
||||
);
|
||||
if (!patch) throw bad("EHUNKGONE", "that change is no longer in the diff; refresh and retry");
|
||||
// The patch names paths from the repo root, so it is applied there.
|
||||
const top = (await git(dir, ["rev-parse", "--show-toplevel"])).stdout.trim();
|
||||
const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "agenthub-hunk-"));
|
||||
const file = path.join(tmp, "hunk.patch");
|
||||
try {
|
||||
await fs.promises.writeFile(file, patch);
|
||||
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
|
||||
// stage: HEAD -> worktree hunk onto the index; undo: the same hunk,
|
||||
// reversed, off the working tree. A hunk the index or tree no longer
|
||||
// matches fails in git's own words (409), never half-applies.
|
||||
const args =
|
||||
action === "stage-hunk" ? ["apply", "--cached", ...zero] : ["apply", "-R", ...zero];
|
||||
return done(action, await git(top, [...args, file]));
|
||||
} finally {
|
||||
await fs.promises.rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "commit") {
|
||||
const message = typeof body.message === "string" ? body.message.trim() : "";
|
||||
if (!message) throw bad("EBADMESSAGE", "commit message is required");
|
||||
if (message.length > MAX_MESSAGE_LEN) throw bad("EBADMESSAGE", "commit message is too long");
|
||||
// `git commit` takes the WHOLE index, whatever the cwd, and a pathspec
|
||||
// would switch it to committing the working-tree copy (unstaged edits
|
||||
// included) — so a subdirectory lane refuses rather than sweep another
|
||||
// actor's staged work outside it into this lane's commit.
|
||||
await assertNothingStagedOutside(dir);
|
||||
if (body.only === "staged") {
|
||||
// The panel's per-file staging only means something if a commit can
|
||||
// take the index as it stands — no `add -A` sweeping in the rest.
|
||||
const staged = [...(await changedPaths(dir)).values()].some(
|
||||
(code) => code[0] !== " " && code[0] !== "?"
|
||||
({ code }) => code[0] !== " " && code[0] !== "?"
|
||||
);
|
||||
if (!staged) throw bad("ENOCHANGES", "nothing staged to commit");
|
||||
} else {
|
||||
if (!(await isDirty(dir))) throw bad("ENOCHANGES", "nothing to commit");
|
||||
await git(dir, ["add", "-A"]);
|
||||
// The LANE's status, not the repo's: `add -A -- .` below only takes the
|
||||
// lane's files, so a clean subdirectory lane in a repo dirty elsewhere
|
||||
// has nothing to commit — not a git failure after the fact.
|
||||
if (!(await laneStatus(dir)).length) throw bad("ENOCHANGES", "nothing to commit");
|
||||
// `-- .`: the lane's own directory, not the whole repo it sits in.
|
||||
await git(dir, ["add", "-A", "--", "."]);
|
||||
}
|
||||
// -m takes the message as one argv element, so a message containing
|
||||
// newlines, quotes or a leading dash is content, never syntax.
|
||||
@@ -229,6 +472,103 @@ async function runGitAction(dir, action, body = {}) {
|
||||
return done(action, await git(dir, ["switch", branch]), branch);
|
||||
}
|
||||
|
||||
/** Whether a merge is in progress (MERGE_HEAD exists). */
|
||||
async function isMerging(dir) {
|
||||
try {
|
||||
await git(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Paths git still holds as unmerged, repo-wide. */
|
||||
async function unmergedPaths(dir) {
|
||||
return (await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"])).stdout
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** merge / resolve / merge-commit / merge-abort. */
|
||||
async function mergeAction(dir, action, body) {
|
||||
if (action === "merge") {
|
||||
const branch = typeof body.branch === "string" ? body.branch.trim() : "";
|
||||
if (!(await isValidBranchName(dir, branch))) {
|
||||
throw bad("EBADBRANCH", "branch is not a valid git branch name");
|
||||
}
|
||||
// A merge moves the whole repo, so only a lane that IS the repo may start
|
||||
// one — a subdirectory lane could not see, let alone resolve, a conflict
|
||||
// outside its own folder.
|
||||
if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
|
||||
throw bad(
|
||||
"ESUBDIR",
|
||||
"a merge changes the whole repository; run it from a lane at the repo root"
|
||||
);
|
||||
}
|
||||
if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
|
||||
// The ref is looked up, never passed through: git sees the full refname
|
||||
// of a local or remote-tracking branch that exists, nothing else.
|
||||
const refs = (
|
||||
await git(dir, [
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)%00%(refname)",
|
||||
"refs/heads",
|
||||
"refs/remotes",
|
||||
])
|
||||
).stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((l) => l.split("\0"));
|
||||
const ref = refs.find(([short]) => short === branch)?.[1];
|
||||
if (!ref) throw bad("ENOBRANCH", `no branch ${branch}`);
|
||||
try {
|
||||
const out = await git(dir, ["merge", "--no-edit", ref]);
|
||||
return { ...done(action, out, branch), conflicts: [] };
|
||||
} catch (e) {
|
||||
// A conflict is a merge that STARTED: report the files, not a failure.
|
||||
if (!(await isMerging(dir))) throw e;
|
||||
return {
|
||||
...done(action, e.git || { stdout: "", stderr: "" }, branch),
|
||||
conflicts: await unmergedPaths(dir),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "resolve") {
|
||||
const [target] = await resolvePaths(dir, { path: body.path });
|
||||
if (!CONFLICT_CODES.has(target.code))
|
||||
throw bad("EBADPATH", `not a conflicted path: ${target.path}`);
|
||||
const side = body.side;
|
||||
if (!["ours", "theirs", "done"].includes(side))
|
||||
throw bad("EBADSIDE", "side must be ours, theirs or done");
|
||||
const p = target.path;
|
||||
const gone =
|
||||
(side === "ours" && target.code[0] === "D") || (side === "theirs" && target.code[1] === "D");
|
||||
if (side !== "done" && !gone) {
|
||||
await git(dir, ["checkout", `--${side}`, "--", p]);
|
||||
return done(action, await git(dir, ["add", "--", p]));
|
||||
}
|
||||
if (side === "done" && fs.existsSync(path.join(dir, p))) {
|
||||
if (MARKER_RE.test(await fs.promises.readFile(path.join(dir, p), "utf8"))) {
|
||||
throw bad("EMARKERS", `${p} still has conflict markers`);
|
||||
}
|
||||
return done(action, await git(dir, ["add", "--", p]));
|
||||
}
|
||||
// The chosen side deleted it (or the hand edit did): resolved as gone.
|
||||
return done(action, await git(dir, ["rm", "-q", "--", p]));
|
||||
}
|
||||
|
||||
if (action === "merge-abort" && body.confirm !== true) {
|
||||
throw bad("ECONFIRM", "merge-abort requires confirm: true");
|
||||
}
|
||||
if (!(await isMerging(dir))) throw bad("ENOMERGE", "no merge in progress");
|
||||
if (action === "merge-abort") return done(action, await git(dir, ["merge", "--abort"]));
|
||||
// merge-commit
|
||||
const left = await unmergedPaths(dir);
|
||||
if (left.length) throw bad("ECONFLICTS", `${left.length} file(s) still in conflict: ${left[0]}`);
|
||||
return done(action, await git(dir, ["commit", "--no-edit"]));
|
||||
}
|
||||
|
||||
/** Create or delete a branch WITHOUT touching the working tree. */
|
||||
async function branchAction(dir, action, body) {
|
||||
const branch = typeof body.branch === "string" ? body.branch.trim() : "";
|
||||
@@ -283,4 +623,4 @@ function done(action, result, branch) {
|
||||
return { action, ok: true, output, ...(branch ? { branch } : {}) };
|
||||
}
|
||||
|
||||
module.exports = { runGitAction, ACTIONS, PROTECTED, NETWORK_TIMEOUT_MS };
|
||||
module.exports = { runGitAction, validateRemoteUrl, ACTIONS, PROTECTED, NETWORK_TIMEOUT_MS };
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @file Saves one text file in a lane's working tree, for the Changes panel's
|
||||
* single-file editor. The one filesystem WRITE of the lane file surface, kept
|
||||
* out of git-ops.js on purpose: that module only ever touches paths `git
|
||||
* status` reports as changed, and an editor may save any file.
|
||||
*
|
||||
* The guards are the READ path's own (git-diff.js), so the editor can never
|
||||
* write where the viewer could not read: a lexical `lanePath`, a realpath
|
||||
* confined to the lane (no `..`, no symlink out), and no `settings.local.json`
|
||||
* — the lane router's key file — at any depth or through any symlink. On top:
|
||||
* only an EXISTING, textual file within the viewer's size cap, and only over
|
||||
* the exact bytes the editor loaded (`baseSha`, from `readFileAt`), so a save
|
||||
* never silently overwrites what the agent wrote in the meantime (ESTALE).
|
||||
* The file keeps its CRLF line endings and its final newline.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const {
|
||||
contentSha,
|
||||
isSecretOnDisk,
|
||||
lanePath,
|
||||
looksBinary,
|
||||
resolveInLane,
|
||||
MAX_FILE_VIEW_BYTES,
|
||||
} = require("./git-diff");
|
||||
|
||||
function bad(code, message) {
|
||||
return Object.assign(new Error(message), { code });
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite `requested` in lane `dir` with `content`. Throws EBADPATH,
|
||||
* ESECRET, ENOFILE, EBADCONTENT, ETOOBIG, EBINARY or ESTALE.
|
||||
*/
|
||||
async function writeLaneFile(dir, requested, content, baseSha) {
|
||||
const file = lanePath(requested);
|
||||
if (await isSecretOnDisk(dir, file)) {
|
||||
throw bad("ESECRET", "that file holds secrets and is not writable here");
|
||||
}
|
||||
if (typeof content !== "string") throw bad("EBADCONTENT", "content must be a string");
|
||||
if (typeof baseSha !== "string" || !/^[0-9a-f]{40}$/.test(baseSha)) {
|
||||
throw bad("EBADCONTENT", "baseSha is required");
|
||||
}
|
||||
if (Buffer.byteLength(content) > MAX_FILE_VIEW_BYTES) {
|
||||
throw bad("ETOOBIG", "file is too large to edit here");
|
||||
}
|
||||
const abs = await resolveInLane(dir, file);
|
||||
|
||||
let current;
|
||||
try {
|
||||
const stat = await fs.promises.stat(abs);
|
||||
if (!stat.isFile()) throw bad("ENOFILE", "no such file in the working tree");
|
||||
if (stat.size > MAX_FILE_VIEW_BYTES) throw bad("ETOOBIG", "file is too large to edit here");
|
||||
current = await fs.promises.readFile(abs);
|
||||
} catch (err) {
|
||||
if (err.code === "ETOOBIG" || err.code === "ENOFILE") throw err;
|
||||
throw bad("ENOFILE", "no such file in the working tree");
|
||||
}
|
||||
if (looksBinary(current)) throw bad("EBINARY", "binary files are not editable here");
|
||||
if (contentSha(current) !== baseSha) {
|
||||
throw bad("ESTALE", "the file changed on disk since it was opened — reload it first");
|
||||
}
|
||||
|
||||
// A browser textarea hands back LF only, and the viewer drops the final
|
||||
// newline: put back what the file had, so a one-word fix is a one-line diff.
|
||||
const text = current.toString("utf8");
|
||||
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
||||
let out = content.replace(/\r\n/g, "\n");
|
||||
if (eol === "\r\n") out = out.replace(/\n/g, "\r\n");
|
||||
if (text.endsWith("\n") && out && !out.endsWith(eol)) out += eol;
|
||||
|
||||
await fs.promises.writeFile(abs, out);
|
||||
return { path: file };
|
||||
}
|
||||
|
||||
module.exports = { writeLaneFile };
|
||||
+28
-1
@@ -69,6 +69,7 @@ const PATCHABLE = new Set([
|
||||
"gate_decision",
|
||||
"ci_status",
|
||||
"needs_action",
|
||||
"needs_action_session",
|
||||
"links",
|
||||
"notes",
|
||||
"session_id",
|
||||
@@ -160,6 +161,31 @@ function normalizeTitle(value) {
|
||||
return String(value).trim().slice(0, TITLE_MAX);
|
||||
}
|
||||
|
||||
/**
|
||||
* The path the OS calls this directory: macOS hands a session `/private/tmp/x`
|
||||
* for a typed `/tmp/x`, and a case-insensitive volume accepts `~/Code` for
|
||||
* `~/code`, so a lane stored under the typed string would never bind a
|
||||
* session. A path that does not exist yet (a worktree lane is created before
|
||||
* its directory) keeps the string it was given.
|
||||
*/
|
||||
function canonicalCwd(cwd) {
|
||||
// A relative path must stay relative, so createLane still refuses it.
|
||||
if (!cwd.startsWith("/")) return cwd;
|
||||
const trimmed = cwd.replace(/\/+$/, "");
|
||||
// A path that does not exist yet resolves through its deepest existing
|
||||
// ancestor, so /link/new and /real/new still name the same lane.
|
||||
const path = require("node:path");
|
||||
const tail = [];
|
||||
for (let dir = trimmed; ; dir = path.dirname(dir)) {
|
||||
try {
|
||||
return path.join(require("node:fs").realpathSync.native(dir || "/"), ...tail);
|
||||
} catch {
|
||||
if (dir === "/" || !dir) return trimmed;
|
||||
tail.unshift(path.basename(dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createLane({
|
||||
title = "",
|
||||
cwd,
|
||||
@@ -184,7 +210,7 @@ function createLane({
|
||||
)
|
||||
.run(
|
||||
normalizeTitle(title),
|
||||
cwd.replace(/\/+$/, ""),
|
||||
canonicalCwd(cwd),
|
||||
branch,
|
||||
pipeline,
|
||||
kind,
|
||||
@@ -842,6 +868,7 @@ module.exports = {
|
||||
DEAD_SEC,
|
||||
VALID_TOOLS,
|
||||
detectionIsStale,
|
||||
canonicalCwd,
|
||||
createLane,
|
||||
listLanes,
|
||||
getLane,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @file Automatic pricing for Claude models the dashboard has seen but has no
|
||||
* rate row of its own for. Every model id in `token_usage` is reduced to its
|
||||
* family (a `-YYYYMMDD` snapshot suffix dropped); a family with no
|
||||
* `<family>%` row is looked up in LiteLLM's public price list and INSERTED —
|
||||
* never updated, so a rate the user edited in Settings always wins. "Its own
|
||||
* row" is the test, not "some row matches": Opus 5.5 was silently priced as
|
||||
* Opus 5 by the broader `claude-opus-5%` pattern.
|
||||
*
|
||||
* The list is fetched only when something is missing, at most once per
|
||||
* CACHE_MS. Off with AGENTHUB_PRICING_SYNC=0|false|off.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const PRICE_LIST_URL =
|
||||
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
|
||||
const CHECK_MS = 15 * 60_000;
|
||||
const CACHE_MS = 24 * 60 * 60_000;
|
||||
/** A per-MTok rate above this is a parse error, not a price. */
|
||||
const MAX_PER_MTOK = 1000;
|
||||
|
||||
let cached = null; // { at, list }
|
||||
|
||||
/** `claude-opus-5-5-20260101` → `claude-opus-5-5`; null for anything else. */
|
||||
function familyOf(model) {
|
||||
if (typeof model !== "string") return null;
|
||||
const fam = model.replace(/-\d{8}$/, "");
|
||||
return /^claude-[a-z0-9-]+$/.test(fam) ? fam : null;
|
||||
}
|
||||
|
||||
/** `claude-opus-5-5` → `Claude Opus 5.5`. */
|
||||
function displayName(fam) {
|
||||
const [, ...parts] = fam.split("-");
|
||||
const words = [];
|
||||
const nums = [];
|
||||
for (const p of parts) (/^\d+$/.test(p) ? nums : words).push(p);
|
||||
const name = words.map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
|
||||
return `Claude ${name}${nums.length ? " " + nums.join(".") : ""}`;
|
||||
}
|
||||
|
||||
/** One LiteLLM entry → a model_pricing row, or null when it is not usable. */
|
||||
function toRow(fam, e) {
|
||||
if (!e || e.litellm_provider !== "anthropic") return null;
|
||||
const per = (v) => (typeof v === "number" ? v * 1e6 : NaN);
|
||||
const input = per(e.input_cost_per_token);
|
||||
const output = per(e.output_cost_per_token);
|
||||
// Anthropic's published multipliers stand in for a field the list omits.
|
||||
const read = per(e.cache_read_input_token_cost ?? e.input_cost_per_token * 0.1);
|
||||
const w5m = per(e.cache_creation_input_token_cost ?? e.input_cost_per_token * 1.25);
|
||||
const w1h = per(e.cache_creation_input_token_cost_above_1hr ?? e.input_cost_per_token * 2);
|
||||
const rates = [input, output, read, w5m, w1h];
|
||||
if (!rates.every((r) => Number.isFinite(r) && r >= 0 && r <= MAX_PER_MTOK)) return null;
|
||||
if (input === 0 && output === 0) return null;
|
||||
const fast = e.supports_fast_mode ? Number(e.provider_specific_entry?.fast) : 0;
|
||||
const fastOk = Number.isFinite(fast) && fast > 0 && fast <= 10;
|
||||
// Rounded: 0.000004 * 1e6 is 3.9999999999999996, which Settings would show.
|
||||
const r = (n) => Math.round(n * 1e6) / 1e6;
|
||||
return {
|
||||
pattern: `${fam}%`,
|
||||
name: displayName(fam),
|
||||
input: r(input),
|
||||
output: r(output),
|
||||
read: r(read),
|
||||
w5m: r(w5m),
|
||||
w1h: r(w1h),
|
||||
fastIn: fastOk ? r(input * fast) : 0,
|
||||
fastOut: fastOk ? r(output * fast) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Families used in token_usage that have no `<family>%` row of their own. */
|
||||
function missingFamilies(db) {
|
||||
const have = new Set(
|
||||
db
|
||||
.prepare("SELECT model_pattern FROM model_pricing")
|
||||
.all()
|
||||
.map((r) => r.model_pattern)
|
||||
);
|
||||
const out = new Set();
|
||||
for (const { model } of db.prepare("SELECT DISTINCT model FROM token_usage").all()) {
|
||||
const fam = familyOf(model);
|
||||
if (fam && !have.has(`${fam}%`)) out.add(fam);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
async function defaultFetch() {
|
||||
const res = await fetch(PRICE_LIST_URL, { signal: AbortSignal.timeout(20_000) });
|
||||
if (!res.ok) throw new Error(`price list HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* One pass. Returns the patterns inserted. Never throws: pricing is a display
|
||||
* concern and a failed fetch must not reach the server's startup path.
|
||||
*/
|
||||
async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } = {}) {
|
||||
try {
|
||||
const missing = missingFamilies(db);
|
||||
if (missing.length === 0) return [];
|
||||
if (!cached || now - cached.at > CACHE_MS) cached = { at: now, list: await fetchList() };
|
||||
const insert = db.prepare(
|
||||
`INSERT OR IGNORE INTO model_pricing (model_pattern, display_name, input_per_mtok,
|
||||
output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok,
|
||||
fast_input_per_mtok, fast_output_per_mtok) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
const added = [];
|
||||
for (const fam of missing) {
|
||||
const row = toRow(fam, cached.list?.[fam]);
|
||||
if (!row) continue;
|
||||
const { changes } = insert.run(
|
||||
row.pattern,
|
||||
row.name,
|
||||
row.input,
|
||||
row.output,
|
||||
row.read,
|
||||
row.w5m,
|
||||
row.w1h,
|
||||
row.fastIn,
|
||||
row.fastOut
|
||||
);
|
||||
if (changes) added.push(row.pattern);
|
||||
}
|
||||
if (added.length) console.log(`[pricing] added rates for ${added.join(", ")}`);
|
||||
return added;
|
||||
} catch (err) {
|
||||
console.warn("[pricing] sync failed:", err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function startPricingSync(db) {
|
||||
if (/^(0|false|off)$/i.test(process.env.AGENTHUB_PRICING_SYNC || "")) return;
|
||||
void syncPricing(db);
|
||||
setInterval(() => void syncPricing(db), CHECK_MS).unref();
|
||||
}
|
||||
|
||||
function _resetCache() {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
module.exports = { syncPricing, startPricingSync, familyOf, displayName, toRow, _resetCache };
|
||||
@@ -12,10 +12,10 @@
|
||||
|
||||
const tmux = require("./tmux");
|
||||
|
||||
const RUN_ID_RE = /^agenthub-lane-\d+$/;
|
||||
const RUN_ID_RE = /^agenthub-lane-\d+(?:-[1-3])?$/;
|
||||
|
||||
/**
|
||||
* Reject anything that isn't exactly `agenthub-lane-<digits>` before it can ever
|
||||
* Reject anything that isn't exactly `agenthub-lane-<digits>[-<1-3>]` before it can ever
|
||||
* reach a tmux/PTY command — the trust boundary for this WS path, since a
|
||||
* validated runId is the only thing standing between an authenticated WS
|
||||
* client and naming an arbitrary session on the host.
|
||||
@@ -48,7 +48,7 @@ function attach(ws, runId, { cols, rows }) {
|
||||
} catch {
|
||||
/* session gone or tmux unavailable — attach below will surface that */
|
||||
}
|
||||
const pty = spawnImpl("tmux", ["attach-session", "-t", runId], {
|
||||
const pty = spawnImpl("tmux", ["attach-session", "-t", `=${runId}`], {
|
||||
name: "xterm-256color",
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
|
||||
+25
-6
@@ -21,6 +21,10 @@
|
||||
*
|
||||
* Spawns `claude` or `codex` in the tmux pane based on the tool parameter.
|
||||
*
|
||||
* A lane can hold up to four sessions, one per Workspace split pane: pane 0 is
|
||||
* `agenthub-lane-<laneId>` (the lane's own `run_id`), panes 1–3 are
|
||||
* `agenthub-lane-<laneId>-<slot>`.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -35,7 +39,9 @@ try {
|
||||
/* db-less environment, skip persistence */
|
||||
}
|
||||
|
||||
const RUN_ID_RE = /^agenthub-lane-(\d+)$/;
|
||||
const RUN_ID_RE = /^agenthub-lane-(\d+)(?:-([1-3]))?$/;
|
||||
/** Highest pane slot a lane may use (the Workspace's 4-pane grid is 0–3). */
|
||||
const MAX_PANE_SLOT = 3;
|
||||
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
||||
// `auto` is the CLI's own default-ish mode (`claude --permission-mode auto`):
|
||||
// it asks only for what its classifier reads as risky. `default` is kept for
|
||||
@@ -55,8 +61,13 @@ const CODEX_APPROVALS = new Set(["on-request", "never"]);
|
||||
// Codex has no `--effort` flag; reasoning effort is a config override.
|
||||
const CODEX_EFFORTS = new Set(["low", "medium", "high", "xhigh"]);
|
||||
|
||||
function runIdForLane(laneId) {
|
||||
return `agenthub-lane-${laneId}`;
|
||||
function runIdForLane(laneId, slot = 0) {
|
||||
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
|
||||
}
|
||||
|
||||
function slotFromRunId(id) {
|
||||
const m = typeof id === "string" ? id.match(RUN_ID_RE) : null;
|
||||
return m ? Number(m[2] || 0) : null;
|
||||
}
|
||||
|
||||
function laneIdFromRunId(id) {
|
||||
@@ -149,6 +160,7 @@ function buildArgv(args) {
|
||||
function spawnRun(args) {
|
||||
const {
|
||||
laneId,
|
||||
slot = 0,
|
||||
cwd,
|
||||
tool = "claude",
|
||||
model,
|
||||
@@ -164,6 +176,9 @@ function spawnRun(args) {
|
||||
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
|
||||
throw makeErr("EBADLANE", "laneId must be an integer");
|
||||
}
|
||||
if (!Number.isInteger(slot) || slot < 0 || slot > MAX_PANE_SLOT) {
|
||||
throw makeErr("EBADSLOT", `slot must be an integer 0–${MAX_PANE_SLOT}`);
|
||||
}
|
||||
if (typeof cwd !== "string" || !cwd) {
|
||||
throw makeErr("EBADCWD", "cwd is required");
|
||||
}
|
||||
@@ -215,7 +230,7 @@ function spawnRun(args) {
|
||||
);
|
||||
}
|
||||
|
||||
const id = runIdForLane(laneId);
|
||||
const id = runIdForLane(laneId, slot);
|
||||
const startedAt = Date.now();
|
||||
const argv = buildArgv({
|
||||
tool,
|
||||
@@ -307,8 +322,10 @@ function spawnRun(args) {
|
||||
return getRun(id);
|
||||
}
|
||||
|
||||
// Only a dashboard lane pane is a run. Without this, `DELETE /api/run/main`
|
||||
// would kill the user's own tmux session named `main`.
|
||||
function killRun(id) {
|
||||
if (!id || !tmux.hasSession(id)) return false;
|
||||
if (laneIdFromRunId(id) === null || !tmux.hasSession(id)) return false;
|
||||
tmux.killSession(id);
|
||||
if (dashboardRuns) {
|
||||
dashboardRuns.patchRun({ id, status: "killed", endedAt: Date.now() });
|
||||
@@ -323,6 +340,7 @@ function publicRun(id) {
|
||||
return {
|
||||
id,
|
||||
laneId,
|
||||
slot: slotFromRunId(id),
|
||||
status: live ? "running" : "gone",
|
||||
tool: row?.tool || "claude",
|
||||
cwd: row?.cwd || null,
|
||||
@@ -337,7 +355,7 @@ function publicRun(id) {
|
||||
}
|
||||
|
||||
function getRun(id) {
|
||||
if (!id) return null;
|
||||
if (laneIdFromRunId(id) === null) return null;
|
||||
return publicRun(id);
|
||||
}
|
||||
|
||||
@@ -353,5 +371,6 @@ module.exports = {
|
||||
listRuns,
|
||||
laneIdFromRunId,
|
||||
runIdForLane,
|
||||
MAX_PANE_SLOT,
|
||||
SHELL_COMMANDS,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,13 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { isInsideContainer } = require("../../scripts/install-hooks");
|
||||
|
||||
let execImpl = execFileSync;
|
||||
|
||||
/** Test seam — same pattern as tmux.js / desktop-env.js. */
|
||||
function __setExecImpl(fn) {
|
||||
execImpl = fn || execFileSync;
|
||||
}
|
||||
|
||||
const UNAVAILABLE = () => ({ available: false, cwds: new Set() });
|
||||
|
||||
/**
|
||||
@@ -62,7 +69,7 @@ function probeLiveCwds() {
|
||||
|
||||
let psOut;
|
||||
try {
|
||||
psOut = execFileSync("ps", ["-Ao", "pid=,args="], {
|
||||
psOut = execImpl("ps", ["-Ao", "pid=,args="], {
|
||||
encoding: "utf8",
|
||||
timeout: 5_000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
@@ -95,7 +102,7 @@ function probeLiveCwds() {
|
||||
// machine format emits `p<pid>` / `f cwd` / `n<path>` records.
|
||||
let lsofOut;
|
||||
try {
|
||||
lsofOut = execFileSync("lsof", ["-a", "-p", pids.join(","), "-d", "cwd", "-Fn"], {
|
||||
lsofOut = execImpl("lsof", ["-a", "-p", pids.join(","), "-d", "cwd", "-Fn"], {
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
@@ -113,4 +120,4 @@ function probeLiveCwds() {
|
||||
return { available: true, cwds };
|
||||
}
|
||||
|
||||
module.exports = { probeLiveCwds, isClaudeCommand };
|
||||
module.exports = { probeLiveCwds, isClaudeCommand, __setExecImpl };
|
||||
|
||||
+15
-7
@@ -33,9 +33,16 @@ function __reset() {
|
||||
execImpl = (args) => execFileSync("tmux", args, EXEC_OPTS);
|
||||
}
|
||||
|
||||
// tmux resolves a bare `-t agenthub-lane-1` by PREFIX when no exact match
|
||||
// exists, so it would hit `agenthub-lane-12` once lane 1's session is gone —
|
||||
// and killSession would kill the wrong lane. `=` forces an exact match; pane
|
||||
// and option targets need the trailing `:` (tmux 3.4 rejects `=name` there).
|
||||
const session = (name) => `=${name}`;
|
||||
const pane = (name) => `=${name}:`;
|
||||
|
||||
function hasSession(name) {
|
||||
try {
|
||||
execImpl(["has-session", "-t", name]);
|
||||
execImpl(["has-session", "-t", session(name)]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -71,7 +78,7 @@ function newSession({ name, cwd, argv, env }) {
|
||||
// this option existed — or before a server restart picked up the change —
|
||||
// still gets it; `set-option` is idempotent, so re-running it is harmless.
|
||||
function enableMouse(name) {
|
||||
execImpl(["set-option", "-t", name, "mouse", "on"]);
|
||||
execImpl(["set-option", "-t", pane(name), "mouse", "on"]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +89,8 @@ function enableMouse(name) {
|
||||
function paneCommand(name) {
|
||||
try {
|
||||
return (
|
||||
execImpl(["display-message", "-p", "-t", name, "#{pane_current_command}"]).trim() || null
|
||||
execImpl(["display-message", "-p", "-t", pane(name), "#{pane_current_command}"]).trim() ||
|
||||
null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
@@ -107,7 +115,7 @@ function sendCommand(name, argv, env, cwd) {
|
||||
"respawn-pane",
|
||||
"-k",
|
||||
"-t",
|
||||
name,
|
||||
pane(name),
|
||||
...(cwd ? ["-c", cwd] : []),
|
||||
...envFlags(env),
|
||||
"--",
|
||||
@@ -125,14 +133,14 @@ function sendCommand(name, argv, env, cwd) {
|
||||
* only the first line and leave the rest sitting unsent in the input box.
|
||||
*/
|
||||
function sendText(name, text) {
|
||||
execImpl(["send-keys", "-t", name, "-l", text]);
|
||||
execImpl(["send-keys", "-t", name, "Enter"]);
|
||||
execImpl(["send-keys", "-t", pane(name), "-l", text]);
|
||||
execImpl(["send-keys", "-t", pane(name), "Enter"]);
|
||||
}
|
||||
|
||||
/** Idempotent — a session that's already gone is not an error. */
|
||||
function killSession(name) {
|
||||
try {
|
||||
execImpl(["kill-session", "-t", name]);
|
||||
execImpl(["kill-session", "-t", session(name)]);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
|
||||
+101
-10
@@ -30,7 +30,11 @@ const STATUS_FILE_CAP = 20;
|
||||
* these variables. This module's whole job is to run git safely against repos other
|
||||
* than the one enclosing the current working directory.
|
||||
*/
|
||||
async function git(cwd, args, { timeout = 0 } = {}) {
|
||||
async function git(
|
||||
cwd,
|
||||
args,
|
||||
{ timeout = 0, maxBuffer = 8 * 1024 * 1024, literal = false, env: extraEnv = {} } = {}
|
||||
) {
|
||||
// Build a clean environment: copy process.env but scrub git hook variables
|
||||
// that could point to the outer repo's git directory or index.
|
||||
const env = { ...process.env };
|
||||
@@ -52,12 +56,25 @@ async function git(cwd, args, { timeout = 0 } = {}) {
|
||||
}
|
||||
// Prevent credential prompts from hanging a background provisioning job
|
||||
env.GIT_TERMINAL_PROMPT = "0";
|
||||
// Pathspec behaviour is ours to choose, never the parent's. `literal` makes a
|
||||
// path argument name exactly one file: without it `new*.js` or `app/[id]/`
|
||||
// is a GLOB, and `git clean -- 'new*.js'` deletes every match.
|
||||
delete env.GIT_GLOB_PATHSPECS;
|
||||
delete env.GIT_NOGLOB_PATHSPECS;
|
||||
delete env.GIT_ICASE_PATHSPECS;
|
||||
if (literal) env.GIT_LITERAL_PATHSPECS = "1";
|
||||
else delete env.GIT_LITERAL_PATHSPECS;
|
||||
// Caller-supplied last: the git credential helper's inputs (git-credentials.js).
|
||||
Object.assign(env, extraEnv);
|
||||
|
||||
try {
|
||||
const result = await execFileAsync("git", args, {
|
||||
cwd,
|
||||
env,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
// A read with its own size cap passes a bigger buffer and treats an
|
||||
// overflow (err.code ERR_CHILD_PROCESS_STDIO_MAXBUFFER, partial output in
|
||||
// err.git.stdout) as truncated rather than as a failure.
|
||||
maxBuffer,
|
||||
// 0 = no limit, node's own default. A network command (fetch/pull/push)
|
||||
// passes one so an unreachable remote fails instead of pinning a request
|
||||
// open forever; GIT_TERMINAL_PROMPT=0 only covers the prompting case.
|
||||
@@ -624,16 +641,62 @@ async function statusCounts(dir) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get short commit hash
|
||||
const headResult = await git(dir, ["rev-parse", "--short", "HEAD"]);
|
||||
const head = headResult.stdout.trim();
|
||||
// Short commit hash — null in a repo with no commit yet (an unborn HEAD),
|
||||
// which is a normal state for a fresh `git init`, not an unreadable repo.
|
||||
let head = null;
|
||||
try {
|
||||
head = (await git(dir, ["rev-parse", "--short", "HEAD"])).stdout.trim() || null;
|
||||
} catch {
|
||||
/* unborn */
|
||||
}
|
||||
|
||||
return { dirty, untracked, head, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane card's view of the working copy: only entries inside the lane's own
|
||||
* cwd, cwd-relative and read with `-z` — the same scoping the Changes panel
|
||||
* uses (`laneStatus`), so a subdirectory lane's card never counts or lists a
|
||||
* sibling's files, and a non-ASCII name is not shown C-quoted. `statusCounts`
|
||||
* stays whole-repo on purpose: `switch` and the preflight need to know about
|
||||
* ANY dirt a branch change would carry across.
|
||||
*/
|
||||
async function laneFiles(dir) {
|
||||
// Lazy: git-diff.js requires this module, so a top-level require would cycle.
|
||||
const { laneStatus, parseNumstat } = require("./git-diff");
|
||||
const entries = await laneStatus(dir);
|
||||
let stats = new Map();
|
||||
try {
|
||||
// -z --relative: cwd-scoped, cwd-relative, unquoted — keys the entries.
|
||||
stats = parseNumstat(
|
||||
(await git(dir, ["diff", "--numstat", "-z", "--relative", "HEAD"])).stdout
|
||||
);
|
||||
} catch {
|
||||
/* no HEAD to diff against -> no per-file deltas */
|
||||
}
|
||||
let dirty = 0;
|
||||
let untracked = 0;
|
||||
const files = [];
|
||||
for (const e of entries) {
|
||||
if (e.code === "??") untracked++;
|
||||
else dirty++;
|
||||
if (files.length < STATUS_FILE_CAP) {
|
||||
files.push({
|
||||
path: e.path,
|
||||
status: e.code,
|
||||
// Counts only: the card's file record has no `binary` field.
|
||||
added: stats.get(e.path)?.added ?? null,
|
||||
deleted: stats.get(e.path)?.deleted ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { dirty, untracked, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a lane's working copy looks like right now: which branch it is on, the
|
||||
* short HEAD, that commit's subject, and how much is uncommitted.
|
||||
* short HEAD, that commit's subject, and how much is uncommitted. A repo with
|
||||
* no commit yet reports `unborn: true`, `head: null` and an empty subject.
|
||||
*
|
||||
* Read-only and cheap, but it is five subprocesses, which is why it lives
|
||||
* behind its own endpoint rather than inside the polled `GET /api/lanes`
|
||||
@@ -641,10 +704,38 @@ async function statusCounts(dir) {
|
||||
* caller shows what git says rather than inventing a nicer word for it.
|
||||
*/
|
||||
async function gitFacts(dir) {
|
||||
const { dirty, untracked, head, files } = await statusCounts(dir);
|
||||
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
||||
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
|
||||
return { branch, head, subject, dirty, untracked, files, ...(await trackingFacts(dir)) };
|
||||
const { head } = await statusCounts(dir);
|
||||
const { dirty, untracked, files } = await laneFiles(dir);
|
||||
// An unborn HEAD has no commit to abbreviate or log, but it does name the
|
||||
// branch the first commit will land on — symbolic-ref reads that.
|
||||
const branch = head
|
||||
? (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim()
|
||||
: (await git(dir, ["symbolic-ref", "--short", "HEAD"])).stdout.trim();
|
||||
const subject = head ? (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim() : "";
|
||||
return {
|
||||
branch,
|
||||
head,
|
||||
unborn: !head,
|
||||
subject,
|
||||
dirty,
|
||||
untracked,
|
||||
files,
|
||||
...(await trackingFacts(dir)),
|
||||
...(await mergeFacts(dir)),
|
||||
};
|
||||
}
|
||||
|
||||
/** A merge in progress and how many files it still holds unmerged (repo-wide). */
|
||||
async function mergeFacts(dir) {
|
||||
try {
|
||||
await git(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
|
||||
} catch {
|
||||
return { merging: false, conflicts: 0 };
|
||||
}
|
||||
const out = await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"]).catch(() => ({
|
||||
stdout: "",
|
||||
}));
|
||||
return { merging: true, conflicts: out.stdout.split("\0").filter(Boolean).length };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -116,7 +116,7 @@ const paths = {
|
||||
tags: ["Lanes"],
|
||||
summary: "Find or adopt the lane owning a working directory",
|
||||
description:
|
||||
"Idempotent: returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner's lane.",
|
||||
"Idempotent: canonicalizes `cwd` to its real path when it exists (the same path `createLane` stores), then returns the lane whose cwd is an exact match or the longest path-boundary parent of `cwd` with `created: false` (200) — with `exact: true` only an exact match counts, so a directory nested inside another lane gets a lane of its own (the Add-lane modal sends it) — otherwise creates an `adopted` lane and returns it with `created: true` (201). The Workspace page opens on a directory rather than a lane id, so this is how it gets exactly one lane for that directory. Concurrent calls for the same path resolve to ONE lane: the `lanes.cwd` UNIQUE constraint decides, and the loser re-reads and returns the winner's lane.",
|
||||
operationId: "ensureLane",
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -199,7 +199,8 @@ const paths = {
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "sourceRepo is relative, missing, or not a git repository.",
|
||||
description:
|
||||
"EBADSOURCEREPO: sourceRepo is relative, missing, or not a git repository. EEMPTYREPO: it has no commits, so there is nothing to branch a worktree from (no lane row is created).",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
@@ -279,7 +280,7 @@ const paths = {
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"available:true with {branch, head, subject, dirty, untracked, files}, or available:false alone. files names the files behind those counts as {path, status, added, deleted} records, capped at 20; the counts themselves are never capped. status is the two-character `git status --porcelain` code and path is the new path for a rename; added/deleted come from one `git diff --numstat HEAD` and are null where git has no delta to report (untracked, binary, renamed, or no commit to diff against).",
|
||||
'available:true with {branch, head, unborn, subject, dirty, untracked, files, hasRemote, upstream, ahead, behind}, or available:false alone. A repo with no commit yet is available with unborn:true, head:null and subject:"". files names the files behind those counts as {path, status, added, deleted} records, capped at 20; the counts themselves are never capped. status is the two-character `git status --porcelain` code and path is the new path for a rename; added/deleted come from one `git diff --numstat HEAD` and are null where git has no delta to report (untracked, binary, renamed, or no commit to diff against).',
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
@@ -511,7 +512,7 @@ const paths = {
|
||||
tags: ["Lanes"],
|
||||
summary: "The lane's recent commits",
|
||||
description:
|
||||
"Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path. Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.",
|
||||
"Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path (relative, no `..`, else 400 EBADPATH). Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.",
|
||||
operationId: "listLaneCommits",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
@@ -520,6 +521,7 @@ const paths = {
|
||||
],
|
||||
responses: {
|
||||
200: { description: "{available, commits:[{sha, short, subject, author, date, refs}]}." },
|
||||
400: { description: "EBADPATH (file= is absolute or has a `..` segment)." },
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
@@ -538,7 +540,13 @@ const paths = {
|
||||
],
|
||||
responses: {
|
||||
200: { description: "{available, path, rev, binary, truncated, lines}." },
|
||||
400: { description: "EBADPATH, EBADREV or ENOFILE." },
|
||||
400: {
|
||||
description: "EBADPATH (incl. a symlink resolving outside the lane), EBADREV or ENOFILE.",
|
||||
},
|
||||
403: {
|
||||
description:
|
||||
"ESECRET — `.claude/settings.local.json` (the AI Router key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).",
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
@@ -578,6 +586,20 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/tree-all": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "Every level of the tracked tree in one read",
|
||||
description:
|
||||
"The file explorer's expand-all-folders read: ONE git ls-files, grouped into levels keyed by directory (\"\" is the root), each level shaped and capped like GET /api/lanes/{id}/git/tree's entries. Read-only, no same-origin guard.",
|
||||
operationId: "listLaneTreeAll",
|
||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||
responses: {
|
||||
200: { description: "{available, levels:{<dir>:[{name, path, dir}]}, truncated}." },
|
||||
404: { description: "Lane not found." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/branches": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
@@ -597,7 +619,7 @@ const paths = {
|
||||
tags: ["Lanes"],
|
||||
summary: "One git write on the lane's working copy",
|
||||
description:
|
||||
'fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u origin HEAD, so only the branch the lane is on), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), commit (git add -A then commit -m <message>, or only:"staged" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent\'s half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per lane under the lane lock. Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.',
|
||||
"fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <remote> HEAD, so only the branch the lane is on; remote = the branch's upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file; staging a conflicted file that still has conflict markers is 400 EMARKERS), discard (per file; irreversible, so it REQUIRES confirm:true), stage-hunk / discard-hunk (ONE hunk of a tracked file's `git diff HEAD`, named by its `@@` line and re-read server-side at the given context — the applied patch is always git's own text, never one from the request; discard-hunk REQUIRES confirm:true; 400 EUNTRACKED for an untracked file, 409 EHUNKGONE when the hunk is no longer in the diff), commit (git add -A then commit -m <message>, or only:\"staged\" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent's half-finished work is never carried onto another branch), branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree), publish (the ONE write whose destination comes from the request: `git remote add origin -- <url>` then `git push -u origin HEAD`, only in a repo with a commit and NO remote — it never re-points one; the url must be https:// without userinfo, ssh:// or scp-like user@host:path, and origin is removed again if the push fails), and merge / resolve / merge-commit / merge-abort (issue #45: merge a local or remote-tracking branch that exists — clean tree and a repo-root lane only, 400 ESUBDIR otherwise — answering conflicts:[paths] when it stops on conflicts; resolve {path, side: ours|theirs|done} a conflicted path, done refusing leftover markers with 400 EMARKERS; merge-commit is 409 ECONFLICTS while anything is unmerged; merge-abort REQUIRES confirm:true; pull/switch/merge/hunk actions are 409 EMERGING mid-merge). fetch/pull/push/publish needing a login answer 409 EGITAUTH with auth:{protocol,host,username,rejected}; resend with credentials:{protocol,host,username,password,remember} (a repo remote's host only, else 400 EBADHOST). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per git index (the lane lock keyed on the worktree's git dir, so two lanes sharing one index cannot collide on index.lock). Deliberately no rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.",
|
||||
operationId: "runLaneGitAction",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
@@ -614,10 +636,13 @@ const paths = {
|
||||
"stage",
|
||||
"unstage",
|
||||
"discard",
|
||||
"stage-hunk",
|
||||
"discard-hunk",
|
||||
"commit",
|
||||
"switch",
|
||||
"branch-create",
|
||||
"branch-delete",
|
||||
"publish",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -629,6 +654,11 @@ const paths = {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
url: {
|
||||
type: "string",
|
||||
description:
|
||||
"publish only; https:// (no userinfo), ssh:// or user@host:path, max 1024 chars.",
|
||||
},
|
||||
message: { type: "string", description: "commit only; required, max 4000 chars." },
|
||||
only: {
|
||||
type: "string",
|
||||
@@ -646,9 +676,23 @@ const paths = {
|
||||
items: { type: "string" },
|
||||
description: "stage, unstage, discard; each must be a path git reports changed.",
|
||||
},
|
||||
path: {
|
||||
type: "string",
|
||||
description:
|
||||
"stage-hunk, discard-hunk; a path git reports changed (not untracked).",
|
||||
},
|
||||
hunk: {
|
||||
type: "string",
|
||||
description:
|
||||
"stage-hunk, discard-hunk; the hunk's `@@` line as the diff returned it. The server re-reads the diff and applies git's own text of that hunk; a hunk no longer present is 409 EHUNKGONE.",
|
||||
},
|
||||
context: {
|
||||
type: "integer",
|
||||
description: "stage-hunk, discard-hunk; the context the hunk was read at.",
|
||||
},
|
||||
confirm: {
|
||||
type: "boolean",
|
||||
description: "discard only; the call is refused without it.",
|
||||
description: "discard and discard-hunk; the call is refused without it.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -656,13 +700,20 @@ const paths = {
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: { description: "{action, ok:true, output} — git's own stdout+stderr." },
|
||||
200: {
|
||||
description:
|
||||
'{action, ok:true, output} — git\'s own stdout+stderr; publish adds remote:"origin".',
|
||||
},
|
||||
400: {
|
||||
description:
|
||||
"EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY or ENOCHANGES.",
|
||||
"EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY, ENOCHANGES, ESTAGEDOUTSIDE, ENOREMOTE, EUNTRACKED, EBADURL, EREMOTEEXISTS, ENOCOMMITS, EBADHOST, EBADCRED, ESUBDIR, EBADSIDE or EMARKERS.",
|
||||
},
|
||||
404: { description: "Lane not found." },
|
||||
500: { description: "git failed; its stderr is appended to error.message." },
|
||||
409: {
|
||||
description:
|
||||
"EGIT — git ran and refused (a rejected push, a diverged --ff-only pull); its stderr is appended to error.message. EGITAUTH — the remote wants a login (carries auth). EHUNKGONE, EMERGING, ENOMERGE, ECONFLICTS.",
|
||||
},
|
||||
500: { description: "Unexpected server-side failure." },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @file HTTP surface for AgentHub's own git credentials (git-credentials.js):
|
||||
* list, save/replace, delete. Like the AI Router routes, nothing here ever
|
||||
* returns a password — `GET` reports `hasToken` — and every write is behind
|
||||
* the same-origin guard. A lane's fetch/pull/push also saves one, through
|
||||
* `POST /api/lanes/:id/git/:action` with `credentials.remember`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const express = require("express");
|
||||
|
||||
const creds = require("../lib/git-credentials");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function fail(res, e) {
|
||||
const status = e.code === "EBADHOST" || e.code === "EBADCRED" ? 400 : 500;
|
||||
res.status(status).json({ error: { code: e.code || "EINTERNAL", message: e.message } });
|
||||
}
|
||||
|
||||
router.get("/", (_req, res) => {
|
||||
res.json({ items: creds.list() });
|
||||
});
|
||||
|
||||
router.post("/", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const { protocol, host, username, password } = req.body || {};
|
||||
creds.save({ protocol, host, username, password });
|
||||
res.json({ items: creds.list() });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:protocol/:host", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
if (!creds.remove(req.params.protocol, req.params.host)) {
|
||||
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such credential" } });
|
||||
}
|
||||
res.json({ items: creds.list() });
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+18
-3
@@ -210,10 +210,18 @@ function touchLaneFromHook(hookType, data) {
|
||||
if (data.session_id && lane.session_id !== data.session_id) patch.session_id = data.session_id;
|
||||
if (hookType === "Notification" && !idleNudge) {
|
||||
patch.needs_action = data.message || "needs you";
|
||||
} else if (lane.needs_action && data.session_id === lane.session_id) {
|
||||
// Clear only when the hook comes from the session currently bound to the lane
|
||||
// (evaluated before any rebinding, so a rebinding hook never clears in the same pass)
|
||||
patch.needs_action_session = data.session_id || null;
|
||||
} else if (
|
||||
lane.needs_action &&
|
||||
data.session_id &&
|
||||
data.session_id === (lane.needs_action_session || lane.session_id)
|
||||
) {
|
||||
// Clear only from the session that raised it: another session sharing the
|
||||
// cwd rebinds session_id on every hook but is not the one waiting. A flag
|
||||
// with no recorded raiser falls back to the bound session (evaluated
|
||||
// before any rebinding, so a rebinding hook never clears in the same pass).
|
||||
patch.needs_action = null;
|
||||
patch.needs_action_session = null;
|
||||
}
|
||||
|
||||
// Only idle↔running is mirrored: provisioning and failed are lifecycle
|
||||
@@ -1592,6 +1600,13 @@ function livenessReap({ ignoreIdleGate = false } = {}) {
|
||||
continue;
|
||||
}
|
||||
if (probe.cwds.has(resolvedCwd)) continue;
|
||||
// lsof reports the OS's own path (/private/tmp on macOS); a session row may
|
||||
// hold the symlinked or differently-cased spelling of the same directory.
|
||||
try {
|
||||
if (probe.cwds.has(require("fs").realpathSync.native(resolvedCwd))) continue;
|
||||
} catch {
|
||||
/* directory gone — the process cannot be sitting in it either */
|
||||
}
|
||||
|
||||
// Idle gate (watchdog ticks only — boot passes skip it, see above): the
|
||||
// transcript mtime is the ground truth for "when did this session last
|
||||
|
||||
+183
-33
@@ -27,10 +27,12 @@ const { broadcast } = require("../websocket");
|
||||
const runs = require("../lib/pty-run");
|
||||
const { applyRouterToLane } = require("../lib/router-apply");
|
||||
const { sameOriginGuard } = require("./run");
|
||||
const { writeLaneFile } = require("../lib/lane-file-write");
|
||||
const { preflight } = require("../lib/lane-preflight");
|
||||
const {
|
||||
LANES_ROOT,
|
||||
addWorktree,
|
||||
git,
|
||||
gitFacts,
|
||||
isGitRepo,
|
||||
isValidBranchName,
|
||||
@@ -40,7 +42,14 @@ const {
|
||||
resolveBase,
|
||||
slugify,
|
||||
} = require("../lib/worktree");
|
||||
const { collectChanges, listCommits, listPaths, listTree, readFileAt } = require("../lib/git-diff");
|
||||
const {
|
||||
collectChanges,
|
||||
listCommits,
|
||||
listPaths,
|
||||
listTree,
|
||||
listTreeAll,
|
||||
readFileAt,
|
||||
} = require("../lib/git-diff");
|
||||
const { runGitAction } = require("../lib/git-ops");
|
||||
const { withLaneLock } = require("../lib/lane-lock");
|
||||
const { checkSync, mergeSync, continueSync } = require("../lib/lane-sync");
|
||||
@@ -293,7 +302,9 @@ router.get("/branches", async (req, res) => {
|
||||
const worktrees = await listWorktrees(sourceRepo).catch(() => []);
|
||||
res.json({ branches, current, worktrees });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: { code: err.code, message: err.message, git: err.git } });
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: { code: gitErrorCode(err), message: err.message, git: err.git } });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -308,7 +319,10 @@ router.post("/ensure", sameOriginGuard, (req, res) => {
|
||||
// specific directory (e.g. Sagi's mascot companion) get a real, existing
|
||||
// path and lane uniqueness stays meaningful. Callers that do care pass one
|
||||
// through unchanged.
|
||||
const cwd = typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir();
|
||||
// Canonical before the lookup, so the lookup and createLane's insert agree.
|
||||
const cwd = lanesLib.canonicalCwd(
|
||||
typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : os.homedir()
|
||||
);
|
||||
|
||||
if (body.tool !== undefined && !lanesLib.VALID_TOOLS.has(body.tool)) {
|
||||
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
||||
@@ -676,7 +690,7 @@ router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
|
||||
// outside; the pane has to be restarted. Report it instead of letting the
|
||||
// switch look clean and then fail on the user's next prompt.
|
||||
const runId = runs.runIdForLane(lane.id);
|
||||
const live = !!runs.getRun(runId);
|
||||
const live = runs.getRun(runId)?.status === "running";
|
||||
// Every other mutating lane route broadcasts; without it the client's
|
||||
// cached row keeps the OLD router_profile, and the next lane switch
|
||||
// re-reads that stale row and flips the picker back.
|
||||
@@ -808,7 +822,10 @@ router.get("/:id/git/log", async (req, res) => {
|
||||
file: typeof req.query.file === "string" && req.query.file ? req.query.file : null,
|
||||
});
|
||||
res.json({ available: true, commits });
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (err.code === "EBADPATH") {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
res.json({ available: false, commits: [] });
|
||||
}
|
||||
});
|
||||
@@ -817,7 +834,8 @@ router.get("/:id/git/log", async (req, res) => {
|
||||
* One file's whole content at a revision (`?rev=work|HEAD|<sha>`), for the
|
||||
* viewer's "show the entire file" mode. Path validation lives in
|
||||
* git-diff.js's `readFileAt` — the `work` revision is the one branch that
|
||||
* touches the filesystem, and it is realpath-confined to the lane.
|
||||
* touches the filesystem, and it is realpath-confined to the lane (symlinks
|
||||
* included). `.claude/settings.local.json` is 403 ESECRET at every rev.
|
||||
*/
|
||||
router.get("/:id/git/file", async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
@@ -830,6 +848,10 @@ router.get("/:id/git/file", async (req, res) => {
|
||||
);
|
||||
res.json({ available: true, ...file });
|
||||
} catch (err) {
|
||||
// A secret file (the router's API key) is refused at every rev.
|
||||
if (err.code === "ESECRET") {
|
||||
return res.status(403).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (["EBADPATH", "EBADREV", "ENOFILE"].includes(err.code)) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
@@ -837,6 +859,26 @@ router.get("/:id/git/file", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Save one text file in the lane from the viewer's editor. Guards live in
|
||||
* lane-file-write.js (the read path's own, plus existing-textual-file-only and
|
||||
* a `baseSha` stale check). Answers the file re-read, fresh `sha` included.
|
||||
*/
|
||||
router.put("/:id/git/file", sameOriginGuard, async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
const body = req.body || {};
|
||||
try {
|
||||
await writeLaneFile(lane.cwd, body.path, body.content, body.baseSha);
|
||||
res.json({ available: true, ...(await readFileAt(lane.cwd, body.path, "work")) });
|
||||
} catch (err) {
|
||||
const status =
|
||||
{ ESECRET: 403, ESTALE: 409 }[err.code] ??
|
||||
(["EBADPATH", "ENOFILE", "EBADCONTENT", "ETOOBIG", "EBINARY"].includes(err.code) ? 400 : 500);
|
||||
res.status(status).json({ error: { code: err.code || "EWRITE", message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
/** Tracked paths matching `?q=`, for quick-open. */
|
||||
router.get("/:id/git/ls", async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
@@ -848,7 +890,18 @@ router.get("/:id/git/ls", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** One directory level of tracked files, for the file explorer. */
|
||||
/** Every level of the tracked tree in one read, for "expand all folders". */
|
||||
router.get("/:id/git/tree-all", async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
try {
|
||||
res.json({ available: true, ...(await listTreeAll(lane.cwd)) });
|
||||
} catch {
|
||||
res.json({ available: false, levels: {}, truncated: false });
|
||||
}
|
||||
});
|
||||
|
||||
/** One directory level of the explorer tree (tracked + untracked-not-ignored), status-decorated. */
|
||||
router.get("/:id/git/tree", async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
@@ -884,8 +937,10 @@ router.get("/:id/git/branches", async (req, res) => {
|
||||
/**
|
||||
* The git WRITES the dashboard offers: fetch, pull, push, stage, unstage,
|
||||
* discard, commit, switch, branch-create and branch-delete.
|
||||
* Guarded (they mutate), serialized per lane (`withLaneLock`) so a push and a
|
||||
* commit fired from two tabs cannot interleave on one index, and thin — every
|
||||
* Guarded (they mutate), serialized per git index (`withLaneLock` keyed on
|
||||
* the worktree's git dir, so a claude and a codex lane on one directory share
|
||||
* it) so a push and a commit fired from two tabs or two lanes cannot
|
||||
* interleave on one index, and thin — every
|
||||
* rule about what each action may do lives in server/lib/git-ops.js, not here.
|
||||
* `git pull` is --ff-only and `switch` refuses a dirty tree, by design there.
|
||||
*/
|
||||
@@ -893,7 +948,15 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
||||
const lane = laneOr404(req, res);
|
||||
if (!lane) return;
|
||||
try {
|
||||
const result = await withLaneLock(lane.id, () =>
|
||||
// Keyed on the INDEX, not the lane id: a claude and a codex lane on one
|
||||
// cwd — or a root lane and a subdirectory lane of one repo — share one
|
||||
// index, and their writes would otherwise race on index.lock. The git dir
|
||||
// is per worktree, so sibling worktree lanes still run in parallel; a
|
||||
// cwd that is not a repo falls back to its real path.
|
||||
const gitDir = await git(lane.cwd, ["rev-parse", "--absolute-git-dir"])
|
||||
.then((r) => r.stdout.trim())
|
||||
.catch(() => fs.promises.realpath(lane.cwd).catch(() => lane.cwd));
|
||||
const result = await withLaneLock(`git:${gitDir}`, () =>
|
||||
runGitAction(lane.cwd, req.params.action, req.body || {})
|
||||
);
|
||||
res.json(result);
|
||||
@@ -910,8 +973,29 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
||||
"EBADPATH",
|
||||
"ECONFIRM",
|
||||
"EPROTECTED",
|
||||
"ESTAGEDOUTSIDE",
|
||||
"ENOREMOTE",
|
||||
"EUNTRACKED",
|
||||
"EBADHOST",
|
||||
"EBADCRED",
|
||||
"ESUBDIR",
|
||||
"EBADSIDE",
|
||||
"EMARKERS",
|
||||
"EBADURL",
|
||||
"EREMOTEEXISTS",
|
||||
"ENOCOMMITS",
|
||||
];
|
||||
const status = badRequest.includes(err.code) ? 400 : 500;
|
||||
// A numeric code is git's own exit status (execFile reports one that way):
|
||||
// git ran and refused — a rejected push, a failed credential, a diverged
|
||||
// pull. That is the repository's state answering a valid request, not a
|
||||
// server fault, so it is a 409 under the string code the client expects.
|
||||
const exited = typeof err.code === "number";
|
||||
// EHUNKGONE is the same kind of answer: the tree moved under the click.
|
||||
const status = badRequest.includes(err.code)
|
||||
? 400
|
||||
: exited || ["EHUNKGONE", "EGITAUTH", "EMERGING", "ENOMERGE", "ECONFLICTS"].includes(err.code)
|
||||
? 409
|
||||
: 500;
|
||||
// git's own stderr is the useful half of a failed push or pull (a
|
||||
// rejection, a missing credential, a diverged branch), so it joins the
|
||||
// message rather than riding a side field the client would have to know
|
||||
@@ -919,8 +1003,11 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
|
||||
const detail = err.git ? (err.git.stderr || "").trim() : "";
|
||||
res.status(status).json({
|
||||
error: {
|
||||
code: err.code || "EGIT",
|
||||
code: gitErrorCode(err),
|
||||
message: detail ? `${err.message}\n${detail}` : err.message,
|
||||
// EGITAUTH: which host wants a login, for the git bar's form. Never
|
||||
// a secret — just protocol, host, a username hint and `rejected`.
|
||||
...(err.auth ? { auth: err.auth } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -973,6 +1060,24 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// A repo with no commit has nothing a worktree could branch from: git would
|
||||
// fail later, in the background job, and leave a `failed` row behind. Refuse
|
||||
// it here instead — that repo is a Repo lane until its first commit.
|
||||
if (!(await listBranches(sourceRepo)).branches.length) {
|
||||
const unborn = await git(sourceRepo, ["rev-parse", "--verify", "HEAD"]).then(
|
||||
() => false,
|
||||
() => true
|
||||
);
|
||||
if (unborn) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: "EEMPTYREPO",
|
||||
message: "sourceRepo has no commits yet — add it as a Repo lane and commit first",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let slug;
|
||||
try {
|
||||
slug = slugify(body.slug || body.title);
|
||||
@@ -1113,26 +1218,44 @@ function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** Kill a lane run and wait for the tmux session to exit before touching its cwd. */
|
||||
/** Every tmux session a lane holds: its recorded `run_id` plus the extra
|
||||
* split-pane sessions (`agenthub-lane-<id>-<slot>`), which never touch it. */
|
||||
function laneRunIds(lane) {
|
||||
const ids = new Set(
|
||||
runs
|
||||
.listRuns()
|
||||
.filter((r) => r.laneId === lane.id)
|
||||
.map((r) => r.id)
|
||||
);
|
||||
if (lane.run_id) ids.add(lane.run_id);
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/** Kill a lane's runs and wait for their tmux sessions to exit before touching its cwd. */
|
||||
async function stopLaneRun(lane) {
|
||||
if (!lane.run_id) return;
|
||||
try {
|
||||
runs.killRun(lane.run_id);
|
||||
} catch {
|
||||
/* a concurrently completed run is already safe */
|
||||
const ids = laneRunIds(lane);
|
||||
if (ids.length === 0) return;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
runs.killRun(id);
|
||||
} catch {
|
||||
/* a concurrently completed run is already safe */
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + RUN_EXIT_TIMEOUT_MS;
|
||||
let run = runs.getRun(lane.run_id);
|
||||
while (run && run.status !== "gone") {
|
||||
if (Date.now() >= deadline) {
|
||||
throw lifecycleError(
|
||||
"ERUNTIMEOUT",
|
||||
`lane run ${lane.run_id} did not exit within ${RUN_EXIT_TIMEOUT_MS / 1000} seconds`
|
||||
);
|
||||
for (const id of ids) {
|
||||
let run = runs.getRun(id);
|
||||
while (run && run.status !== "gone") {
|
||||
if (Date.now() >= deadline) {
|
||||
throw lifecycleError(
|
||||
"ERUNTIMEOUT",
|
||||
`lane run ${id} did not exit within ${RUN_EXIT_TIMEOUT_MS / 1000} seconds`
|
||||
);
|
||||
}
|
||||
await wait(RUN_EXIT_POLL_MS);
|
||||
run = runs.getRun(id);
|
||||
}
|
||||
await wait(RUN_EXIT_POLL_MS);
|
||||
run = runs.getRun(lane.run_id);
|
||||
}
|
||||
lanesLib.updateLane(lane.id, { run_id: null });
|
||||
}
|
||||
@@ -1154,7 +1277,7 @@ function sendLifecycleError(res, err) {
|
||||
}
|
||||
if (err.git) {
|
||||
return res.status(500).json({
|
||||
error: { code: err.code, message: err.message, stderr: err.git.stderr },
|
||||
error: { code: gitErrorCode(err), message: err.message, stderr: err.git.stderr },
|
||||
});
|
||||
}
|
||||
return res.status(500).json({ error: { code: err.code, message: err.message } });
|
||||
@@ -1207,6 +1330,16 @@ function sendRuntimeError(res, err) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The string `error.code` for an error that may have come out of `git()`.
|
||||
* When git exits non-zero, execFile sets `err.code` to the exit STATUS (a
|
||||
* number, e.g. 128); the client branches on string codes, so that becomes
|
||||
* `EGIT`. A missing code (a timeout kill) is `EGIT` too; string codes pass.
|
||||
*/
|
||||
function gitErrorCode(err) {
|
||||
return typeof err.code === "string" && err.code ? err.code : "EGIT";
|
||||
}
|
||||
|
||||
/** Resolve `:id` or answer 404. Returns null once the response has been sent. */
|
||||
function laneOr404(req, res) {
|
||||
const lane = lanesLib.getLane(req.params.id);
|
||||
@@ -1525,9 +1658,18 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// The pane a `start` targets: 0 is the lane's own session (`run_id`), 1–3
|
||||
// are the Workspace split panes' extra sessions on the same lane.
|
||||
let startedRunId = null;
|
||||
try {
|
||||
switch (action) {
|
||||
case "start": {
|
||||
const slot = body.slot === undefined ? 0 : body.slot;
|
||||
if (!Number.isInteger(slot) || slot < 0 || slot > runs.MAX_PANE_SLOT) {
|
||||
return res.status(400).json({
|
||||
error: { code: "EBADSLOT", message: `slot must be an integer 0–${runs.MAX_PANE_SLOT}` },
|
||||
});
|
||||
}
|
||||
// Overwriting run_id while its child is alive orphans that child: a later
|
||||
// reset would kill and await only the RECORDED run, then `git clean -fd`
|
||||
// the directory the orphan is still writing into. Stop the first run before starting a
|
||||
@@ -1541,12 +1683,14 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
// waited for the lock — the lock makes that race visible instead of
|
||||
// spawning a run for a lane that no longer exists.
|
||||
if (!current) return { missing: true };
|
||||
const live = current.run_id ? runs.getRun(current.run_id) : null;
|
||||
const liveId = slot ? runs.runIdForLane(current.id, slot) : current.run_id;
|
||||
const live = liveId ? runs.getRun(liveId) : null;
|
||||
if (live && live.status === "running") {
|
||||
return { conflict: true };
|
||||
}
|
||||
const handle = runs.spawnRun({
|
||||
laneId: current.id,
|
||||
slot,
|
||||
initialPrompt: body.prompt || "",
|
||||
cwd: current.cwd,
|
||||
// The tool is the LANE's fact, never the request's: a client that
|
||||
@@ -1564,7 +1708,10 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
resumeLast: body.resumeLast === true,
|
||||
resumeSessionId: body.resumeSessionId || (body.resume ? current.session_id : undefined),
|
||||
});
|
||||
lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" });
|
||||
startedRunId = handle.id;
|
||||
// An extra pane's session is not the lane's run: `run_id` stays the
|
||||
// pane-0 session every other path (reset, router restart) names.
|
||||
if (!slot) lanesLib.updateLane(current.id, { run_id: handle.id, status: "running" });
|
||||
return {};
|
||||
});
|
||||
if (outcome.missing) {
|
||||
@@ -1579,9 +1726,9 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
case "stop": {
|
||||
// A lane with no live run is already stopped — say so, don't 500.
|
||||
if (lane.run_id) {
|
||||
for (const id of laneRunIds(lane)) {
|
||||
try {
|
||||
runs.killRun(lane.run_id);
|
||||
runs.killRun(id);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
@@ -1615,7 +1762,10 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
}
|
||||
|
||||
broadcastLane(lane.id);
|
||||
res.json({ lane: payload(lanesLib.getLane(lane.id)) });
|
||||
res.json({
|
||||
lane: payload(lanesLib.getLane(lane.id)),
|
||||
...(startedRunId ? { run_id: startedRunId } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
router.delete("/:id", sameOriginGuard, (req, res) => {
|
||||
|
||||
+55
-1
@@ -19,8 +19,11 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const express = require("express");
|
||||
const { Router } = express;
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const crypto = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
const runs = require("../lib/pty-run");
|
||||
const tmux = require("../lib/tmux");
|
||||
@@ -81,6 +84,57 @@ router.get("/", (_req, res) => {
|
||||
* runs are first. Optional ?laneId=<n> narrows to the runs started through one
|
||||
* lane, which is what the Workspace page's per-lane history shows.
|
||||
*/
|
||||
// ── Drag & drop into the terminal ─────────────────────────────────────────
|
||||
// A local terminal answers a dropped image by typing its PATH, which is how
|
||||
// `claude` attaches it. A browser has no path to give, so the image is saved
|
||||
// here — on the machine the pane runs on — and the client pastes this path.
|
||||
// Only the bytes come from the request: the name is random and the type picks
|
||||
// the extension from a fixed map, so a request never names a file.
|
||||
const DROP_TYPES = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
};
|
||||
const DROP_MAX_BYTES = 20 * 1024 * 1024;
|
||||
const DROP_TTL_MS = 24 * 60 * 60_000;
|
||||
const dropDir = () => path.join(os.tmpdir(), `agenthub-drops-${os.userInfo().uid}`);
|
||||
|
||||
router.post(
|
||||
"/drop-image",
|
||||
express.raw({ type: Object.keys(DROP_TYPES), limit: DROP_MAX_BYTES }),
|
||||
(req, res) => {
|
||||
const ext =
|
||||
DROP_TYPES[
|
||||
String(req.headers["content-type"] || "")
|
||||
.split(";")[0]
|
||||
.trim()
|
||||
];
|
||||
if (!ext || !Buffer.isBuffer(req.body) || req.body.length === 0) {
|
||||
return res.status(400).json({ error: "png, jpeg, gif or webp image body required" });
|
||||
}
|
||||
try {
|
||||
const dir = dropDir();
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
// ponytail: prune-on-write, a day old; a sweep job only if drops pile up.
|
||||
const cutoff = Date.now() - DROP_TTL_MS;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
const f = path.join(dir, name);
|
||||
try {
|
||||
if (fs.statSync(f).mtimeMs < cutoff) fs.unlinkSync(f);
|
||||
} catch {
|
||||
/* raced with another prune — fine */
|
||||
}
|
||||
}
|
||||
const file = path.join(dir, `${crypto.randomBytes(8).toString("hex")}.${ext}`);
|
||||
fs.writeFileSync(file, req.body, { mode: 0o600, flag: "wx" });
|
||||
res.json({ path: file });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.get("/history", (req, res) => {
|
||||
let dr = null;
|
||||
try {
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ function initWebSocket(server) {
|
||||
}
|
||||
|
||||
let ptyWss = null;
|
||||
const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+)$/;
|
||||
const PTY_PATH_RE = /^\/ws-pty\/(agenthub-lane-\d+(?:-[1-3])?)$/;
|
||||
|
||||
/**
|
||||
* The attaching terminal's own size, off the attach URL's query. tmux resizes
|
||||
|
||||
Reference in New Issue
Block a user