Nouveau workspace packages/vscode (git-arboretum, privé, non publié sur npm), client REST/WS réutilisant @arboretum/shared. Auth Authorization: Bearer sur REST et l'upgrade WS (via `ws`) ; un client Node sans en-tête Origin passe le check Origin strict du serveur. - Arbres temps réel : Repositories (repos → worktrees → sessions) et Groups, via le WebSocket. - Terminaux natifs (vscode.Pseudoterminal) pour attacher/observer une session — rendu et scrollback de VS Code ; décodage UTF-8 streaming + comptabilité ACK dans des modules purs. - Status bar (compteur waiting) + notifications natives sur passage en waiting, réponses Yes/No via la commande WS answer. - Mutations git (create worktree, commit, push, promote), start/kill/hide/resume/fork, session de groupe ; conscience du workspace (reveal + start/create here). - Bundle esbuild (format cjs, external vscode) inlinant @arboretum/shared → VSIX autonome. Logique réutilisable sans import vscode → testée par vitest (19 tests). - CI : .gitea/workflows/vscode-release.yml package le VSIX sur tag vscode-vX.Y.Z (artefact + asset de release best-effort). build:vscode hors du build principal (comme le site). - spikes/s5-vscode/STUDY.md : décision de conception (GO phasé A→D), marquée implémentée.
197 lines
6.7 KiB
TypeScript
197 lines
6.7 KiB
TypeScript
// État mémoire de l'extension — module pur (aucun import vscode), testable.
|
|
// Seedé via REST au (re)connect, puis patché par les events WS. Émet onDidChange à chaque mutation ;
|
|
// les arbres / status bar / notifications s'y abonnent. Principe « groupe léger » : on ne corrèle pas
|
|
// les worktrees aux sessions via la liste embarquée (potentiellement périmée) mais par `cwd`, exactement
|
|
// comme `sessionsForCwd` côté serveur.
|
|
import type {
|
|
GroupSummary,
|
|
RepoSummary,
|
|
SessionSummary,
|
|
WorktreeSummary,
|
|
} from '@arboretum/shared';
|
|
import type { GroupEvent, SessionEvent, WorktreeEvent } from '../api/ws-client.js';
|
|
import type { RestClient } from '../api/rest-client.js';
|
|
|
|
/** Normalise un chemin absolu pour la corrélation (retire le slash final). */
|
|
function normPath(p: string): string {
|
|
return p.replace(/\/+$/, '') || '/';
|
|
}
|
|
|
|
export class Store {
|
|
private readonly repos = new Map<string, RepoSummary>();
|
|
/** worktrees par repo, indexés par chemin (clé stable). */
|
|
private readonly worktrees = new Map<string, Map<string, WorktreeSummary>>();
|
|
private readonly sessions = new Map<string, SessionSummary>();
|
|
private readonly groups = new Map<string, GroupSummary>();
|
|
private readonly listeners = new Set<() => void>();
|
|
|
|
/** affiche aussi les sessions externes (découvertes hors Arboretum). */
|
|
showExternalSessions = false;
|
|
|
|
onDidChange(cb: () => void): () => void {
|
|
this.listeners.add(cb);
|
|
return () => this.listeners.delete(cb);
|
|
}
|
|
|
|
/** Bascule l'affichage des sessions externes et notifie les abonnés (refiltre les arbres). */
|
|
setShowExternalSessions(value: boolean): void {
|
|
if (this.showExternalSessions === value) return;
|
|
this.showExternalSessions = value;
|
|
this.emit();
|
|
}
|
|
|
|
private emit(): void {
|
|
for (const cb of this.listeners) cb();
|
|
}
|
|
|
|
/** Charge tout l'état depuis le daemon (au premier hello_ok et à chaque reconnexion). */
|
|
async seed(rest: RestClient): Promise<void> {
|
|
const [repos, worktrees, sessions, groups] = await Promise.all([
|
|
rest.listRepos(),
|
|
rest.listWorktrees(),
|
|
rest.listSessions(false),
|
|
rest.listGroups(),
|
|
]);
|
|
this.repos.clear();
|
|
this.worktrees.clear();
|
|
this.sessions.clear();
|
|
this.groups.clear();
|
|
for (const r of repos.repos) this.repos.set(r.id, r);
|
|
for (const w of worktrees.worktrees) this.putWorktree(w);
|
|
for (const s of sessions.sessions) this.sessions.set(s.id, s);
|
|
for (const g of groups.groups) this.groups.set(g.id, g);
|
|
this.emit();
|
|
}
|
|
|
|
clear(): void {
|
|
this.repos.clear();
|
|
this.worktrees.clear();
|
|
this.sessions.clear();
|
|
this.groups.clear();
|
|
this.emit();
|
|
}
|
|
|
|
private putWorktree(w: WorktreeSummary): void {
|
|
let byPath = this.worktrees.get(w.repoId);
|
|
if (!byPath) {
|
|
byPath = new Map();
|
|
this.worktrees.set(w.repoId, byPath);
|
|
}
|
|
byPath.set(normPath(w.path), w);
|
|
// les sessions embarquées dans le worktree alimentent aussi la map sessions (état initial).
|
|
for (const s of w.sessions) if (!this.sessions.has(s.id)) this.sessions.set(s.id, s);
|
|
}
|
|
|
|
// ---- réducteurs d'events WS ----
|
|
|
|
applySession(e: SessionEvent): void {
|
|
if (e.type === 'session_update') {
|
|
this.sessions.set(e.session.id, e.session);
|
|
} else {
|
|
// session_exit : marque morte si connue (sinon ignore).
|
|
const prev = this.sessions.get(e.sessionId);
|
|
if (prev) this.sessions.set(e.sessionId, { ...prev, live: false, status: 'exited', exitCode: e.exitCode });
|
|
}
|
|
this.emit();
|
|
}
|
|
|
|
applyWorktree(e: WorktreeEvent): void {
|
|
switch (e.type) {
|
|
case 'repo_update':
|
|
this.repos.set(e.repo.id, e.repo);
|
|
break;
|
|
case 'repo_removed':
|
|
this.repos.delete(e.repoId);
|
|
this.worktrees.delete(e.repoId);
|
|
break;
|
|
case 'worktree_update':
|
|
this.putWorktree(e.worktree);
|
|
break;
|
|
case 'worktree_removed':
|
|
this.worktrees.get(e.repoId)?.delete(normPath(e.path));
|
|
break;
|
|
}
|
|
this.emit();
|
|
}
|
|
|
|
applyGroup(e: GroupEvent): void {
|
|
if (e.type === 'group_update') this.groups.set(e.group.id, e.group);
|
|
else this.groups.delete(e.groupId);
|
|
this.emit();
|
|
}
|
|
|
|
// ---- lecture ----
|
|
|
|
listRepos(): RepoSummary[] {
|
|
return [...this.repos.values()]
|
|
.filter((r) => !r.hidden && r.valid)
|
|
.sort((a, b) => a.label.localeCompare(b.label));
|
|
}
|
|
|
|
getRepo(id: string): RepoSummary | undefined {
|
|
return this.repos.get(id);
|
|
}
|
|
|
|
listWorktrees(repoId: string): WorktreeSummary[] {
|
|
const byPath = this.worktrees.get(repoId);
|
|
if (!byPath) return [];
|
|
// worktree principal en tête, puis tri par branche/chemin.
|
|
return [...byPath.values()].sort((a, b) => {
|
|
if (a.isMain !== b.isMain) return a.isMain ? -1 : 1;
|
|
return (a.branch ?? a.path).localeCompare(b.branch ?? b.path);
|
|
});
|
|
}
|
|
|
|
getWorktree(repoId: string, path: string): WorktreeSummary | undefined {
|
|
return this.worktrees.get(repoId)?.get(normPath(path));
|
|
}
|
|
|
|
/** sessions corrélées à un worktree par cwd (comme le serveur) ; filtre les externes selon le réglage. */
|
|
sessionsForWorktree(path: string): SessionSummary[] {
|
|
const wp = normPath(path);
|
|
return [...this.sessions.values()]
|
|
.filter((s) => normPath(s.cwd) === wp)
|
|
.filter((s) => this.showExternalSessions || s.source === 'managed')
|
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
}
|
|
|
|
getSession(id: string): SessionSummary | undefined {
|
|
return this.sessions.get(id);
|
|
}
|
|
|
|
listGroups(): GroupSummary[] {
|
|
return [...this.groups.values()].sort((a, b) => a.label.localeCompare(b.label));
|
|
}
|
|
|
|
getGroup(id: string): GroupSummary | undefined {
|
|
return this.groups.get(id);
|
|
}
|
|
|
|
/** sessions de groupe (couvrant plusieurs repos) attachées à un groupe donné. */
|
|
sessionsForGroup(groupId: string): SessionSummary[] {
|
|
return [...this.sessions.values()]
|
|
.filter((s) => s.groupId === groupId)
|
|
.filter((s) => this.showExternalSessions || s.source === 'managed')
|
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
}
|
|
|
|
/** sessions en attente d'input (activity === 'waiting') — pour la status bar et les notifications. */
|
|
waitingSessions(): SessionSummary[] {
|
|
return [...this.sessions.values()].filter((s) => s.live && s.activity === 'waiting');
|
|
}
|
|
|
|
allSessions(): SessionSummary[] {
|
|
return [...this.sessions.values()];
|
|
}
|
|
|
|
/** trouve le worktree dont le chemin == folderPath (mapping workspace). */
|
|
findWorktreeByPath(folderPath: string): { repoId: string; worktree: WorktreeSummary } | undefined {
|
|
const target = normPath(folderPath);
|
|
for (const [repoId, byPath] of this.worktrees) {
|
|
const wt = byPath.get(target);
|
|
if (wt) return { repoId, worktree: wt };
|
|
}
|
|
return undefined;
|
|
}
|
|
}
|