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,173 @@
// Client REST typé du daemon Arboretum — module pur (pas d'import vscode), testable.
// Auth par en-tête `Authorization: Bearer <token>` (le hook preValidation du serveur l'accepte
// au même titre que le cookie). Un client Node n'envoie pas d'en-tête Origin → il passe le check
// Origin strict côté serveur. Réutilise les types de `@arboretum/shared`.
import type {
ApiError,
CommitWorktreeRequest,
CreateGroupSessionRequest,
CreateSessionRequest,
CreateWorktreeRequest,
CreateWorktreeResponse,
GroupSessionResponse,
GroupsListResponse,
MeResponse,
PromoteWorktreeRequest,
PushWorktreeRequest,
RepoBranchesResponse,
ReposListResponse,
SessionResponse,
SessionsListResponse,
StartRepoSessionRequest,
WorktreeResponse,
WorktreesListResponse,
} from '@arboretum/shared';
import { normalizeBaseUrl } from '../config.js';
/** Erreur normalisée portant le code/HTTP renvoyés par le serveur. */
export class RestError extends Error {
constructor(
message: string,
readonly status: number,
readonly code: string,
) {
super(message);
this.name = 'RestError';
}
}
type FetchLike = typeof fetch;
export class RestClient {
private baseUrl: string;
private token: string | null;
constructor(
opts: { baseUrl: string; token?: string | null },
private readonly fetchImpl: FetchLike = fetch,
) {
this.baseUrl = normalizeBaseUrl(opts.baseUrl);
this.token = opts.token ?? null;
}
setBaseUrl(url: string): void {
this.baseUrl = normalizeBaseUrl(url);
}
setToken(token: string | null): void {
this.token = token;
}
get url(): string {
return this.baseUrl;
}
// ---- auth ----
/** Valide le token courant ; lève RestError(401) si invalide. */
me(): Promise<MeResponse> {
return this.request<MeResponse>('GET', '/api/v1/auth/me');
}
// ---- repos & worktrees ----
listRepos(): Promise<ReposListResponse> {
return this.request<ReposListResponse>('GET', '/api/v1/repos');
}
listWorktrees(): Promise<WorktreesListResponse> {
return this.request<WorktreesListResponse>('GET', '/api/v1/worktrees');
}
getBranches(repoId: string): Promise<RepoBranchesResponse> {
return this.request<RepoBranchesResponse>('GET', `/api/v1/repos/${encodeURIComponent(repoId)}/branches`);
}
createWorktree(repoId: string, body: CreateWorktreeRequest): Promise<CreateWorktreeResponse> {
return this.request<CreateWorktreeResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees`, body);
}
commitWorktree(repoId: string, body: CommitWorktreeRequest): Promise<WorktreeResponse> {
return this.request<WorktreeResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/commit`, body);
}
pushWorktree(repoId: string, body: PushWorktreeRequest): Promise<WorktreeResponse> {
return this.request<WorktreeResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/push`, body);
}
promoteWorktree(repoId: string, body: PromoteWorktreeRequest): Promise<WorktreeResponse> {
return this.request<WorktreeResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/worktrees/promote`, body);
}
startRepoSession(repoId: string, body: StartRepoSessionRequest): Promise<SessionResponse> {
return this.request<SessionResponse>('POST', `/api/v1/repos/${encodeURIComponent(repoId)}/session`, body);
}
// ---- sessions ----
createSession(body: CreateSessionRequest): Promise<SessionResponse> {
return this.request<SessionResponse>('POST', '/api/v1/sessions', body);
}
listSessions(includeHidden = false): Promise<SessionsListResponse> {
const q = includeHidden ? '?includeHidden=true' : '';
return this.request<SessionsListResponse>('GET', `/api/v1/sessions${q}`);
}
resumeSession(id: string): Promise<SessionResponse> {
return this.request<SessionResponse>('POST', `/api/v1/sessions/${encodeURIComponent(id)}/resume`, {});
}
forkSession(id: string): Promise<SessionResponse> {
return this.request<SessionResponse>('POST', `/api/v1/sessions/${encodeURIComponent(id)}/fork`, {});
}
hideSession(id: string): Promise<{ ok: true }> {
return this.request<{ ok: true }>('POST', `/api/v1/sessions/${encodeURIComponent(id)}/hide`, {});
}
killSession(id: string): Promise<{ ok: true }> {
return this.request<{ ok: true }>('DELETE', `/api/v1/sessions/${encodeURIComponent(id)}`);
}
// ---- groupes ----
listGroups(): Promise<GroupsListResponse> {
return this.request<GroupsListResponse>('GET', '/api/v1/groups');
}
createGroupSession(groupId: string, body: CreateGroupSessionRequest): Promise<GroupSessionResponse> {
return this.request<GroupSessionResponse>('POST', `/api/v1/groups/${encodeURIComponent(groupId)}/session`, body);
}
// ---- interne ----
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { Accept: 'application/json' };
if (this.token) headers.Authorization = `Bearer ${this.token}`;
if (body !== undefined) headers['Content-Type'] = 'application/json';
const init: RequestInit = { method, headers };
if (body !== undefined) init.body = JSON.stringify(body);
let res: Response;
try {
res = await this.fetchImpl(`${this.baseUrl}${path}`, init);
} catch (err) {
throw new RestError(`Cannot reach Arboretum at ${this.baseUrl}: ${(err as Error).message}`, 0, 'NETWORK');
}
if (!res.ok) {
let code = `HTTP_${res.status}`;
let message = `${method} ${path}${res.status}`;
try {
const data = (await res.json()) as ApiError;
if (data?.error) {
code = data.error.code ?? code;
message = data.error.message ?? message;
}
} catch {
/* corps non-JSON : on garde le message générique */
}
throw new RestError(message, res.status, code);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
}

View File

