Files
AgentHub/server/lib/projects.js
T
nntrivi2001 ba2c4e1914 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.
2026-10-01 16:33:25 +07:00

134 lines
4.3 KiB
JavaScript

/**
* @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 };