feat(lanes): Add Lane repo/worktree mode toggle, manual branch, folder browse

- Repo mode adopts a directory as-is via /lanes/ensure (no worktree, no
  branch fields) - the right choice for a main repo you want stage
  detection on. Worktree mode (default) keeps the existing provisioning
  flow but now requires a manually-typed branch name instead of deriving
  one from the title.
- POST /lanes/worktree accepts an optional `branch`, validated via
  `git check-ref-format --branch`; omitting it preserves the CLI's
  existing auto-derived-branch behavior.
- New GET /lanes/browse lists a directory's immediate subdirectories,
  backing a small folder-browse modal on both path fields - browsers
  cannot expose an absolute path from a native picker, so this is
  server-backed instead, consistent with the tool's local-first model.
This commit is contained in:
2026-08-06 16:03:49 +07:00
parent 9f13769fb4
commit 78f6e1be8e
10 changed files with 594 additions and 72 deletions
+55 -2
View File
@@ -10,6 +10,7 @@
const { Router } = require("express");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { db } = require("../db");
const lanesLib = require("../lib/lanes");
@@ -25,6 +26,7 @@ const {
addWorktree,
gitFacts,
isGitRepo,
isValidBranchName,
listBranches,
removeWorktree,
resetWorktree,
@@ -193,6 +195,47 @@ router.post("/gc", sameOriginGuard, (req, res) => {
}
});
/**
* Directory listing for the Add Lane modal's folder browser — browsers cannot
* expose absolute filesystem paths from a native picker, so path selection is
* done by browsing server-side instead. Read-only; registered ahead of
* "/:id" so the literal "browse" segment is never captured as a lane id.
*/
router.get("/browse", (req, res) => {
const raw =
typeof req.query.path === "string" && req.query.path.trim() ? req.query.path : os.homedir();
const resolved = path.resolve(raw);
let stat;
try {
stat = fs.statSync(resolved);
} catch {
return res.status(400).json({ error: { code: "ENOTFOUND", message: "path does not exist" } });
}
if (!stat.isDirectory()) {
return res
.status(400)
.json({ error: { code: "ENOTADIR", message: "path is not a directory" } });
}
let entries = [];
try {
entries = fs
.readdirSync(resolved, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
.map((e) => {
const full = path.join(resolved, e.name);
return { name: e.name, path: full, isGitRepo: fs.existsSync(path.join(full, ".git")) };
})
.sort((a, b) => a.name.localeCompare(b.name));
} catch {
// An unreadable entry mid-listing is skipped, not a request failure.
}
const parent = path.dirname(resolved) === resolved ? null : path.dirname(resolved);
res.json({ path: resolved, parent, entries });
});
router.get("/:id", (req, res) => {
const lane = lanesLib.getLane(req.params.id);
if (!lane) return res.status(404).json({ error: { code: "ENOLANE", message: "lane not found" } });
@@ -503,8 +546,18 @@ router.post("/worktree", sameOriginGuard, async (req, res) => {
});
}
const branchPrefix = process.env.LANE_BRANCH_PREFIX || "feat/";
const branch = `${branchPrefix}${slug}`;
let branch;
if (body.branch !== undefined) {
if (!(await isValidBranchName(resolvedSourceRepo, body.branch))) {
return res.status(400).json({
error: { code: "EBADBRANCH", message: "branch is not a valid git branch name" },
});
}
branch = body.branch;
} else {
const branchPrefix = process.env.LANE_BRANCH_PREFIX || "feat/";
branch = `${branchPrefix}${slug}`;
}
let lane;
try {
lane = lanesLib.createLane({