@@ -0,0 +1,425 @@
// Client WebSocket multiplexé Node — portage du client web (packages/web/src/lib/ws-client.ts).
// UNE connexion, plusieurs canaux ; handshake hello/hello_ok, sub des topics, corrélation FIFO des
// 'attached', flow control par ACK (cumul d'octets PAR CANAL, remis à 0 à chaque RESYNC), reconnexion
// avec backoff. Différences vs web :
// - transport injecté (RawSocket) → testable sans réseau ; l'implémentation par défaut utilise `ws`
// avec l'en-tête `Authorization: Bearer` posé à l'upgrade (le navigateur, lui, envoie le cookie).
// - pas de callback de rendu (xterm) : on ACK les octets AU MOMENT où on les remet au sink (onDidWrite
// d'un Pseudoterminal émet de façon synchrone). Un ACK traînant débouncé flushe le reliquat < 64 Ko.
import {
BINARY_FRAME,
FLOW,
PROTOCOL_VERSION,
decodeBinaryFrame,
type ClientMessage,
type ServerMessage,
} from '@arboretum/shared';
import { wsUrlFromBase } from '../config.js';
export type WsStatus = 'idle' | 'connecting' | 'open' | 'reconnecting';
export type DetachReason = 'client' | 'session_exit' | 'replaced';
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' }>;
export type WorktreeEvent = Extract<ServerMessage, { type: 'repo_update' | 'repo_removed' | 'worktree_update' | 'worktree_removed' }>;
export type GroupEvent = Extract<ServerMessage, { type: 'group_update' | 'group_removed' }>;
/** Sink d'un canal : reçoit les octets BRUTS (le décodage UTF-8 est délégué au SessionBridge). */
export interface AttachmentSink {
data(payload: Uint8Array): void;
reset(): void;
detached(reason: DetachReason): void;
controlChanged(controlling: boolean): void;
}
export interface AttachOptions {
sessionId: string;
mode: 'interactive' | 'observer';
cols: number;
rows: number;
sink: AttachmentSink;
}
/** Transport minimal — abstrait pour les tests ; l'impl par défaut enveloppe `ws`. */
export interface RawSocket {
send(data: string): void;
close(): void;
onOpen(cb: () => void): void;
onText(cb: (text: string) => void): void;
onBinary(cb: (data: Uint8Array) => void): void;
onClose(cb: () => void): void;
onError(cb: (err: Error) => void): void;
}
export type SocketFactory = (wsUrl: string, token: string) => RawSocket;
const BACKOFF_MIN_MS = 500;
const BACKOFF_MAX_MS = 10_000;
export class Attachment {
channel = -1;
controlling = false;
closed = false;
cols: number;
rows: number;
epoch = 0;
processedBytes = 0;
lastAckBytes = 0;
trailingAckTimer: ReturnType<typeof setTimeout> | null = null;
pending: { resolve: (a: Attachment) => void; reject: (err: Error) => void } | null = null;
constructor(
private readonly client: ArbWsClient,
readonly sessionId: string,
readonly mode: 'interactive' | 'observer',
readonly sink: AttachmentSink,
cols: number,
rows: number,
) {
this.cols = cols;
this.rows = rows;
}
sendStdin(data: string): void {
if (this.closed || this.channel < 0) return;
this.client.sendControl({ type: 'stdin', channel: this.channel, data });
}
/** Répond à un dialogue Claude sans clavier ; le serveur traduit en keystrokes. */
answer(action: 'select' | 'confirm' | 'deny', optionN?: number): void {
if (this.closed || this.channel < 0) return;
this.client.sendControl(
action === 'select' && optionN !== undefined
? { type: 'answer', channel: this.channel, action, optionN }
: { type: 'answer', channel: this.channel, action },
);
}
resize(cols: number, rows: number): void {
this.cols = cols;
this.rows = rows;
if (this.closed || this.channel < 0 || !this.controlling) return;
this.client.sendControl({ type: 'resize', channel: this.channel, cols, rows });
}
detach(): void {
if (this.closed) return;
this.closed = true;
this.pending = null;
this.client.releaseAttachment(this);
}
}
export interface ArbWsClientOptions {
socketFactory: SocketFactory;
onStatus?: (status: WsStatus) => void;
onSession?: (e: SessionEvent) => void;
onWorktree?: (e: WorktreeEvent) => void;
onGroup?: (e: GroupEvent) => void;
/** appelé au premier hello_ok après (re)connexion → re-seed du store via REST. */
onReady?: () => void;
log?: (msg: string) => void;
}
export class ArbWsClient {
status: WsStatus = 'idle';
private baseUrl = '';
private token = '';
private socket: RawSocket | null = null;
private ready = false;
private stopped = true;
private backoffMs = BACKOFF_MIN_MS;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private readonly attachments = new Set<Attachment>();
private readonly byChannel = new Map<number, Attachment>();
private awaitingAttached: Attachment[] = [];
constructor(private readonly opts: ArbWsClientOptions) {}
/** (Re)configure l'URL/token ; si la connexion était active, elle est relancée. */
configure(baseUrl: string, token: string): void {
const wasActive = !this.stopped;
this.baseUrl = baseUrl;
this.token = token;
if (wasActive) {
this.hardReset();
this.connect();
}
}
connect(): void {
this.stopped = false;
if (this.socket || this.reconnectTimer) return;
if (!this.token) return; // pas authentifié : on n'ouvre rien
this.setStatus('connecting');
this.openSocket();
}
disconnect(): void {
this.stopped = true;
this.hardReset();
this.setStatus('idle');
}
/** résout sur le prochain 'attached' corrélé ; rejette si la session est introuvable/terminée */
attach(opts: AttachOptions): Promise<Attachment> {
const att = new Attachment(this, opts.sessionId, opts.mode, opts.sink, opts.cols, opts.rows);
const promise = new Promise<Attachment>((resolve, reject) => {
att.pending = { resolve, reject };
});
this.attachments.add(att);
this.connect();
if (this.ready) this.sendAttach(att);
return promise;
}
sendControl(msg: ClientMessage): void {
if (!this.ready || !this.socket) return;
try {
this.socket.send(JSON.stringify(msg));
} catch {
/* socket en cours de fermeture : la reconnexion rejouera */
}
}
releaseAttachment(att: Attachment): void {
this.attachments.delete(att);
if (att.trailingAckTimer) {
clearTimeout(att.trailingAckTimer);
att.trailingAckTimer = null;
}
if (att.channel >= 0) {
this.byChannel.delete(att.channel);
this.sendControl({ type: 'detach', channel: att.channel });
att.channel = -1;
}
}
// ---- interne ----
private setStatus(s: WsStatus): void {
this.status = s;
this.opts.onStatus?.(s);
}
/** ferme la socket courante et invalide tous les états sans toucher au flag stopped. */
private hardReset(): void {
this.clearReconnectTimer();
const socket = this.socket;
this.socket = null;
this.ready = false;
for (const att of this.attachments) {
if (att.trailingAckTimer) {
clearTimeout(att.trailingAckTimer);
att.trailingAckTimer = null;
}
att.pending?.reject(new Error('disconnected'));
att.pending = null;
att.closed = true;
att.sink.detached('client');
}
this.attachments.clear();
this.byChannel.clear();
this.awaitingAttached = [];
socket?.close();
}
private openSocket(): void {
const socket = this.opts.socketFactory(wsUrlFromBase(this.baseUrl), this.token);
this.socket = socket;
socket.onOpen(() => {
const hello: ClientMessage = { type: 'hello', protocol: PROTOCOL_VERSION };
socket.send(JSON.stringify(hello));
});
socket.onText((text) => this.handleText(text));
socket.onBinary((data) => this.handleBinary(data));
socket.onClose(() => this.handleClose(socket));
socket.onError((err) => this.opts.log?.(`ws error: ${err.message}`));
}
private handleClose(socket: RawSocket): void {
if (this.socket !== socket) return;
this.socket = null;
this.ready = false;
this.byChannel.clear();
this.awaitingAttached = [];
for (const att of this.attachments) {
att.channel = -1;
att.epoch += 1;
if (att.trailingAckTimer) {
clearTimeout(att.trailingAckTimer);
att.trailingAckTimer = null;
}
}
if (this.stopped) {
this.setStatus('idle');
return;
}
this.setStatus('reconnecting');
this.scheduleReconnect();
}
private scheduleReconnect(): void {
this.clearReconnectTimer();
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.stopped) this.openSocket();
}, this.backoffMs);
this.backoffMs = Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
}
private clearReconnectTimer(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
private sendAttach(att: Attachment): void {
this.awaitingAttached.push(att);
this.sendControl({ type: 'attach', sessionId: att.sessionId, mode: att.mode, cols: att.cols, rows: att.rows });
}
private handleText(text: string): void {
let msg: ServerMessage;
try {
msg = JSON.parse(text) as ServerMessage;
} catch {
return;
}
this.handleServerMessage(msg);
}
private handleBinary(data: Uint8Array): void {
if (data.byteLength < BINARY_FRAME.HEADER_BYTES) return;
const frame = decodeBinaryFrame(data);
const att = this.byChannel.get(frame.channel);
if (!att || att.closed) return;
if (frame.type === BINARY_FRAME.RESYNC) {
// le serveur repart de sentBytes=0 : reset terminal, replay, compteurs à zéro (le replay ne compte pas)
att.epoch += 1;
att.processedBytes = 0;
att.lastAckBytes = 0;
att.sink.reset();
if (frame.payload.byteLength > 0) att.sink.data(frame.payload);
this.scheduleTrailingAck(att, att.epoch);
return;
}
if (frame.type !== BINARY_FRAME.OUTPUT) return;
// Émission synchrone vers le sink (onDidWrite) → on ACK directement les octets remis.
const length = frame.payload.byteLength;
att.sink.data(frame.payload);
if (att.channel < 0) return;
att.processedBytes += length;
if (att.processedBytes - att.lastAckBytes >= FLOW.ACK_EVERY_BYTES) {
att.lastAckBytes = att.processedBytes;
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
}
// Filet : flushe le reliquat < ACK_EVERY_BYTES pour que le serveur sorte toujours de pause.
this.scheduleTrailingAck(att, att.epoch);
}
private scheduleTrailingAck(att: Attachment, epoch: number): void {
if (att.trailingAckTimer) clearTimeout(att.trailingAckTimer);
att.trailingAckTimer = setTimeout(() => {
att.trailingAckTimer = null;
if (att.closed || att.epoch !== epoch || att.channel < 0) return;
if (att.processedBytes > att.lastAckBytes) {
att.lastAckBytes = att.processedBytes;
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
}
}, 200);
}
private handleServerMessage(msg: ServerMessage): void {
switch (msg.type) {
case 'hello_ok': {
this.ready = true;
this.backoffMs = BACKOFF_MIN_MS;
this.setStatus('open');
this.sendControl({ type: 'sub', topics: ['sessions', 'worktrees', 'groups'] });
for (const att of this.attachments) {
if (!att.closed) this.sendAttach(att);
}
this.opts.onReady?.();
return;
}
case 'attached': {
const att = this.awaitingAttached.shift();
if (!att) return;
if (att.closed) {
this.sendControl({ type: 'detach', channel: msg.channel });
return;
}
const wasControlling = att.controlling;
att.channel = msg.channel;
att.controlling = msg.controlling;
this.byChannel.set(msg.channel, att);
if (att.pending) {
att.pending.resolve(att);
att.pending = null;
} else if (wasControlling !== msg.controlling) {
att.sink.controlChanged(msg.controlling);
}
return;
}
case 'detached': {
const att = this.byChannel.get(msg.channel);
if (!att) return;
this.byChannel.delete(msg.channel);
att.channel = -1;
if (msg.reason === 'client') return;
att.closed = true;
this.attachments.delete(att);
att.sink.detached(msg.reason);
return;
}
case 'control_changed': {
const att = this.byChannel.get(msg.channel);
if (!att || att.closed) return;
att.controlling = msg.controlling;
att.sink.controlChanged(msg.controlling);
return;
}
case 'session_update':
case 'session_exit':
this.opts.onSession?.(msg);
return;
case 'repo_update':
case 'repo_removed':
case 'worktree_update':
case 'worktree_removed':
this.opts.onWorktree?.(msg);
return;
case 'group_update':
case 'group_removed':
this.opts.onGroup?.(msg);
return;
case 'error': {
if (msg.channel !== undefined) {
this.opts.log?.(`channel ${msg.channel}: ${msg.code}${msg.message}`);
return;
}
if ((msg.code === 'NOT_FOUND' || msg.code === 'SESSION_EXITED') && this.awaitingAttached.length > 0) {
const att = this.awaitingAttached.shift();
if (!att) return;
this.attachments.delete(att);
if (att.pending) {
att.pending.reject(new Error(msg.code));
att.pending = null;
} else if (!att.closed) {
att.closed = true;
att.sink.detached('session_exit');
}
return;
}
this.opts.log?.(`${msg.code}${msg.message}`);
return;
}
case 'pong':
return;
}
}
}

