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