// 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 { workspaceUrl } from './config.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; signOut(): Promise; reseed(): Promise; } // ---- 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): Promise { 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 { const open = deps.terminals.getBridge(session.id); if (open) { open.answer(action, optionN); return; } const noop: AttachmentSink = { data: () => {}, reset: () => {}, detached: () => {}, controlChanged: () => {} }; // `screen: false` : canal de réponse seul, sans affichage. Sinon il volait le `controlling` de la // session et imposait 80x24 au PTY, ce qui déformait le terminal réellement ouvert ailleurs. const att = await deps.ws.attach({ sessionId: session.id, mode: 'interactive', cols: 80, rows: 24, sink: noop, screen: false }); 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.fetchWorktree', (node) => { const wt = asWorktree(node as AnyNode); if (!wt) return; void vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: 'Arboretum: fetching…' }, () => run('fetch', async () => { await rest.fetchWorktree(wt.repoId, { path: wt.worktree.path }); void vscode.window.showInformationMessage('Arboretum: fetched.'); }), ); }); cmd('arboretum.pullWorktree', (node) => { const wt = asWorktree(node as AnyNode); if (!wt) return; void run('pull', async () => { const mode = await vscode.window.showQuickPick( [ { label: 'Fast-forward only', mode: 'ff-only' as const }, { label: 'Rebase', mode: 'rebase' as const }, ], { placeHolder: 'Pull strategy' }, ); if (!mode) return; await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: 'Arboretum: pulling…' }, async () => { await rest.pullWorktree(wt.repoId, { path: wt.worktree.path, mode: mode.mode }); void vscode.window.showInformationMessage('Arboretum: pulled.'); }); }); }); cmd('arboretum.promoteWorktree', (node) => { const wt = asWorktree(node as AnyNode); if (wt) void promoteWorktreeFlow(deps, wt.repoId, wt.worktree); }); cmd('arboretum.openWorktreeIde', (node) => { const wt = asWorktree(node as AnyNode); if (!wt) return; void vscode.env.openExternal(vscode.Uri.parse(workspaceUrl(rest.url, wt.repoId, wt.worktree.path))); }); // ---- 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); }); // ---- « Démarrer le projet » (lancement multi-terminaux) ---- cmd('arboretum.startProject', (node) => { const wt = asWorktree(node as AnyNode); if (wt) { void startProjectFlow(deps, wt.repoId, { worktreePath: wt.worktree.path }); return; } const repo = asRepo(node as AnyNode); if (repo) void startProjectFlow(deps, repo.id, {}); }); cmd('arboretum.stopLaunch', (node) => { const s = asSession(node as AnyNode); if (s?.launchRunId) void stopLaunchFlow(deps, s.launchRunId); }); // ---- 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 { 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 { 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 { 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('; ')}`, ); } }); } /** * Lance les commandes de démarrage du repo (un terminal natif par commande activée). L'édition des * commandes reste dans l'IDE web : si aucune n'est configurée, on propose de l'ouvrir. */ async function startProjectFlow( deps: CommandDeps, repoId: string, body: { worktreePath?: string }, ): Promise { await run('start project', async () => { const res = await deps.rest.startLaunch(repoId, body); for (const s of res.sessions) deps.terminals.open(s, 'interactive'); if (res.sessions.length === 0) { const OPEN = 'Open web IDE'; const pick = await vscode.window.showInformationMessage( 'Arboretum: no launch commands configured for this project. Define them in the web IDE.', OPEN, ); if (pick === OPEN) void vscode.env.openExternal(vscode.Uri.parse(deps.rest.url)); return; } if (res.skipped.length > 0) { void vscode.window.showWarningMessage( `Arboretum: ${res.skipped.length} command(s) skipped: ${res.skipped.map((s) => s.reason).join('; ')}`, ); } }); } /** Arrête d'un coup tous les terminaux vivants d'un même lancement (partageant le launchRunId). */ async function stopLaunchFlow(deps: CommandDeps, launchRunId: string): Promise { const live = deps.store.allSessions().filter((s) => s.live && s.launchRunId === launchRunId); if (live.length === 0) return; const ok = await vscode.window.showWarningMessage( `Stop all ${live.length} launch terminal(s)?`, { modal: true }, 'Stop all', ); if (ok !== 'Stop all') return; await run('stop launch', async () => { await Promise.all(live.map((s) => deps.rest.killSession(s.id))); }); }