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:
@@ -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('; ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user