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
+299
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('; ')}`,
);
}
});
}