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