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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user