View File

@@ -0,0 +1,33 @@
// Implémentation par défaut du transport RawSocket, basée sur le paquet `ws` (Node).
// L'auth se fait en posant `Authorization: Bearer <token>` dans les en-têtes de l'upgrade HTTP
// (un navigateur enverrait le cookie ; un client Node passe le token ici). Isolé du cœur ws-client
// pour que celui-ci reste injectable et testable sans réseau.
import WebSocket, { type RawData } from 'ws';
import type { RawSocket, SocketFactory } from './ws-client.js';
/** Normalise un RawData (Buffer | ArrayBuffer | Buffer[]) en Uint8Array copiée (offset 0). */
function toUint8(data: RawData): Uint8Array {
if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data));
if (data instanceof ArrayBuffer) return new Uint8Array(data);
// Buffer (sous-classe de Uint8Array) : copie pour ne pas dépendre du buffer poolé réutilisé par ws.
return new Uint8Array(data);
}
export const wsSocketFactory: SocketFactory = (wsUrl: string, token: string): RawSocket => {
const socket = new WebSocket(wsUrl, { headers: { Authorization: `Bearer ${token}` } });
return {
send: (d) => socket.send(d),
close: () => socket.close(),
onOpen: (cb) => socket.on('open', cb),
onText: (cb) =>
socket.on('message', (data: RawData, isBinary: boolean) => {
if (!isBinary) cb(toUint8(data).toString());
}),
onBinary: (cb) =>
socket.on('message', (data: RawData, isBinary: boolean) => {
if (isBinary) cb(toUint8(data));
}),
onClose: (cb) => socket.on('close', () => cb()),
onError: (cb) => socket.on('error', (err: Error) => cb(err)),
};
};

View File

@@ -0,0 +1,22 @@
// Stockage du token dans le SecretStorage de VS Code (chiffré par l'hôte). Les tokens du daemon sont
// stockés hashés côté serveur et le bootstrap ne s'affiche qu'une fois → on ne peut pas « lire » un
// token existant : l'utilisateur le saisit une fois (commande Sign In), on le conserve ici.
import type { SecretStorage } from 'vscode';
const TOKEN_KEY = 'arboretum.token';
export class Auth {
constructor(private readonly secrets: SecretStorage) {}
getToken(): Thenable<string | undefined> {
return this.secrets.get(TOKEN_KEY);
}
storeToken(token: string): Thenable<void> {
return this.secrets.store(TOKEN_KEY, token);
}
clearToken(): Thenable<void> {
return this.secrets.delete(TOKEN_KEY);
}
}

View File

