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