feat(lanes): full-width Changes, Stage all, one-read expand-all

- The Changes panel's width control gains a full-width step (narrow →
  50/50 → 70/30 → full → closed); the console beside it is hidden with
  CSS, never unmounted.
- "Expand all folders" is one GET /api/lanes/:id/git/tree-all (one
  `git ls-files`, every level shaped like /git/tree) instead of one
  request and one git process per folder.
- Overview: a Stage all button and the staged-only commit box sit above
  the file list and stay pinned while it scrolls; the Files finder is
  pinned too.
This commit is contained in:
2026-09-24 14:21:44 +07:00
parent 428519335d
commit 0737786faf
13 changed files with 231 additions and 107 deletions
+17
View File
@@ -19,6 +19,7 @@ const {
listCommits,
listPaths,
listTree,
listTreeAll,
parseUnifiedDiff,
readFileAt,
} = require("../lib/git-diff");
@@ -456,6 +457,22 @@ describe("listCommits / readFileAt / listPaths / listTree", () => {
);
await assert.rejects(() => listTree(LANE, { subdir: "../.." }), { code: "EBADPATH" });
});
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", () => {
+38
View File
@@ -785,12 +785,50 @@ async function listTree(dir, { subdir = "" } = {}) {
};
}
/**
* Every level of the tracked tree 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 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 prefix = d ? `${d}/` : "";
const entries = [
...[...dirs].sort().map((name) => ({ name, path: `${prefix}${name}`, dir: true })),
...files.sort((a, b) => a.name.localeCompare(b.name)),
];
if (entries.length > MAX_PATHS) truncated = true;
levels[d] = entries.slice(0, MAX_PATHS);
}
return { levels, truncated };
}
module.exports = {
collectChanges,
laneStatus,
listCommits,
listPaths,
listTree,
listTreeAll,
parseNumstat,
parsePorcelainZ,
parseUnifiedDiff,
+19 -1
View File
@@ -41,7 +41,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");
@@ -859,6 +866,17 @@ router.get("/:id/git/ls", async (req, res) => {
}
});
/** 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 tracked files, for the file explorer. */
router.get("/:id/git/tree", async (req, res) => {
const lane = laneOr404(req, res);