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,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BINARY_FRAME, FLOW, encodeBinaryFrame, type ClientMessage } from '@arboretum/shared';
import { ArbWsClient, type AttachmentSink, type RawSocket } from '../src/api/ws-client.js';
class FakeSocket implements RawSocket {
sent: string[] = [];
private openCb: (() => void) | null = null;
private textCb: ((t: string) => void) | null = null;
private binaryCb: ((d: Uint8Array) => void) | null = null;
private closeCb: (() => void) | null = null;
send(data: string): void {
this.sent.push(data);
}
close(): void {
this.closeCb?.();
}
onOpen(cb: () => void): void {
this.openCb = cb;
}
onText(cb: (t: string) => void): void {
this.textCb = cb;
}
onBinary(cb: (d: Uint8Array) => void): void {
this.binaryCb = cb;
}
onClose(cb: () => void): void {
this.closeCb = cb;
}
onError(): void {
/* non utilisé dans ces tests */
}
fireOpen(): void {
this.openCb?.();
}
fireText(msg: unknown): void {
this.textCb?.(JSON.stringify(msg));
}
fireBinary(data: Uint8Array): void {
this.binaryCb?.(data);
}
/** messages de contrôle envoyés, parsés et filtrés par type. */
controls<T extends ClientMessage['type']>(type: T): Array<Extract<ClientMessage, { type: T }>> {
return this.sent
.map((s) => JSON.parse(s) as ClientMessage)
.filter((m): m is Extract<ClientMessage, { type: T }> => m.type === type);
}
}
function makeNoopSink(): AttachmentSink {
return { data: () => {}, reset: () => {}, detached: () => {}, controlChanged: () => {} };
}
describe('ArbWsClient', () => {
let fake: FakeSocket;
let client: ArbWsClient;
beforeEach(() => {
vi.useFakeTimers();
fake = new FakeSocket();
client = new ArbWsClient({ socketFactory: () => fake });
client.configure('http://127.0.0.1:7317', 'arb_token');
client.connect();
fake.fireOpen();
fake.fireText({ type: 'hello_ok', protocol: 1, serverVersion: '1.8.0' });
});
afterEach(() => {
vi.useRealTimers();
});
it('envoie hello puis sub après hello_ok', () => {
expect(fake.controls('hello')).toHaveLength(1);
expect(fake.controls('sub')[0]?.topics).toEqual(['sessions', 'worktrees', 'groups']);
});
it('corrèle attach→attached et délivre les OUTPUT au sink', async () => {
const chunks: Uint8Array[] = [];
const sink: AttachmentSink = { ...makeNoopSink(), data: (p) => chunks.push(p) };
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink });
fake.fireText({ type: 'attached', channel: 1, sessionId: 'sess', mode: 'interactive', controlling: true });
const att = await p;
expect(att.channel).toBe(1);
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 1, new Uint8Array([104, 105]))); // "hi"
expect(chunks).toHaveLength(1);
expect([...(chunks[0] ?? [])]).toEqual([104, 105]);
});
it('ACK au seuil ACK_EVERY_BYTES', async () => {
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink: makeNoopSink() });
fake.fireText({ type: 'attached', channel: 7, sessionId: 'sess', mode: 'interactive', controlling: true });
await p;
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 7, new Uint8Array(FLOW.ACK_EVERY_BYTES)));
const acks = fake.controls('ack');
expect(acks).toHaveLength(1);
expect(acks[0]).toMatchObject({ channel: 7, bytes: FLOW.ACK_EVERY_BYTES });
});
it('ACK traînant flushe un reliquat sous le seuil après 200ms', async () => {
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink: makeNoopSink() });
fake.fireText({ type: 'attached', channel: 2, sessionId: 'sess', mode: 'interactive', controlling: true });
await p;
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 2, new Uint8Array(1000)));
expect(fake.controls('ack')).toHaveLength(0); // sous le seuil → pas dACK immédiat
vi.advanceTimersByTime(200);
expect(fake.controls('ack')).toEqual([{ type: 'ack', channel: 2, bytes: 1000 }]);
});
it('RESYNC réinitialise le terminal et les compteurs dACK', async () => {
let resets = 0;
const sink: AttachmentSink = { ...makeNoopSink(), reset: () => (resets += 1) };
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink });
fake.fireText({ type: 'attached', channel: 3, sessionId: 'sess', mode: 'interactive', controlling: true });
await p;
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 3, new Uint8Array(FLOW.ACK_EVERY_BYTES)));
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.RESYNC, 3, new Uint8Array([65]))); // reset + replay "A"
expect(resets).toBe(1);
// après resync, le replay ne compte pas : un nouvel OUTPUT au seuil ré-ACK à partir de 0
fake.fireBinary(encodeBinaryFrame(BINARY_FRAME.OUTPUT, 3, new Uint8Array(FLOW.ACK_EVERY_BYTES)));
const acks = fake.controls('ack');
expect(acks[acks.length - 1]).toMatchObject({ bytes: FLOW.ACK_EVERY_BYTES });
});
it('envoie stdin/resize sur le bon canal', async () => {
const p = client.attach({ sessionId: 'sess', mode: 'interactive', cols: 80, rows: 24, sink: makeNoopSink() });
fake.fireText({ type: 'attached', channel: 5, sessionId: 'sess', mode: 'interactive', controlling: true });
const att = await p;
att.sendStdin('ls\n');
att.resize(120, 40);
expect(fake.controls('stdin')[0]).toEqual({ type: 'stdin', channel: 5, data: 'ls\n' });
expect(fake.controls('resize')[0]).toEqual({ type: 'resize', channel: 5, cols: 120, rows: 40 });
});
});