@@ -0,0 +1,299 @@
// Enregistrement de toutes les commandes de l'extension. Les handlers contextuels reçoivent le nœud
// d'arbre cliqué (ou via menu) et narrowent dessus ; les mutations passent par le RestClient et l'état
// se met à jour via les events WS (pas de refetch manuel, sauf hide qui sort la session de la liste).
import * as vscode from 'vscode';
import type { GroupSummary, RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
import { RestError, type RestClient } from './api/rest-client.js';
import type { ArbWsClient, AttachmentSink } from './api/ws-client.js';
import type { Store } from './state/store.js';
import type { SessionTerminalManager } from './terminal/session-pty.js';
import type { WorkspaceMapper } from './workspace.js';
import type { AnyNode } from './views/nodes.js';
export interface CommandDeps {
store: Store;
rest: RestClient;
ws: ArbWsClient;
terminals: SessionTerminalManager;
workspace: WorkspaceMapper;
signIn(): Promise<void>;
signOut(): Promise<void>;
reseed(): Promise<void>;
}
// ---- extraction des nœuds ----
function asSession(node: AnyNode | undefined): SessionSummary | undefined {
return node && (node.kind === 'session' || node.kind === 'group-session') ? node.session : undefined;
}
function asWorktree(node: AnyNode | undefined): { repoId: string; worktree: WorktreeSummary } | undefined {
return node?.kind === 'worktree' ? { repoId: node.repoId, worktree: node.worktree } : undefined;
}
function asRepo(node: AnyNode | undefined): RepoSummary | undefined {
return node?.kind === 'repo' ? node.repo : undefined;
}
function asGroup(node: AnyNode | undefined): GroupSummary | undefined {
return node?.kind === 'group' ? node.group : undefined;
}
/** Exécute une action en affichant proprement une erreur REST (code + message serveur). */
async function run(label: string, fn: () => Promise<void>): Promise<void> {
try {
await fn();
} catch (err) {
const msg = err instanceof RestError ? `${err.code}: ${err.message}` : (err as Error).message;
void vscode.window.showErrorMessage(`Arboretum — ${label}: ${msg}`);
}
}
/** Répond à un dialogue : via le terminal ouvert si présent, sinon par une attache éphémère. */
export async function answerSession(
deps: CommandDeps,
session: SessionSummary,
action: 'select' | 'confirm' | 'deny',
optionN?: number,
): Promise<void> {
const open = deps.terminals.getBridge(session.id);
if (open) {
open.answer(action, optionN);
return;
}
const noop: AttachmentSink = { data: () => {}, reset: () => {}, detached: () => {}, controlChanged: () => {} };
const att = await deps.ws.attach({ sessionId: session.id, mode: 'interactive', cols: 80, rows: 24, sink: noop });
att.answer(action, optionN);
setTimeout(() => att.detach(), 600); // laisse le serveur traiter avant de fermer le canal éphémère
}
export function registerCommands(context: vscode.ExtensionContext, deps: CommandDeps): void {
const { store, rest, terminals, workspace } = deps;
const cmd = (id: string, handler: (...args: unknown[]) => unknown): void => {
context.subscriptions.push(vscode.commands.registerCommand(id, handler));
};
// ---- auth / global ----
cmd('arboretum.signIn', () => deps.signIn());
cmd('arboretum.signOut', () => deps.signOut());
cmd('arboretum.refresh', () => run('refresh', () => deps.reseed()));
cmd('arboretum.openDashboard', () => {
void vscode.env.openExternal(vscode.Uri.parse(rest.url));
});
// ---- terminaux (Phase B) ----
cmd('arboretum.attachSession', (node) => {
const s = asSession(node as AnyNode);
if (s) terminals.open(s, 'interactive');
});
cmd('arboretum.observeSession', (node) => {
const s = asSession(node as AnyNode);
if (s) terminals.open(s, 'observer');
});
cmd('arboretum.showWaiting', async () => {
const waiting = store.waitingSessions();
if (waiting.length === 0) {
void vscode.window.showInformationMessage('Arboretum: no session waiting.');
return;
}
const pick = await vscode.window.showQuickPick(
waiting.map((s) => ({ label: s.title?.trim() || s.command, description: s.waitingFor ?? s.cwd, session: s })),
{ placeHolder: 'Sessions waiting for input' },
);
if (pick) terminals.open(pick.session, 'interactive');
});
// ---- answer (Phase C) ----
cmd('arboretum.answerSession', async (node) => {
const s = asSession(node as AnyNode);
if (!s) return;
const dialogOptions = s.dialog?.options ?? [];
if (dialogOptions.length > 0) {
const pick = await vscode.window.showQuickPick(
dialogOptions.map((o) => ({ label: o.label, option: o })),
{ placeHolder: s.dialog?.waitingFor ?? 'Choose an option' },
);
if (pick) await run('answer', () => answerSession(deps, s, 'select', pick.option.n));
return;
}
const pick = await vscode.window.showQuickPick(['Yes', 'No'], { placeHolder: s.waitingFor ?? 'Answer the prompt' });
if (pick) await run('answer', () => answerSession(deps, s, pick === 'Yes' ? 'confirm' : 'deny'));
});
// ---- sessions ----
cmd('arboretum.killSession', async (node) => {
const s = asSession(node as AnyNode);
if (!s) return;
const ok = await vscode.window.showWarningMessage(`Kill session "${s.title?.trim() || s.command}"?`, { modal: true }, 'Kill');
if (ok === 'Kill') await run('kill', async () => void (await rest.killSession(s.id)));
});
cmd('arboretum.hideSession', (node) => {
const s = asSession(node as AnyNode);
if (!s) return;
void run('hide', async () => {
await rest.hideSession(s.id);
await deps.reseed(); // la session masquée sort de la liste (listSessions sans includeHidden)
});
});
cmd('arboretum.resumeSession', (node) => {
const s = asSession(node as AnyNode);
if (!s) return;
void run('resume', async () => {
const res = await rest.resumeSession(s.id);
terminals.open(res.session, 'interactive');
});
});
cmd('arboretum.forkSession', (node) => {
const s = asSession(node as AnyNode);
if (!s) return;
void run('fork', async () => {
const res = await rest.forkSession(s.id);
terminals.open(res.session, 'interactive');
});
});
// ---- worktrees (Phase C) ----
cmd('arboretum.createWorktree', (node) => {
const repo = asRepo(node as AnyNode);
if (repo) void createWorktreeFlow(deps, repo.id);
});
cmd('arboretum.commitWorktree', (node) => {
const wt = asWorktree(node as AnyNode);
if (!wt) return;
void run('commit', async () => {
const message = await vscode.window.showInputBox({ prompt: 'Commit message', placeHolder: 'Describe your changes' });
if (!message) return;
await rest.commitWorktree(wt.repoId, { path: wt.worktree.path, message });
void vscode.window.showInformationMessage('Arboretum: committed.');
});
});
cmd('arboretum.pushWorktree', (node) => {
const wt = asWorktree(node as AnyNode);
if (!wt) return;
void vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: 'Arboretum: pushing…' }, () =>
run('push', async () => {
await rest.pushWorktree(wt.repoId, { path: wt.worktree.path });
void vscode.window.showInformationMessage('Arboretum: pushed.');
}),
);
});
cmd('arboretum.promoteWorktree', (node) => {
const wt = asWorktree(node as AnyNode);
if (wt) void promoteWorktreeFlow(deps, wt.repoId, wt.worktree);
});
// ---- démarrage de session ----
cmd('arboretum.startSession', (node) => {
const wt = asWorktree(node as AnyNode);
if (wt) {
void run('start session', async () => {
const res = await rest.createSession({ cwd: wt.worktree.path, command: 'claude' });
terminals.open(res.session, 'interactive');
});
return;
}
const repo = asRepo(node as AnyNode);
if (repo) {
void run('start session', async () => {
const res = await rest.startRepoSession(repo.id, { command: 'claude' });
terminals.open(res.session, 'interactive');
});
}
});
cmd('arboretum.startGroupSession', (node) => {
const group = asGroup(node as AnyNode);
if (group) void startGroupSessionFlow(deps, group);
});
// ---- conscience du workspace (Phase D) ----
cmd('arboretum.startSessionHere', () => {
const repoId = workspace.currentRepoId();
if (!repoId) {
void vscode.window.showWarningMessage('Arboretum: the current folder is not a known repo/worktree.');
return;
}
const folder = vscode.workspace.workspaceFolders?.[0];
if (!folder) return;
void run('start session', async () => {
const res = await rest.createSession({ cwd: folder.uri.fsPath, command: 'claude' });
terminals.open(res.session, 'interactive');
});
});
cmd('arboretum.createWorktreeHere', () => {
const repoId = workspace.currentRepoId();
if (!repoId) {
void vscode.window.showWarningMessage('Arboretum: the current folder is not a known repo.');
return;
}
void createWorktreeFlow(deps, repoId);
});
cmd('arboretum.revealWorktree', () => workspace.revealCurrent());
}
// ---- flows interactifs ----
async function createWorktreeFlow(deps: CommandDeps, repoId: string): Promise<void> {
await run('create worktree', async () => {
const branches = await deps.rest.getBranches(repoId);
const existing = [...new Set([...branches.local, ...branches.remote.map((b) => b.replace(/^origin\//, ''))])].sort();
const NEW = '$(add) New branch…';
const items = [NEW, ...existing];
const choice = await vscode.window.showQuickPick(items, { placeHolder: 'Branch for the new worktree (auto: checkout if exists, else create)' });
if (!choice) return;
let branch = choice;
if (choice === NEW) {
const input = await vscode.window.showInputBox({ prompt: 'New branch name' });
if (!input) return;
branch = input.trim();
}
if (!branch) return;
const res = await deps.rest.createWorktree(repoId, { branch, mode: 'auto' });
void vscode.window.showInformationMessage(`Arboretum: worktree ${res.action} for "${branch}".`);
});
}
async function promoteWorktreeFlow(deps: CommandDeps, repoId: string, worktree: WorktreeSummary): Promise<void> {
const label = worktree.branch ?? worktree.path;
const confirm = await vscode.window.showWarningMessage(
`Promote "${label}" to the repo's main checkout? The worktree is removed (branch kept).`,
{ modal: true },
'Promote',
);
if (confirm !== 'Promote') return;
try {
await deps.rest.promoteWorktree(repoId, { path: worktree.path });
void vscode.window.showInformationMessage(`Arboretum: "${label}" promoted to main.`);
} catch (err) {
if (err instanceof RestError && err.status === 409) {
const force = await vscode.window.showWarningMessage(
`Arboretum: working tree is dirty (${err.message}). Promote anyway?`,
{ modal: true },
'Force promote',
);
if (force === 'Force promote') {
await run('promote', async () => {
await deps.rest.promoteWorktree(repoId, { path: worktree.path, force: true });
void vscode.window.showInformationMessage(`Arboretum: "${label}" promoted (forced).`);
});
}
return;
}
const msg = err instanceof RestError ? `${err.code}: ${err.message}` : (err as Error).message;
void vscode.window.showErrorMessage(`Arboretum — promote: ${msg}`);
}
}
async function startGroupSessionFlow(deps: CommandDeps, group: GroupSummary): Promise<void> {
await run('start group session', async () => {
const branch = await vscode.window.showInputBox({
prompt: `Branch to cover in each repo of "${group.label}" (leave empty for the main checkouts)`,
placeHolder: 'feature/cross-repo (optional)',
});
if (branch === undefined) return; // annulé (chaîne vide = checkouts principaux)
const body = branch.trim() ? { command: 'claude' as const, branch: branch.trim() } : { command: 'claude' as const };
const res = await deps.rest.createGroupSession(group.id, body);
deps.terminals.open(res.session, 'interactive');
if (res.skipped.length > 0) {
void vscode.window.showWarningMessage(
`Arboretum: ${res.skipped.length} repo(s) skipped — ${res.skipped.map((s) => s.reason).join('; ')}`,
);
}
});
}

View File

@@ -0,0 +1,21 @@
// Lecture des réglages `arboretum.*` — module pur (aucun import vscode) pour rester testable.
// L'extension fournit un `ConfigSnapshot` lu depuis workspace.getConfiguration ; ce module
// normalise/dérive les valeurs (notamment l'URL WebSocket à partir de l'URL HTTP).
export interface ConfigSnapshot {
url: string;
showExternalSessions: boolean;
notifyOnWaiting: boolean;
}
/** Base REST normalisée (sans slash final) ; `http://127.0.0.1:7317` par défaut. */
export function normalizeBaseUrl(url: string): string {
const trimmed = (url || '').trim().replace(/\/+$/, '');
return trimmed || 'http://127.0.0.1:7317';
}
/** Dérive l'URL du endpoint WebSocket `/ws` (http→ws, https→wss). */
export function wsUrlFromBase(baseUrl: string): string {
const base = normalizeBaseUrl(baseUrl);
return `${base.replace(/^http/, 'ws')}/ws`;
}

View File

@@ -0,0 +1,152 @@
// Point d'entrée de l'extension : câble auth ↔ REST ↔ WS ↔ store ↔ surfaces natives (arbres, terminaux,
// status bar, notifications, workspace). C'est le SEUL gros consommateur de l'API vscode avec les vues ;
// toute la logique réutilisable (protocole, état) vit dans des modules purs testables.
import * as vscode from 'vscode';
import { Auth } from './auth/auth.js';
import { RestClient } from './api/rest-client.js';
import { ArbWsClient } from './api/ws-client.js';
import { wsSocketFactory } from './api/ws-socket.js';
import { Store } from './state/store.js';
import { SessionTerminalManager } from './terminal/session-pty.js';
import { ReposTreeProvider } from './views/repos-tree.js';
import { GroupsTreeProvider } from './views/groups-tree.js';
import { StatusBar } from './status-bar.js';
import { WaitingNotifier } from './notifications.js';
import { WorkspaceMapper } from './workspace.js';
import { answerSession, registerCommands } from './commands.js';
function readUrl(): string {
return vscode.workspace.getConfiguration('arboretum').get<string>('url', 'http://127.0.0.1:7317');
}
function readShowExternal(): boolean {
return vscode.workspace.getConfiguration('arboretum').get<boolean>('showExternalSessions', false);
}
function readNotify(): boolean {
return vscode.workspace.getConfiguration('arboretum').get<boolean>('notifyOnWaiting', true);
}
async function setConnectedContext(connected: boolean): Promise<void> {
await vscode.commands.executeCommand('setContext', 'arboretum.connected', connected);
}
export async function activate(context: vscode.ExtensionContext): Promise<void> {
const log = vscode.window.createOutputChannel('Arboretum');
context.subscriptions.push(log);
const auth = new Auth(context.secrets);
const store = new Store();
store.showExternalSessions = readShowExternal();
const rest = new RestClient({ baseUrl: readUrl() });
const reseed = async (): Promise<void> => {
try {
await store.seed(rest);
} catch (err) {
log.appendLine(`seed failed: ${(err as Error).message}`);
}
};
const statusBar = new StatusBar(store);
context.subscriptions.push(statusBar);
const ws = new ArbWsClient({
socketFactory: wsSocketFactory,
onSession: (e) => store.applySession(e),
onWorktree: (e) => store.applyWorktree(e),
onGroup: (e) => store.applyGroup(e),
onReady: () => void reseed(),
onStatus: (s) => {
const connected = s === 'open';
statusBar.setConnected(connected);
void setConnectedContext(connected);
},
log: (m) => log.appendLine(m),
});
const terminals = new SessionTerminalManager(ws);
context.subscriptions.push({ dispose: () => terminals.dispose() });
const reposProvider = new ReposTreeProvider(store);
const groupsProvider = new GroupsTreeProvider(store);
const reposView = vscode.window.createTreeView('arboretum.repos', { treeDataProvider: reposProvider, showCollapseAll: true });
const groupsView = vscode.window.createTreeView('arboretum.groups', { treeDataProvider: groupsProvider, showCollapseAll: true });
context.subscriptions.push(reposView, groupsView);
const workspace = new WorkspaceMapper(store, reposView, reposProvider);
context.subscriptions.push(workspace);
const signIn = async (): Promise<void> => {
const token = await vscode.window.showInputBox({
prompt: `Arboretum token for ${rest.url}`,
password: true,
ignoreFocusOut: true,
placeHolder: 'arb_… (printed once on first daemon start, or created in Settings → Tokens)',
});
if (!token) return;
// valide le token avant de le stocker (sonde isolée pour ne pas polluer le client principal).
const probe = new RestClient({ baseUrl: rest.url, token });
try {
await probe.me();
} catch (err) {
void vscode.window.showErrorMessage(`Arboretum: sign in failed — ${(err as Error).message}`);
return;
}
await auth.storeToken(token);
rest.setToken(token);
ws.configure(rest.url, token);
ws.connect();
void vscode.window.showInformationMessage('Arboretum: connected.');
};
const signOut = async (): Promise<void> => {
await auth.clearToken();
rest.setToken(null);
ws.disconnect();
store.clear();
await setConnectedContext(false);
void vscode.window.showInformationMessage('Arboretum: signed out.');
};
registerCommands(context, { store, rest, ws, terminals, workspace, signIn, signOut, reseed });
// Notifications : ouvrir le terminal ou répondre Yes/No (réutilise le helper transient-attach).
const notifier = new WaitingNotifier(store, {
notifyEnabled: () => readNotify(),
openTerminal: (session) => terminals.open(session, 'interactive'),
answer: (session, action) => {
void answerSession({ store, rest, ws, terminals, workspace, signIn, signOut, reseed }, session, action).catch((err: Error) =>
log.appendLine(`answer failed: ${err.message}`),
);
},
});
context.subscriptions.push(notifier);
// Réglages à chaud : URL (reconfigure REST+WS), sessions externes (refiltre), notif (lu à la volée).
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration(async (e) => {
if (e.affectsConfiguration('arboretum.url')) {
rest.setBaseUrl(readUrl());
const token = await auth.getToken();
if (token) ws.configure(rest.url, token);
}
if (e.affectsConfiguration('arboretum.showExternalSessions')) {
store.setShowExternalSessions(readShowExternal());
}
}),
);
// Connexion initiale si un token est déjà mémorisé.
await setConnectedContext(false);
const token = await auth.getToken();
if (token) {
rest.setToken(token);
ws.configure(rest.url, token);
ws.connect();
}
context.subscriptions.push({ dispose: () => ws.disconnect() });
}
export function deactivate(): void {
// tout est enregistré dans context.subscriptions → disposé par VS Code.
}

View File

@@ -0,0 +1,57 @@
// Notifications natives sur front montant d'une session vers `waiting` (doublonne utilement le Web
// Push, ici dans l'éditeur). Suit l'activité précédente par session pour ne notifier qu'à la transition,
// pas à chaque mise à jour. Actions : ouvrir le terminal, ou répondre Yes/No via la commande WS `answer`.
import * as vscode from 'vscode';
import type { SessionSummary } from '@arboretum/shared';
import type { Store } from './state/store.js';
export interface NotificationHooks {
notifyEnabled(): boolean;
openTerminal(session: SessionSummary): void;
answer(session: SessionSummary, action: 'confirm' | 'deny'): void;
}
export class WaitingNotifier implements vscode.Disposable {
/** dernière activité connue par session → détection du front montant vers 'waiting'. */
private readonly lastActivity = new Map<string, string | null | undefined>();
private readonly unsubscribe: () => void;
constructor(
private readonly store: Store,
private readonly hooks: NotificationHooks,
) {
this.unsubscribe = store.onDidChange(() => this.check());
}
private check(): void {
const sessions = this.store.allSessions();
const seen = new Set<string>();
for (const s of sessions) {
seen.add(s.id);
const prev = this.lastActivity.get(s.id);
const cur = s.live ? s.activity : null;
this.lastActivity.set(s.id, cur);
if (cur === 'waiting' && prev !== 'waiting' && prev !== undefined && this.hooks.notifyEnabled()) {
this.notify(s);
}
// prev === undefined : première observation (au seed) → on n'annonce pas l'historique.
}
for (const id of [...this.lastActivity.keys()]) if (!seen.has(id)) this.lastActivity.delete(id);
}
private notify(session: SessionSummary): void {
const title = session.title?.trim() || session.command;
const detail = session.waitingFor ? `${session.waitingFor}` : '';
void vscode.window
.showInformationMessage(`Arboretum: "${title}" is waiting for input${detail}`, 'Open Terminal', 'Yes', 'No')
.then((choice) => {
if (choice === 'Open Terminal') this.hooks.openTerminal(session);
else if (choice === 'Yes') this.hooks.answer(session, 'confirm');
else if (choice === 'No') this.hooks.answer(session, 'deny');
});
}
dispose(): void {
this.unsubscribe();
}
}

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;
}
}

View File

@@ -0,0 +1,40 @@
// Status bar : compteur des sessions Claude en attente d'input (`activity === 'waiting'`).
// Clic → quick-pick des sessions en attente (commande arboretum.showWaiting). Masqué si déconnecté
// ou s'il n'y a rien en attente.
import * as vscode from 'vscode';
import type { Store } from './state/store.js';
export class StatusBar implements vscode.Disposable {
private readonly item: vscode.StatusBarItem;
private connected = false;
private readonly unsubscribe: () => void;
constructor(private readonly store: Store) {
this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
this.item.command = 'arboretum.showWaiting';
this.unsubscribe = store.onDidChange(() => this.update());
this.update();
}
setConnected(connected: boolean): void {
this.connected = connected;
this.update();
}
private update(): void {
const count = this.connected ? this.store.waitingSessions().length : 0;
if (count === 0) {
this.item.hide();
return;
}
this.item.text = `$(bell-dot) ${count}`;
this.item.tooltip = `Arboretum: ${count} session(s) waiting for input`;
this.item.color = new vscode.ThemeColor('statusBarItem.warningForeground');
this.item.show();
}
dispose(): void {
this.unsubscribe();
this.item.dispose();
}
}

View File

@@ -0,0 +1,85 @@
// Pont d'une session vers un terminal — module pur (aucun import vscode), testable.
// Le ws-client remet des octets BRUTS (un chunk PTY peut couper un caractère UTF-8 en frontière de
// frame, exactement comme côté web où xterm.write(Uint8Array) gérait le décodage). Ici la cible est
// un Pseudoterminal qui veut des `string` → on décode en streaming avec un TextDecoder, et on recrée
// le décodeur à chaque RESYNC pour ne pas traîner une séquence partielle au-delà d'un reset.
import type { ArbWsClient, Attachment, AttachmentSink, DetachReason } from '../api/ws-client.js';
/** Décodeur UTF-8 incrémental : conserve l'octet partiel d'une frontière de frame jusqu'au suivant. */
export class StreamingUtf8Decoder {
private decoder = new TextDecoder('utf-8');
decode(chunk: Uint8Array): string {
return this.decoder.decode(chunk, { stream: true });
}
/** Vide l'état partiel et repart à neuf (appelé sur RESYNC). */
reset(): void {
this.decoder = new TextDecoder('utf-8');
}
}
export interface SessionBridgeCallbacks {
/** texte décodé à écrire dans le terminal. */
onText(text: string): void;
/** RESYNC reçu : le terminal doit se réinitialiser (le texte de replay suit via onText). */
onReset(): void;
onDetached(reason: DetachReason): void;
onControlChanged(controlling: boolean): void;
}
/**
* Relie une session Arboretum à un terminal via le ws-client. Instancié par l'adaptateur
* Pseudoterminal ; reste pur pour être testable avec un faux ws-client.
*/
export class SessionBridge {
private attachment: Attachment | null = null;
private readonly decoder = new StreamingUtf8Decoder();
private detached = false;
constructor(
private readonly ws: ArbWsClient,
private readonly sessionId: string,
private readonly mode: 'interactive' | 'observer',
private readonly cb: SessionBridgeCallbacks,
) {}
/** Ouvre l'attache ; résout quand le canal est établi. Rejette si la session est introuvable. */
async open(cols: number, rows: number): Promise<void> {
const sink: AttachmentSink = {
data: (payload) => {
if (this.detached) return;
const text = this.decoder.decode(payload);
if (text.length > 0) this.cb.onText(text);
},
reset: () => {
this.decoder.reset();
this.cb.onReset();
},
detached: (reason) => {
this.detached = true;
this.cb.onDetached(reason);
},
controlChanged: (c) => this.cb.onControlChanged(c),
};
this.attachment = await this.ws.attach({ sessionId: this.sessionId, mode: this.mode, cols, rows, sink });
}
sendStdin(data: string): void {
this.attachment?.sendStdin(data);
}
resize(cols: number, rows: number): void {
this.attachment?.resize(cols, rows);
}
answer(action: 'select' | 'confirm' | 'deny', optionN?: number): void {
this.attachment?.answer(action, optionN);
}
close(): void {
this.detached = true;
this.attachment?.detach();
this.attachment = null;
}
}

View File

@@ -0,0 +1,86 @@
// Adaptateur vscode.Pseudoterminal : expose une session Arboretum comme un terminal NATIF de VS Code
// (rendu, scrollback, copier/coller, liens gratuits). C'est la pièce maîtresse de l'intégration —
// pas de xterm-in-webview. Le pont WS/décodage vit dans SessionBridge (pur) ; cet adaptateur ne fait
// que relier les events Pseudoterminal ↔ bridge.
import * as vscode from 'vscode';
import type { SessionSummary } from '@arboretum/shared';
import type { ArbWsClient } from '../api/ws-client.js';
import { SessionBridge } from './session-bridge.js';
const ANSI_RESET = '\x1bc'; // RIS : réinitialisation complète du terminal (avant le replay d'un RESYNC)
export class SessionTerminalManager {
private readonly terminals = new Map<string, vscode.Terminal>();
private readonly bridges = new Map<string, SessionBridge>();
constructor(private readonly ws: ArbWsClient) {}
/** Bridge d'une session si un terminal est ouvert (pour répondre à un dialogue sans ré-attacher). */
getBridge(sessionId: string): SessionBridge | undefined {
return this.bridges.get(sessionId);
}
/** Ouvre (ou ré-affiche) un terminal natif attaché à la session. */
open(session: SessionSummary, mode: 'interactive' | 'observer'): void {
const existing = this.terminals.get(session.id);
if (existing) {
existing.show();
return;
}
const writeEmitter = new vscode.EventEmitter<string>();
const closeEmitter = new vscode.EventEmitter<number | void>();
let bridge: SessionBridge | null = null;
const pty: vscode.Pseudoterminal = {
onDidWrite: writeEmitter.event,
onDidClose: closeEmitter.event,
open: (dims) => {
bridge = new SessionBridge(this.ws, session.id, mode, {
onText: (text) => writeEmitter.fire(text),
onReset: () => writeEmitter.fire(ANSI_RESET),
onDetached: () => {
writeEmitter.fire('\r\n\x1b[2m[arboretum: session ended]\x1b[0m\r\n');
closeEmitter.fire();
},
onControlChanged: (controlling) => {
if (!controlling && mode === 'interactive') {
writeEmitter.fire('\r\n\x1b[33m[arboretum: control lost — now observing]\x1b[0m\r\n');
}
},
});
this.bridges.set(session.id, bridge);
bridge.open(dims?.columns ?? 80, dims?.rows ?? 24).catch((err: Error) => {
writeEmitter.fire(`\r\n\x1b[31m[arboretum: cannot attach — ${err.message}]\x1b[0m\r\n`);
closeEmitter.fire();
});
},
close: () => {
bridge?.close();
this.bridges.delete(session.id);
this.terminals.delete(session.id);
},
handleInput: (data) => {
if (mode === 'interactive') bridge?.sendStdin(data);
},
setDimensions: (dims) => bridge?.resize(dims.columns, dims.rows),
};
const name = this.terminalName(session, mode);
const terminal = vscode.window.createTerminal({ name, pty, iconPath: new vscode.ThemeIcon('comment-discussion') });
this.terminals.set(session.id, terminal);
terminal.show();
}
private terminalName(session: SessionSummary, mode: 'interactive' | 'observer'): string {
const base = session.title?.trim() || session.cwd.split('/').pop() || session.command;
const prefix = mode === 'observer' ? '👁 ' : '';
return `${prefix}${base}`;
}
dispose(): void {
for (const t of this.terminals.values()) t.dispose();
this.terminals.clear();
this.bridges.clear();
}
}

View File

@@ -0,0 +1,64 @@
// Arbre Groups → repos membres → worktrees/sessions, plus les sessions de groupe (multi-repo, --add-dir)
// rattachées directement au groupe. Rafraîchi en temps réel (store.onDidChange).
import * as vscode from 'vscode';
import type { Store } from '../state/store.js';
import type { GroupsNode } from './nodes.js';
import { groupTreeItem, repoTreeItem, sessionTreeItem, worktreeTreeItem } from './tree-items.js';
export class GroupsTreeProvider implements vscode.TreeDataProvider<GroupsNode> {
private readonly changeEmitter = new vscode.EventEmitter<GroupsNode | undefined>();
readonly onDidChangeTreeData = this.changeEmitter.event;
constructor(private readonly store: Store) {
store.onDidChange(() => this.changeEmitter.fire(undefined));
}
getTreeItem(node: GroupsNode): vscode.TreeItem {
switch (node.kind) {
case 'group':
return groupTreeItem(node.group);
case 'repo':
return repoTreeItem(node.repo);
case 'worktree':
return worktreeTreeItem(node.worktree);
case 'group-session':
case 'session': {
const item = sessionTreeItem(node.session);
if (node.session.live) {
item.command = { command: 'arboretum.attachSession', title: 'Attach', arguments: [node] };
}
return item;
}
}
}
getChildren(node?: GroupsNode): GroupsNode[] {
if (!node) {
return this.store.listGroups().map((group) => ({ kind: 'group', group }));
}
if (node.kind === 'group') {
const children: GroupsNode[] = [];
// sessions de groupe (couvrant plusieurs repos) en tête
for (const session of this.store.sessionsForGroup(node.group.id)) {
children.push({ kind: 'group-session', session, groupId: node.group.id });
}
// puis les repos membres
for (const repoId of node.group.repoIds) {
const repo = this.store.getRepo(repoId);
if (repo) children.push({ kind: 'repo', repo });
}
return children;
}
if (node.kind === 'repo') {
return this.store
.listWorktrees(node.repo.id)
.map((worktree) => ({ kind: 'worktree', repoId: node.repo.id, worktree }));
}
if (node.kind === 'worktree') {
return this.store
.sessionsForWorktree(node.worktree.path)
.map((session) => ({ kind: 'session', session, repoId: node.repoId, worktreePath: node.worktree.path }));
}
return [];
}
}

View File

@@ -0,0 +1,33 @@
// Types de nœuds des arbres. Les handlers de commandes reçoivent un nœud (clic ou menu contextuel) et
// narrowent sur `kind`. Volontairement plats (données + discriminant) ; la hiérarchie parent/enfant est
// tenue par les providers (maps indexées) pour que reveal()/getParent fonctionnent.
import type { GroupSummary, RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
export interface RepoNode {
kind: 'repo';
repo: RepoSummary;
}
export interface WorktreeNode {
kind: 'worktree';
repoId: string;
worktree: WorktreeSummary;
}
export interface SessionNode {
kind: 'session';
session: SessionSummary;
repoId: string;
worktreePath: string;
}
export interface GroupNode {
kind: 'group';
group: GroupSummary;
}
export interface GroupSessionNode {
kind: 'group-session';
session: SessionSummary;
groupId: string;
}
export type ReposNode = RepoNode | WorktreeNode | SessionNode;
export type GroupsNode = GroupNode | RepoNode | WorktreeNode | SessionNode | GroupSessionNode;
export type AnyNode = ReposNode | GroupsNode;

Binary file not shown.

View File

@@ -0,0 +1,95 @@
// Helpers de construction des TreeItem (icônes d'état, labels, contextValue) partagés par les deux
// arbres (Repositories & Groups). Les `contextValue` pilotent les menus contextuels (cf. package.json) ;
// les regex y matchent des sous-chaînes → format stable `arboretum:session:<live|dead>[:discovered][:waiting]`.
import * as vscode from 'vscode';
import type { GroupSummary, RepoSummary, SessionSummary, WorktreeSummary } from '@arboretum/shared';
export function repoTreeItem(repo: RepoSummary): vscode.TreeItem {
const item = new vscode.TreeItem(repo.label, vscode.TreeItemCollapsibleState.Expanded);
item.id = `repo:${repo.id}`;
item.contextValue = 'arboretum:repo';
item.iconPath = new vscode.ThemeIcon('repo');
item.description = repo.defaultBranch ?? '';
item.tooltip = repo.path;
return item;
}
export function worktreeTreeItem(worktree: WorktreeSummary): vscode.TreeItem {
const label = worktree.branch ?? (worktree.detached ? '(detached)' : worktree.head.slice(0, 8));
const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.Expanded);
item.id = `wt:${worktree.path}`;
item.contextValue = 'arboretum:worktree';
item.iconPath = new vscode.ThemeIcon(worktree.isMain ? 'star-full' : 'git-branch');
item.description = worktreeStatus(worktree);
item.tooltip = worktree.path;
item.resourceUri = vscode.Uri.file(worktree.path);
return item;
}
function worktreeStatus(w: WorktreeSummary): string {
const parts: string[] = [];
if (w.isMain) parts.push('main');
if (w.git.ahead > 0) parts.push(`${w.git.ahead}`);
if (w.git.behind > 0) parts.push(`${w.git.behind}`);
if (w.git.dirtyCount > 0) parts.push(`${w.git.dirtyCount}`);
return parts.join(' ');
}
export function sessionContextValue(s: SessionSummary): string {
let v = `arboretum:session:${s.live ? 'live' : 'dead'}`;
if (s.source === 'discovered') v += ':discovered';
if (s.live && s.activity === 'waiting') v += ':waiting';
return v;
}
export function sessionTreeItem(s: SessionSummary): vscode.TreeItem {
const label = s.title?.trim() || s.command;
const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.None);
item.id = `session:${s.id}`;
item.contextValue = sessionContextValue(s);
item.iconPath = sessionIcon(s);
item.description = sessionDescription(s);
item.tooltip = sessionTooltip(s);
return item;
}
function sessionIcon(s: SessionSummary): vscode.ThemeIcon {
if (!s.live) return new vscode.ThemeIcon('circle-slash');
switch (s.activity) {
case 'waiting':
return new vscode.ThemeIcon('bell-dot', new vscode.ThemeColor('charts.yellow'));
case 'busy':
return new vscode.ThemeIcon('loading~spin');
case 'idle':
return new vscode.ThemeIcon('pass', new vscode.ThemeColor('charts.green'));
default:
return new vscode.ThemeIcon('terminal');
}
}
function sessionDescription(s: SessionSummary): string {
const parts: string[] = [];
if (s.live && s.activity) parts.push(s.activity);
else if (!s.live) parts.push('exited');
if (s.source === 'discovered') parts.push('external');
if (s.groupId) parts.push('group');
if (s.waitingFor) parts.push(`${s.waitingFor}`);
return parts.join(' ');
}
function sessionTooltip(s: SessionSummary): string {
const lines = [`${s.command}${s.cwd}`, `status: ${s.status}${s.live ? ' (live)' : ''}`];
if (s.activity) lines.push(`activity: ${s.activity}`);
if (s.addedDirs && s.addedDirs.length > 0) lines.push(`added dirs: ${s.addedDirs.length}`);
return lines.join('\n');
}
export function groupTreeItem(group: GroupSummary): vscode.TreeItem {
const item = new vscode.TreeItem(group.label, vscode.TreeItemCollapsibleState.Collapsed);
item.id = `group:${group.id}`;
item.contextValue = 'arboretum:group';
item.iconPath = new vscode.ThemeIcon('folder-library');
item.description = `${group.repoIds.length} repos`;
if (group.description) item.tooltip = group.description;
return item;
}

View File

@@ -0,0 +1,57 @@
// Conscience du workspace (Phase D) : mappe les dossiers ouverts dans VS Code aux repos/worktrees
// Arboretum par égalité de chemin, met en évidence le worktree correspondant dans l'arbre, et fournit
// le repoId courant aux commandes contextuelles (« start session here », « create worktree here »).
import * as vscode from 'vscode';
import type { Store } from './state/store.js';
import type { ReposNode } from './views/nodes.js';
import type { ReposTreeProvider } from './views/repos-tree.js';
function normPath(p: string): string {
return p.replace(/\/+$/, '') || '/';
}
export class WorkspaceMapper implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
constructor(
private readonly store: Store,
private readonly treeView: vscode.TreeView<ReposNode>,
private readonly provider: ReposTreeProvider,
) {
this.disposables.push(vscode.workspace.onDidChangeWorkspaceFolders(() => this.revealCurrent()));
// re-tente après chaque rafraîchissement de l'état (les worktrees arrivent après la connexion).
this.disposables.push({ dispose: store.onDidChange(() => this.revealCurrent()) });
}
/** repoId correspondant à un dossier ouvert (via worktree ou racine de repo), pour les actions contextuelles. */
currentRepoId(): string | undefined {
for (const folder of vscode.workspace.workspaceFolders ?? []) {
const p = normPath(folder.uri.fsPath);
const match = this.store.findWorktreeByPath(p);
if (match) return match.repoId;
const repo = this.store.listRepos().find((r) => normPath(r.path) === p);
if (repo) return repo.id;
}
return undefined;
}
/** Met en évidence dans l'arbre le worktree du premier dossier ouvert qui matche. */
revealCurrent(): void {
if (!this.treeView.visible) return;
for (const folder of vscode.workspace.workspaceFolders ?? []) {
const match = this.store.findWorktreeByPath(normPath(folder.uri.fsPath));
if (!match) continue;
const node = this.provider.worktreeNode(match.repoId, match.worktree.path);
if (node) {
void this.treeView.reveal(node, { select: false, focus: false, expand: true }).then(undefined, () => {
/* reveal est best-effort : un échec (nœud pas encore matérialisé) n'est pas bloquant */
});
return;
}
}
}
dispose(): void {
for (const d of this.disposables) d.dispose();
}
}