feat(lanes): split panes on one lane, in-place file editing, hunk review bar
- Split view: every pane shows the selected lane, each in its own tmux session (`slot` 0-3 on POST /api/lanes/:id/start; slot 0 stays the lane's `run_id`, slots 1-3 are `agenthub-lane-<id>-<slot>`). A pane's Active runs hides what the other panes show; stop/reset/remove kill every slot. No per-pane lane picker any more. - Whole-file editor: PUT /api/lanes/:id/git/file (lane-file-write.js) with the read path's guards, existing text files only, and a baseSha stale check (409 ESTALE). A file it cannot take says why. - Per-hunk review bar under each hunk's last changed line: prev/next, "n of N", Undo, Keep. Review diffs no longer nest their own scroll box (unless windowed), so navigation moves the one real scroller. - Files tree lists untracked-not-ignored files and colours entries by git status (M/U/A/D/R), folders holding changes get a dot. - Changes panel: Unstage all on the single commit/Stage-all row; sizes Narrow (30%) / Medium / Full screen, no 70/30. - Lane card: the needs-you banner is gone (the strip tile keeps it).
This commit is contained in:
+77
-21
@@ -27,6 +27,7 @@ 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,
|
||||
@@ -855,6 +856,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);
|
||||
@@ -877,7 +898,7 @@ router.get("/:id/git/tree-all", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** One directory level of tracked files, for the file explorer. */
|
||||
/** 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;
|
||||
@@ -1165,26 +1186,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 });
|
||||
}
|
||||
@@ -1587,9 +1626,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
|
||||
@@ -1603,12 +1651,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
|
||||
@@ -1626,7 +1676,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) {
|
||||
@@ -1641,9 +1694,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 */
|
||||
}
|
||||
@@ -1677,7 +1730,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) => {
|
||||
|
||||
Reference in New Issue
Block a user