ba2c4e1914
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.
134 lines
4.3 KiB
JavaScript
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 };
|