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