Un terminal pouvait rester tout noir alors que sa session tournait. Le PTY était vivant et avait bien écrit sa sortie : la panne était dans le transport. Le replay d'attache est une frame BINAIRE, mais un client n'apprend son numéro de canal qu'avec le message `attached` ; le serveur envoyait le replay AVANT, donc tout client le jetait sur un canal inconnu. Rien n'était peint, et un TUI au repos (Claude à son prompt) ne réémet jamais rien de lui-même. `attach()` renvoie désormais le replay et la gateway l'émet APRÈS `attached` : un seul correctif serveur répare le web, l'app de bureau et l'extension VS Code, qui portaient le même défaut client. Le resize de l'attache masquait le bug en provoquant un SIGWINCH, d'où son apparence intermittente. Seconde moitié du symptôme (« je tape et rien ne se passe ») : le dock montait avant la liste des sessions, en déduisait « non attachable » et s'attachait en observateur, à vie et en silence. Un pane n'attache plus avant de connaître sa session (`sessions.loaded`). Attaches sans écran : le message `attach` accepte un `screen` optionnel (défaut true). Un client qui n'affiche rien et veut seulement répondre à un dialogue ne prend plus le contrôle de la session, ne lui impose plus ses dimensions (ce qui figeait la géométrie du vrai terminal) et ne reçoit plus le flux pour le jeter. Rendre les pannes visibles : la raison d'un exit est écrite dans le terminal (`[arboretum] bash exited with code 3`) avant le détachement ; un repaint est forcé si rien n'arrive 1,2 s après l'attache, puis annoncé avec « Refresh screen » ; les refus de canal remontent à l'écran au lieu d'un console.warn ; le chemin du CLI claude est revalidé (périmé après une bascule nvm/asdf, le PTY mourait sans un octet). Colonnes de terminaux : le dock devient une rangée de colonnes redimensionnables (3 max), chacune avec ses onglets. Algèbre pure dans lib/dock-model.ts, cinq invariants documentés, ratios plutôt que pixels. `dockSessionIds` et `activeDockSessionId` deviennent des computed dérivés : aucun consommateur ni test existant ne change. Alt+clic ouvre à côté depuis les quatre panneaux. Le plafond de hauteur du dock suit le viewport au lieu d'un 640 px figé. Correctif préexistant au passage : PanelSplitter passait ses bornes par valeur, figées au premier rendu, alors que le clavier les relisait. Portée git : la vue Changements suit le worktree du terminal focalisé, ou tous les dépôts de son groupe pour une session de groupe, avec « tout voir » à un clic. L'index Git de la sidebar reste global (c'est la sortie d'une portée étroite) et le badge d'activité aussi (il sert à signaler le travail qu'on ne regarde pas). Seul le TERMINAL impose le contexte : le repli sur l'onglet éditeur, essayé d'abord, rétrécissait la vue multi-projet dès qu'on ouvrait un fichier. Vérifications : acceptance-p17.mjs prouve l'ordre des trames sur un vrai WebSocket (avec l'ancien ordre : 0 octet rejoué, échec), verify-terminals.mjs prouve par interaction réelle que le terminal peint, que deux colonnes coexistent, que la frappe atteint le bon PTY (fichier témoin par cwd) et que la vue suit le terminal.
395 lines
16 KiB
TypeScript
395 lines
16 KiB
TypeScript
// 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<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: () => {} };
|
|
// `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<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('; ')}`,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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<void> {
|
|
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<void> {
|
|
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)));
|
|
});
|
|
}
|