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:
2026-10-01 16:33:25 +07:00
parent 5b3884b309
commit ba2c4e1914
39 changed files with 2431 additions and 1439 deletions
+94
View File
@@ -0,0 +1,94 @@
/**
* @file useLanes — the lane list (GET /api/lanes) shared by every component
* that shows workspaces: the sidebar's project tree, the Workspace page and the
* status bar's counts. One module-level copy, so N components mean one request;
* `lane_update` frames are merged in place (a `removed` or unknown lane
* refetches), and a WebSocket reconnect refetches the whole list because the
* frames missed while disconnected are never replayed. The copy is dropped
* when the last component unmounts, so a fresh mount always reads fresh rows.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useSyncExternalStore } from "react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { keepIfSame } from "../lib/keepIfSame";
import type { Lane, WSMessage } from "../lib/types";
let lanes: Lane[] | null = null;
// Bumped when the store is torn down, so a reply to a request made before the
// teardown cannot repopulate it.
let generation = 0;
let inflight: Promise<void> | null = null;
const listeners = new Set<() => void>();
let detach: (() => void) | null = null;
const EMPTY: Lane[] = [];
function publish(next: Lane[]) {
const kept = keepIfSame(next)(lanes);
if (kept === lanes) return;
lanes = kept;
listeners.forEach((l) => l());
}
function load(): Promise<void> {
if (!inflight) {
const gen = generation;
inflight = api.lanes
.list()
.then((r) => {
if (gen === generation) publish(r.lanes);
})
// Unreachable server: keep what we had.
.catch(() => undefined)
.finally(() => {
if (gen === generation) inflight = null;
});
}
return inflight;
}
function onMessage(msg: WSMessage) {
if (msg.type !== "lane_update") return;
const payload = msg.data as { lane?: Lane; removed?: number };
const lane = payload.lane;
if (payload.removed !== undefined || !lane || !lanes) return void load();
const i = lanes.findIndex((l) => l.id === lane.id);
if (i === -1) return void load();
const next = [...lanes];
next[i] = lane;
publish(next);
}
function subscribe(l: () => void) {
listeners.add(l);
if (listeners.size === 1) {
const offMsg = eventBus.subscribe(onMessage);
const offConn = eventBus.onConnection((up) => {
if (up) void load();
});
detach = () => {
offMsg();
offConn();
};
}
return () => {
listeners.delete(l);
if (listeners.size === 0) {
detach?.();
detach = null;
lanes = null;
inflight = null;
generation++;
}
};
}
/** Every lane, in id order; `refresh` re-reads them (after a create/remove). */
export function useLanes() {
const v = useSyncExternalStore(subscribe, () => lanes);
useEffect(() => {
if (!lanes) void load();
}, []);
return { lanes: v ?? EMPTY, loaded: v !== null, refresh: load };
}