feat(ui): sidebar project tree, status bar and a tabbed workbench for workspaces

The Workspace page's lane strip moves into the sidebar as a project tree:
workspaces grouped by git repository (new GET /api/projects, cached in
memory, POST /api/projects/refresh), untracked worktrees with Adopt, inline
rename, drag/Alt+arrow ordering and a right-click row menu. The selected
workspace is the URL's ?lane=, one shared lane list feeds the tree, the new
bottom status bar and the page. The Changes panel gains an activity bar
(Files / Changes / Sessions, with resume), and split panes get a header.
This commit is contained in:
2026-10-01 16:33:25 +07:00
parent 5b3884b309
commit ba2c4e1914
39 changed files with 2431 additions and 1439 deletions
+112
View File
@@ -0,0 +1,112 @@
/**
* @file Tests for server/lib/projects.js and GET /api/projects: a lane's
* project root from a main worktree, a linked worktree, a subdirectory and a
* non-repo; grouping lanes per repo with the Folders pseudo-project last; and
* matching each worktree to the lane that sits on it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const http = require("node:http");
const { execFileSync } = require("node:child_process");
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "projects-")));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
process.env.GIT_CONFIG_NOSYSTEM = "1";
// Inside a git hook (the pre-commit suite run) git exports GIT_DIR,
// GIT_INDEX_FILE and friends; the fixture repos below must not inherit them.
for (const k of Object.keys(process.env)) {
if (k.startsWith("GIT_") && k !== "GIT_CONFIG_NOSYSTEM") delete process.env[k];
}
const { createApp } = require("../index");
const lanesLib = require("../lib/lanes");
const { projectRootFor, clearProjectCaches } = require("../lib/projects");
const repo = path.join(ROOT, "zrepo");
const linked = path.join(ROOT, "linked");
const claudeWt = path.join(repo, ".claude", "worktrees", "wf-1");
const plain = path.join(ROOT, "plain");
let app;
let base;
function git(cwd, ...args) {
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", ...args], {
cwd,
stdio: "ignore",
});
}
before(async () => {
fs.mkdirSync(path.join(repo, "sub"), { recursive: true });
fs.mkdirSync(plain);
git(repo, "init", "-q", "-b", "main");
fs.writeFileSync(path.join(repo, "a.txt"), "a");
git(repo, "add", ".");
git(repo, "commit", "-q", "-m", "init");
git(repo, "worktree", "add", "-q", "-b", "feat", linked);
git(repo, "worktree", "add", "-q", "-b", "wf-1", claudeWt);
app = http.createServer(createApp());
await new Promise((r) => app.listen(0, "127.0.0.1", r));
base = `http://127.0.0.1:${app.address().port}`;
});
after(async () => {
await new Promise((r) => app.close(r));
});
describe("projectRootFor", () => {
it("resolves a main worktree, a linked one and a subdir to the main root", async () => {
assert.equal(await projectRootFor(repo), repo);
assert.equal(await projectRootFor(linked), repo);
assert.equal(await projectRootFor(path.join(repo, "sub")), repo);
});
it("answers null outside a repo", async () => {
assert.equal(await projectRootFor(plain), null);
});
});
describe("GET /api/projects", () => {
it("groups lanes per repo, Folders last, and marks untracked worktrees", async () => {
const main = lanesLib.createLane({ cwd: repo });
const sub = lanesLib.createLane({ cwd: path.join(repo, "sub") });
const folder = lanesLib.createLane({ cwd: plain });
clearProjectCaches();
const res = await fetch(`${base}/api/projects`);
assert.equal(res.status, 200);
const { projects } = await res.json();
assert.deepEqual(
projects.map((p) => [p.root, p.name, p.workspaces]),
[
[repo, "zrepo", [main.id, sub.id]],
[null, "Folders", [folder.id]],
]
);
const wt = Object.fromEntries(projects[0].worktrees.map((w) => [w.path, w]));
assert.deepEqual(wt[repo], { path: repo, branch: "main", main: true, workspace_id: main.id });
// A lane on a SUBDIRECTORY marks no worktree; nobody sits on `linked`.
assert.equal(wt[linked].workspace_id, null);
assert.equal(wt[linked].source, undefined);
assert.equal(wt[claudeWt].source, "claude");
});
it("matches a worktree to a lane adopted on it after the list was cached", async () => {
const adopted = lanesLib.createLane({ cwd: linked });
const { projects } = await (await fetch(`${base}/api/projects`)).json();
const wt = projects[0].worktrees.find((w) => w.path === linked);
assert.equal(wt.workspace_id, adopted.id);
});
it("refresh refuses a cross-origin POST", async () => {
const res = await fetch(`${base}/api/projects/refresh`, {
method: "POST",
headers: { Origin: "http://evil.test" },
});
assert.equal(res.status, 403);
});
});
+2
View File
@@ -88,6 +88,7 @@ const webhooksRouter = require("./routes/webhooks");
const remoteSourcesRouter = require("./routes/remote-sources");
const metricsRouter = require("./routes/metrics");
const lanesRouter = require("./routes/lanes");
const projectsRouter = require("./routes/projects");
const locksRouter = require("./routes/locks");
const APP_VERSION = (() => {
@@ -128,6 +129,7 @@ function createApp() {
app.use("/api/ai-tools", aiToolsRouter);
app.use("/api/run", runRouter);
app.use("/api/lanes", lanesRouter);
app.use("/api/projects", projectsRouter);
app.use("/api/locks", locksRouter);
app.use("/api/alerts", alertsRouter);
app.use("/api/webhooks", webhooksRouter);
+133
View File
@@ -0,0 +1,133 @@
/**
* @file Projects: lanes grouped by the git repository they belong to, for the
* sidebar's project → workspace tree. A project is a repo's MAIN worktree root
* (the directory holding the common `.git`); a lane outside any repo lands in
* the `root: null` pseudo-project ("Folders"). Each project also lists the
* repo's worktrees from `git worktree list`, so a worktree no lane covers can
* be shown and adopted.
*
* Both reads shell out to git, so both are cached in memory: a cwd's root for
* the life of the process (a directory does not move between repos), a repo's
* worktree list for 30 s. `clearProjectCaches` (POST /api/projects/refresh)
* drops both. Which lane owns a worktree is recomputed on every call from the
* lane rows, so creating or removing a lane needs no invalidation. None of
* this runs on the polled `GET /api/lanes`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { git, listWorktrees, LANES_ROOT } = require("./worktree");
const WORKTREE_TTL_MS = 30_000;
const rootCache = new Map();
const worktreeCache = new Map();
function real(p) {
try {
return fs.realpathSync(p);
} catch {
return p;
}
}
function isUnder(p, dir) {
const rel = path.relative(dir, p);
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
}
/**
* The repository a directory belongs to: its main worktree's top level
* (realpath), or null when the directory is not inside a git repo.
* @param {string} cwd
* @returns {Promise<string|null>}
*/
async function projectRootFor(cwd) {
if (rootCache.has(cwd)) return rootCache.get(cwd);
let root = null;
try {
const { stdout } = await git(cwd, ["rev-parse", "--git-common-dir"]);
// Relative (".git") from the main worktree itself, absolute from a linked one.
const common = path.resolve(cwd, stdout.trim());
root = real(path.basename(common) === ".git" ? path.dirname(common) : common);
} catch {
root = null;
}
// A lane still being provisioned has no directory yet: ask again next time.
if (root || fs.existsSync(cwd)) rootCache.set(cwd, root);
return root;
}
async function worktreesFor(root) {
const hit = worktreeCache.get(root);
if (hit && Date.now() - hit.at < WORKTREE_TTL_MS) return hit.list;
let list = [];
try {
list = await listWorktrees(root);
} catch {
list = [];
}
worktreeCache.set(root, { at: Date.now(), list });
return list;
}
function clearProjectCaches() {
rootCache.clear();
worktreeCache.clear();
}
/**
* @param {{id:number, cwd:string}[]} lanes - lane rows, in id order
* @returns {Promise<object[]>} projects by name, the Folders pseudo-project last
*/
async function listProjects(lanes) {
const roots = await Promise.all(lanes.map((l) => projectRootFor(l.cwd)));
const byRoot = new Map();
lanes.forEach((lane, i) => {
const r = roots[i];
if (!byRoot.has(r)) byRoot.set(r, []);
byRoot.get(r).push(lane);
});
const lanesRoot = real(LANES_ROOT);
const projects = await Promise.all(
[...byRoot]
.filter(([root]) => root !== null)
.map(async ([root, own]) => {
const claudeDir = path.join(root, ".claude", "worktrees");
const worktrees = (await worktreesFor(root))
.map((w, i) => ({ ...w, path: real(w.path), main: i === 0 }))
// A worktree whose directory is gone (`prunable`) cannot be adopted.
.filter((w) => fs.existsSync(w.path))
.map((w) => ({
path: w.path,
branch: w.branch || null,
main: w.main,
workspace_id: own.find((l) => l.cwd === w.path)?.id ?? null,
...(isUnder(w.path, claudeDir)
? { source: "claude" }
: isUnder(w.path, lanesRoot)
? { source: "agenthub" }
: {}),
}));
return {
root,
name: path.basename(root).replace(/\.git$/, "") || root,
workspaces: own.map((l) => l.id),
worktrees,
};
})
);
projects.sort((a, b) => a.name.localeCompare(b.name));
const folders = byRoot.get(null);
if (folders) {
projects.push({
root: null,
name: "Folders",
workspaces: folders.map((l) => l.id),
worktrees: [],
});
}
return projects;
}
module.exports = { projectRootFor, listProjects, clearProjectCaches };
+25
View File
@@ -0,0 +1,25 @@
/**
* @file Express router for /api/projects: every lane grouped by the git repo
* it belongs to, with that repo's worktrees (see server/lib/projects.js). Read
* on demand by the sidebar's project tree, never polled; `POST /refresh` drops
* the cached roots and worktree lists first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const lanesLib = require("../lib/lanes");
const { listProjects, clearProjectCaches } = require("../lib/projects");
const { sameOriginGuard } = require("../lib/same-origin");
const router = Router();
router.get("/", async (_req, res) => {
res.json({ projects: await listProjects(lanesLib.listLanes()) });
});
router.post("/refresh", sameOriginGuard, async (_req, res) => {
clearProjectCaches();
res.json({ projects: await listProjects(lanesLib.listLanes()) });
});
module.exports = router;