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:
+74
-16
@@ -24,6 +24,7 @@
|
||||
* @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");
|
||||
|
||||
@@ -667,6 +668,7 @@ async function readFileAt(dir, requested, rev = "work") {
|
||||
|
||||
let text;
|
||||
let binary = false;
|
||||
let sha;
|
||||
if (rev === "work") {
|
||||
// Neither `..` nor a symlink may walk out of the lane: resolveInLane
|
||||
// compares REAL paths, not the string the caller sent.
|
||||
@@ -695,6 +697,9 @@ async function readFileAt(dir, requested, rev = "work") {
|
||||
}
|
||||
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 {
|
||||
try {
|
||||
// `./`: resolve from the lane's cwd, not the repo root (a bare
|
||||
@@ -715,7 +720,12 @@ async function readFileAt(dir, requested, 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");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -753,18 +763,67 @@ async function lsFiles(dir, args) {
|
||||
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"];
|
||||
|
||||
/**
|
||||
* One directory level of the working tree, for the file explorer. Tracked
|
||||
* entries only (same `.gitignore` reasoning as `listPaths`), folded into
|
||||
* 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
|
||||
* 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 { all, cut } = await lsFiles(dir, clean ? ["--", `${clean}/`] : []);
|
||||
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 = [];
|
||||
@@ -774,10 +833,7 @@ async function listTree(dir, { subdir = "" } = {}) {
|
||||
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),
|
||||
@@ -817,13 +873,13 @@ async function hunkPatch(dir, file, header, context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every level of the tracked tree from ONE `git ls-files`, keyed by directory
|
||||
* 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 } = await lsFiles(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);
|
||||
@@ -842,11 +898,7 @@ async function listTreeAll(dir) {
|
||||
const levels = {};
|
||||
let truncated = cut;
|
||||
for (const [d, { dirs, files }] of byDir) {
|
||||
const prefix = d ? `${d}/` : "";
|
||||
const entries = [
|
||||
...[...dirs].sort().map((name) => ({ name, path: `${prefix}${name}`, dir: true })),
|
||||
...files.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
];
|
||||
const entries = treeEntries(d ? `${d}/` : "", dirs, files, st);
|
||||
if (entries.length > MAX_PATHS) truncated = true;
|
||||
levels[d] = entries.slice(0, MAX_PATHS);
|
||||
}
|
||||
@@ -855,6 +907,12 @@ async function listTreeAll(dir) {
|
||||
|
||||
module.exports = {
|
||||
collectChanges,
|
||||
contentSha,
|
||||
isSecretOnDisk,
|
||||
lanePath,
|
||||
looksBinary,
|
||||
resolveInLane,
|
||||
MAX_FILE_VIEW_BYTES,
|
||||
hunkPatch,
|
||||
laneStatus,
|
||||
listCommits,
|
||||
|
||||
Reference in New Issue
Block a user