feat(vscode): extension VS Code native (intégration native, pas un webview)

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.
This commit is contained in:
2026-06-23 17:43:03 +02:00
parent 663ae7ace1
commit cf7eb05aca
35 changed files with 6172 additions and 4 deletions

View File

@@ -0,0 +1,196 @@
// É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;
}
}