feat: per-change-block hunk actions (#50), codex lanes without a pipeline, macOS LaunchAgent
- git (#50): stage-hunk/discard-hunk take an optional `block` — one run of consecutive +/- lines inside an @@ hunk. `onlyBlock` (server/lib/git-diff.js) cuts git's fresh hunk down to it, folding the other blocks into the side being checked; LaneDiffView hangs one review bar per block (unified + split) and its arrows walk blocks. - git exclude: entries AgentHub writes for a lane go to the COMMON git dir with the lane's path prefix (server/lib/git-exclude.js, used by router-apply and lane-mcp) — a linked worktree's own info/exclude is never read by git, and a subdir lane has no .git. MCP sync no longer fails with ENOTDIR on a worktree lane or creates a stray .git in a non-repo lane. - codex lanes carry no pipeline: codex-ingest mirrors only idle/running, codex-nudge is removed, agents install writes nothing for codex, the Workspace hides stage/pipeline/feature pickers/PipelineMap for them, and every server start clears leftover codex stage data and the old AGENTS.md stage block. - Changes panel: a whole file opens for reading and enters the editor on a click on its text (caret at the clicked line), not automatically. - macOS: LaunchAgent start-at-login (scripts/launchd-service.js, picked by scripts/service-manager.js). - Adds the AgentHub introduction slides and their image-embedding script.
This commit is contained in:
@@ -321,7 +321,7 @@ describe("LaneChangesPanel", () => {
|
||||
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 () => {
|
||||
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
path: "",
|
||||
@@ -346,19 +346,24 @@ describe("LaneChangesPanel", () => {
|
||||
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.
|
||||
// Opening a file only shows it: no editor until the text itself is clicked.
|
||||
const view = await screen.findByTestId("lane-file-view");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
await user.click(view);
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("const whole = 1;");
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
expect(editor).toHaveFocus();
|
||||
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.
|
||||
// Cancel drops the edit and goes back to reading.
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("at full width opens a changed file from the overview straight into the editor", async () => {
|
||||
it("at full width opens a changed file from the overview for reading, not editing", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
@@ -372,10 +377,31 @@ describe("LaneChangesPanel", () => {
|
||||
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();
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("a");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not enter the editor when a click only ends a text selection", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "server/lib/git-ops.js",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["copy me"],
|
||||
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"));
|
||||
const view = await screen.findByTestId("lane-file-view");
|
||||
const spy = vi.spyOn(window, "getSelection").mockReturnValue({
|
||||
toString: () => "copy",
|
||||
} as unknown as Selection);
|
||||
await user.click(view);
|
||||
spy.mockRestore();
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("needs two clicks to discard, and only the second one reaches the server", async () => {
|
||||
@@ -615,9 +641,7 @@ describe("LaneChangesPanel", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await user.click(await screen.findByText("src/main.ts"));
|
||||
await screen.findByTestId("lane-file-view");
|
||||
|
||||
await user.click(screen.getByTestId("lane-file-edit"));
|
||||
await user.click(await screen.findByTestId("lane-file-view"));
|
||||
const editor = await screen.findByTestId("lane-file-editor");
|
||||
expect(editor).toHaveValue("const whole = 1;");
|
||||
await user.clear(editor);
|
||||
@@ -676,8 +700,7 @@ describe("LaneChangesPanel", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await user.click(await screen.findByText("src/main.ts"));
|
||||
await screen.findByTestId("lane-file-view");
|
||||
await user.click(screen.getByTestId("lane-file-edit"));
|
||||
await user.click(await screen.findByTestId("lane-file-view"));
|
||||
await user.type(await screen.findByTestId("lane-file-editor"), "y");
|
||||
await user.click(screen.getByTestId("lane-file-save"));
|
||||
|
||||
@@ -922,6 +945,59 @@ describe("LaneChangesPanel", () => {
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
|
||||
path: "server/lib/git-ops.js",
|
||||
hunk: "@@ -9,2 +10,3 @@",
|
||||
block: 0,
|
||||
context: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("gives each +/- block inside ONE hunk its own bar and acts on that block only (#50)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(
|
||||
changes({
|
||||
files: [
|
||||
{
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
unstaged: true,
|
||||
added: 2,
|
||||
deleted: 1,
|
||||
binary: false,
|
||||
hunks: [
|
||||
{
|
||||
header: "@@ -1,4 +1,5 @@",
|
||||
context: "",
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ type: "del" as const, oldLine: 1, newLine: null, text: "a" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 1, text: "A" },
|
||||
{ type: "ctx" as const, oldLine: 2, newLine: 2, text: "b" },
|
||||
{ type: "ctx" as const, oldLine: 3, newLine: 3, text: "c" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 4, text: "new" },
|
||||
{ type: "ctx" as const, oldLine: 4, newLine: 5, text: "d" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Review" }));
|
||||
await screen.findByTestId("lane-changes-review");
|
||||
|
||||
const bars = screen.getAllByTestId("lane-hunk-bar");
|
||||
expect(bars.map((b) => b.textContent)).toEqual([
|
||||
expect.stringContaining("1 of 2"),
|
||||
expect.stringContaining("2 of 2"),
|
||||
]);
|
||||
await user.click(screen.getAllByRole("button", { name: "Stage this hunk" })[1]!);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
|
||||
path: "server/lib/git-ops.js",
|
||||
hunk: "@@ -1,4 +1,5 @@",
|
||||
block: 1,
|
||||
context: 3,
|
||||
});
|
||||
});
|
||||
@@ -959,6 +1035,7 @@ describe("LaneChangesPanel", () => {
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard-hunk", {
|
||||
path: "server/lib/git-ops.js",
|
||||
hunk: "@@ -1,2 +1,3 @@",
|
||||
block: 0,
|
||||
context: 3,
|
||||
confirm: true,
|
||||
});
|
||||
|
||||
@@ -422,8 +422,8 @@ describe("LaneDiffView — hunk review bar navigation", () => {
|
||||
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);
|
||||
for (const el of view.container.querySelectorAll<HTMLElement>("[data-change-index]")) {
|
||||
const k = Number(el.dataset.changeIndex);
|
||||
el.getBoundingClientRect = () => ({ top: k * 100 - scroller.scrollTop }) as DOMRect;
|
||||
}
|
||||
return scroller;
|
||||
@@ -454,6 +454,49 @@ describe("LaneDiffView — hunk review bar navigation", () => {
|
||||
expect(screen.getAllByRole("button", { name: "Next change" })[2]).toBeDisabled();
|
||||
});
|
||||
|
||||
it("hangs one bar under EACH +/- block of a single hunk, in both layouts (#50)", () => {
|
||||
const merged: LaneChangeFile = {
|
||||
...FILE,
|
||||
hunks: [
|
||||
{
|
||||
header: "@@ -1,6 +1,6 @@",
|
||||
context: "",
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ type: "del" as const, oldLine: 1, newLine: null, text: "a" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 1, text: "A" },
|
||||
{ type: "ctx" as const, oldLine: 2, newLine: 2, text: "b" },
|
||||
{ type: "del" as const, oldLine: 3, newLine: null, text: "c" },
|
||||
{ type: "ctx" as const, oldLine: 4, newLine: 3, text: "d" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 4, text: "E" },
|
||||
{ type: "ctx" as const, oldLine: 5, newLine: 5, text: "f" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const seen: number[] = [];
|
||||
for (const layout of ["unified", "split"] as const) {
|
||||
const { unmount } = render(
|
||||
<LaneDiffView
|
||||
file={merged}
|
||||
layout={layout}
|
||||
hunkActions={(c) => {
|
||||
seen.push(c.block);
|
||||
return <span />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByTestId("lane-hunk-bar").map((b) => b.textContent)).toEqual([
|
||||
expect.stringContaining("1 of 3"),
|
||||
expect.stringContaining("2 of 3"),
|
||||
expect.stringContaining("3 of 3"),
|
||||
]);
|
||||
unmount();
|
||||
}
|
||||
expect(seen).toEqual([0, 1, 2, 0, 1, 2]);
|
||||
});
|
||||
|
||||
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]");
|
||||
|
||||
Reference in New Issue
Block a user