// É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(); /** worktrees par repo, indexés par chemin (clé stable). */ private readonly worktrees = new Map>(); private readonly sessions = new Map(); private readonly groups = new Map(); 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 { 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; } }