From f513cff25319f6d9a4361ed4e7989c841e89d73c Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 18 Jun 2026 10:03:19 +0200 Subject: [PATCH] feat: groupes de travail (P5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute la notion de groupe — collection nommée de repos (membership légère persistée, many-to-many) — pour piloter plusieurs repos en simultané : - DB : migration #5 (tables groups + group_repos, FK ON DELETE CASCADE). - GroupManager (synchrone, EventEmitter) + routes REST /api/v1/groups ; ne persiste que la membership, worktrees/sessions filtrés par repoId côté client. - Protocole : GroupSummary, DTOs, topic WS « groups », events group_update/removed (additifs, PROTOCOL_VERSION inchangé). - Web : store groups, GroupsListView, GroupView (réutilise RepoSection), grille multi-terminaux (TerminalGrid/TerminalCell), action « feature cross-repo » (orchestration client, tolérante aux échecs partiels), routes + i18n EN/FR. - Tests : group-manager.test.ts + extension protocol.test.ts ; acceptance-p5.mjs. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.fr.md | 4 +- README.md | 4 +- packages/server/scripts/acceptance-p5.mjs | 146 +++++++++++++ packages/server/src/app.ts | 9 +- packages/server/src/core/group-manager.ts | 201 ++++++++++++++++++ packages/server/src/db/index.ts | 25 +++ packages/server/src/routes/groups.ts | 86 ++++++++ packages/server/src/ws/gateway.ts | 15 ++ packages/server/test/group-manager.test.ts | 100 +++++++++ packages/shared/src/api.ts | 26 ++- packages/shared/src/protocol.ts | 24 ++- packages/shared/test/protocol.test.ts | 9 +- .../src/components/CrossRepoFeatureModal.vue | 122 +++++++++++ packages/web/src/components/TerminalCell.vue | 39 ++++ packages/web/src/components/TerminalGrid.vue | 28 +++ packages/web/src/i18n/en.ts | 48 +++++ packages/web/src/i18n/fr.ts | 48 +++++ packages/web/src/lib/api.ts | 1 + packages/web/src/lib/ws-client.ts | 22 +- packages/web/src/router/index.ts | 2 + packages/web/src/stores/groups.ts | 175 +++++++++++++++ packages/web/src/views/DashboardView.vue | 1 + packages/web/src/views/GroupView.vue | 149 +++++++++++++ packages/web/src/views/GroupsListView.vue | 157 ++++++++++++++ 24 files changed, 1429 insertions(+), 12 deletions(-) create mode 100644 packages/server/scripts/acceptance-p5.mjs create mode 100644 packages/server/src/core/group-manager.ts create mode 100644 packages/server/src/routes/groups.ts create mode 100644 packages/server/test/group-manager.test.ts create mode 100644 packages/web/src/components/CrossRepoFeatureModal.vue create mode 100644 packages/web/src/components/TerminalCell.vue create mode 100644 packages/web/src/components/TerminalGrid.vue create mode 100644 packages/web/src/stores/groups.ts create mode 100644 packages/web/src/views/GroupView.vue create mode 100644 packages/web/src/views/GroupsListView.vue diff --git a/README.fr.md b/README.fr.md index 0b80079..7e8cf40 100644 --- a/README.fr.md +++ b/README.fr.md @@ -10,7 +10,7 @@ English · Français

-**Statut : MVP.** Le dashboard worktree-first, la découverte et la reprise de sessions, le cycle de vie des worktrees multi-repo, les états de session en temps réel, le terminal web et la supervision mobile (PWA installable, Web Push quand une session vous attend, valider/refuser sans ouvrir de terminal) sont implémentés et testés. +**Statut : MVP.** Le dashboard worktree-first, la découverte et la reprise de sessions, le cycle de vie des worktrees multi-repo, les états de session en temps réel, le terminal web, la supervision mobile (PWA installable, Web Push quand une session vous attend, valider/refuser sans ouvrir de terminal) et les groupes de travail (piloter plusieurs repos liés à la fois) sont implémentés et testés. --- @@ -31,6 +31,7 @@ Un unique daemon Node.js que vous lancez sur votre machine de dev (`npx @johanle - **Découverte & reprise de sessions** — les sessions lancées dans votre propre terminal apparaissent automatiquement ; reprenez les sessions mortes, observez ou forkez les vivantes. Ne corrompt jamais une session vivante. - **Terminal web** — terminal xterm.js complet vers chaque session managée, qui survit aux déconnexions du navigateur. - **Supervision depuis votre téléphone** — PWA installable avec notifications push quand une session vous attend ; validez ou refusez une demande sans ouvrir de terminal. +- **Groupes de travail** — regroupez des repos liés (ex. une API, son frontend web et une lib partagée) dans un groupe nommé pour travailler sur tous à la fois : une vue unifiée de leurs worktrees et sessions, une grille multi-terminaux côte à côte, et une action « feature cross-repo » en un clic qui crée le même worktree de branche (et au besoin une session Claude) dans chaque repo du groupe. --- @@ -247,6 +248,7 @@ node packages/server/scripts/acceptance-p1.mjs # cœur : daemon + client WS r node packages/server/scripts/acceptance-p2.mjs # découverte & reprise de sessions node packages/server/scripts/acceptance-p3.mjs # worktrees & corrélation de sessions node packages/server/scripts/acceptance-p4.mjs # Web Push + commande WS `answer` +node packages/server/scripts/acceptance-p5.mjs # groupes de travail : CRUD + broadcast WS + CASCADE ``` ## Licence diff --git a/README.md b/README.md index f525714..8ccebe7 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ English · Français

-**Status: MVP.** The worktree-first dashboard, session discovery & resume, multi-repo worktree lifecycle, live session states, the web terminal, and mobile supervision (installable PWA, Web Push when a session needs you, approve/deny without opening a terminal) are implemented and tested. +**Status: MVP.** The worktree-first dashboard, session discovery & resume, multi-repo worktree lifecycle, live session states, the web terminal, and mobile supervision (installable PWA, Web Push when a session needs you, approve/deny without opening a terminal), and work groups (run several related repos at once) are implemented and tested. --- @@ -31,6 +31,7 @@ A single Node.js daemon you run on your dev machine (`npx @johanleroy/git-arbore - **Session discovery & resume** — sessions you launched in your own terminal show up automatically; resume dead ones, observe or fork live ones. Never corrupts a live session. - **Web terminal** — full xterm.js terminal to every managed session, surviving browser disconnects. - **Supervision from your phone** — installable PWA with push notifications when a session needs you; approve/deny a prompt without opening a terminal. +- **Work groups** — bundle related repos (e.g. an API, its web frontend and a shared library) into a named group to work on them at once: a unified view of all their worktrees and sessions, a side-by-side multi-terminal grid, and a one-click "cross-repo feature" that creates the same branch worktree (and optionally a Claude session) across every repo in the group. --- @@ -247,6 +248,7 @@ node packages/server/scripts/acceptance-p1.mjs # core: daemon + real WS client node packages/server/scripts/acceptance-p2.mjs # session discovery & resume node packages/server/scripts/acceptance-p3.mjs # worktrees & session correlation node packages/server/scripts/acceptance-p4.mjs # Web Push + WS `answer` command +node packages/server/scripts/acceptance-p5.mjs # work groups: CRUD + WS broadcast + CASCADE ``` ## License diff --git a/packages/server/scripts/acceptance-p5.mjs b/packages/server/scripts/acceptance-p5.mjs new file mode 100644 index 0000000..5d47baa --- /dev/null +++ b/packages/server/scripts/acceptance-p5.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +// Acceptation P5 (sans navigateur, sans quota Claude) : groupes de travail. +// Vrai daemon + vrai repo git tmp. Couvre : CRUD groupe via REST, broadcast WS group_update/ +// group_removed sur le topic 'groups', ajout/retrait de repo, et purge CASCADE de la membership +// quand le repo est supprimé (PRAGMA foreign_keys = ON). +import { spawn, execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const WebSocket = require('ws'); + +const PORT = 7545; +const ORIGIN = `http://127.0.0.1:${PORT}`; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const results = []; +const check = (name, ok, detail = '') => { + results.push({ name, ok, detail }); + console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` — ${detail}` : ''}`); +}; + +const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p5-')); +const repo = join(tmp, 'demo-repo'); +execFileSync('mkdir', ['-p', repo]); +const git = (...args) => execFileSync('git', args, { cwd: repo, stdio: 'pipe' }); +git('init', '-b', 'main'); +git('config', 'user.email', 'test@arboretum.dev'); +git('config', 'user.name', 'Test'); +execFileSync('bash', ['-lc', 'echo "# demo" > README.md'], { cwd: repo }); +git('add', '-A'); +git('commit', '-m', 'init'); + +const srv = spawn( + 'node', + [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude')], + { env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] }, +); +let srvOut = ''; +srv.stdout.on('data', (d) => (srvOut += d)); +srv.stderr.on('data', (d) => (srvOut += d)); + +function wsClient(cookie) { + const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } }); + const state = { msgs: [] }; + ws.on('message', (data, isBinary) => { + if (!isBinary) state.msgs.push(JSON.parse(String(data))); + }); + const waitMsg = async (pred, timeout = 8000) => { + const t0 = Date.now(); + while (Date.now() - t0 < timeout) { + const m = state.msgs.find(pred); + if (m) return m; + await sleep(50); + } + return null; + }; + return { ws, state, waitMsg, send: (m) => ws.send(JSON.stringify(m)) }; +} + +const j = (path, method, cookie, body) => + fetch(`${ORIGIN}${path}`, { + method, + headers: { Origin: ORIGIN, Cookie: cookie, ...(body ? { 'Content-Type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + +try { + await sleep(1500); + const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0]; + check('boot + token bootstrap', !!token); + + const login = await fetch(`${ORIGIN}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: ORIGIN }, + body: JSON.stringify({ token }), + }); + const cookie = login.headers.get('set-cookie')?.split(';')[0] ?? ''; + check('login → cookie', login.status === 200); + + const c = wsClient(cookie); + await new Promise((res, rej) => (c.ws.on('open', res), c.ws.on('error', rej))); + c.send({ type: 'hello', protocol: 1 }); + await c.waitMsg((m) => m.type === 'hello_ok'); + c.send({ type: 'sub', topics: ['sessions', 'worktrees', 'groups'] }); + + // Enregistrement d'un repo (cible de la membership). + const addRepo = await j('/api/v1/repos', 'POST', cookie, { path: repo }); + const repoSummary = (await addRepo.json()).repo; + check('POST /repos → 201', addRepo.status === 201 && repoSummary?.valid === true); + + // Création d'un groupe vide via REST → broadcast WS group_update. + const created = await j('/api/v1/groups', 'POST', cookie, { label: 'Sprint 42', color: '#4f46e5' }); + const group = (await created.json()).group; + check('POST /groups → 201', created.status === 201 && group?.label === 'Sprint 42' && group?.repoIds.length === 0); + const pushedCreate = await c.waitMsg((m) => m.type === 'group_update' && m.group?.id === group.id); + check('broadcast WS group_update (création)', !!pushedCreate); + + const list = await (await j('/api/v1/groups', 'GET', cookie)).json(); + check('GET /groups → groupe présent', list.groups?.some((g) => g.id === group.id)); + + // Ajout du repo au groupe → group_update avec repoIds peuplé. + const added = await j(`/api/v1/groups/${group.id}/repos`, 'POST', cookie, { repoId: repoSummary.id }); + const addedBody = await added.json(); + check('POST /groups/:id/repos → repoIds', added.status === 200 && addedBody.group?.repoIds.includes(repoSummary.id)); + const pushedAdd = await c.waitMsg((m) => m.type === 'group_update' && m.group?.repoIds?.includes(repoSummary.id)); + check('broadcast WS group_update (ajout repo)', !!pushedAdd); + + // Renommage via PATCH. + const patched = await j(`/api/v1/groups/${group.id}`, 'PATCH', cookie, { label: 'Renamed' }); + check('PATCH /groups/:id → 200 (renommé)', patched.status === 200 && (await patched.json()).group?.label === 'Renamed'); + + // repoId inexistant → 404. + const bad = await j(`/api/v1/groups/${group.id}/repos`, 'POST', cookie, { repoId: 'does-not-exist' }); + check('POST repo inexistant → 404', bad.status === 404); + + // Suppression du repo → CASCADE purge la membership. + const delRepo = await j(`/api/v1/repos/${repoSummary.id}`, 'DELETE', cookie); + check('DELETE /repos/:id → 200', delRepo.status === 200); + await sleep(200); + const afterCascade = await (await j(`/api/v1/groups/${group.id}`, 'GET', cookie)).json(); + check('CASCADE : membership purgée à la suppression du repo', afterCascade.group?.repoIds.length === 0); + + // Suppression du groupe → broadcast WS group_removed. + const delGroup = await j(`/api/v1/groups/${group.id}`, 'DELETE', cookie); + check('DELETE /groups/:id → 200', delGroup.status === 200); + const removed = await c.waitMsg((m) => m.type === 'group_removed' && m.groupId === group.id); + check('broadcast WS group_removed', !!removed); + const delAgain = await j(`/api/v1/groups/${group.id}`, 'DELETE', cookie); + check('DELETE groupe inconnu → 404', delAgain.status === 404); + + c.ws.close(); +} catch (err) { + check('exception', false, String(err)); +} finally { + srv.kill('SIGTERM'); + await sleep(1500); + check('arrêt propre du daemon (SIGTERM)', srv.exitCode === 0 || srv.signalCode === null || srv.exitCode === null); + rmSync(tmp, { recursive: true, force: true }); + const failed = results.filter((r) => !r.ok); + console.log(failed.length === 0 ? '\nACCEPTANCE P5: ALL GREEN' : `\nACCEPTANCE P5: ${failed.length} FAILURE(S)`); + process.exit(failed.length === 0 ? 0 : 1); +} diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 168f185..421613b 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -11,10 +11,12 @@ import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service. import { PtyManager } from './core/pty-manager.js'; import { DiscoveryService } from './core/discovery-service.js'; import { WorktreeManager } from './core/worktree-manager.js'; +import { GroupManager } from './core/group-manager.js'; import { PushService } from './core/push-service.js'; import { registerAuthRoutes } from './routes/auth.js'; import { registerSessionRoutes } from './routes/sessions.js'; import { registerRepoRoutes } from './routes/repos.js'; +import { registerGroupRoutes } from './routes/groups.js'; import { registerWorktreeRoutes } from './routes/worktrees.js'; import { registerPushRoutes } from './routes/push.js'; import { registerFsRoutes } from './routes/fs.js'; @@ -35,6 +37,7 @@ export interface AppBundle { manager: PtyManager; discovery: DiscoveryService; worktrees: WorktreeManager; + groups: GroupManager; push: PushService; } @@ -50,6 +53,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund sessionsDir: config.claudeSessionsDir, }); const worktrees = new WorktreeManager(db, manager, discovery); + const groups = new GroupManager(db); void app.register(fastifyCookie); void app.register(fastifyWebsocket, { @@ -91,13 +95,14 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund registerAuthRoutes(app, auth, limiter, serverVersion); registerSessionRoutes(app, manager, discovery); registerRepoRoutes(app, worktrees); + registerGroupRoutes(app, groups); registerWorktreeRoutes(app, worktrees); registerPushRoutes(app, push); registerFsRoutes(app); // La route websocket doit être déclarée APRÈS le chargement du plugin (contexte // encapsulé) — sinon le handler reçoit la signature REST (request, reply). void app.register(async (scoped) => { - registerWsGateway(scoped, manager, discovery, worktrees, serverVersion); + registerWsGateway(scoped, manager, discovery, worktrees, groups, serverVersion); }); // SPA buildée embarquée dans le paquet npm (public/) — absente en dev (vite dev sert le front) @@ -112,5 +117,5 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund }); } - return { app, auth, manager, discovery, worktrees, push }; + return { app, auth, manager, discovery, worktrees, groups, push }; } diff --git a/packages/server/src/core/group-manager.ts b/packages/server/src/core/group-manager.ts new file mode 100644 index 0000000..b7d1f4b --- /dev/null +++ b/packages/server/src/core/group-manager.ts @@ -0,0 +1,201 @@ +// Gestion des groupes de travail (P5) : un groupe = collection nommée de repos (many-to-many). +// Membership légère et persistée ; les worktrees/sessions du groupe ne sont PAS stockés ici — +// ils restent servis par WorktreeManager/PtyManager et filtrés côté client par repoId. +// Tout est synchrone : aucune I/O git/fs, node:sqlite est synchrone. +import { EventEmitter } from 'node:events'; +import { randomUUID } from 'node:crypto'; +import type { GroupSummary } from '@arboretum/shared'; +import type { Db } from '../db/index.js'; + +const LABEL_MAX = 100; +const DESCRIPTION_MAX = 2000; +const COLOR_RE = /^#[0-9a-fA-F]{6}$/; + +interface GroupRow { + id: string; + label: string; + description: string | null; + color: string | null; + position: number; + created_at: string; + updated_at: string; +} + +export interface GroupManagerEvents { + group_update: [GroupSummary]; + group_removed: [string]; +} + +/** Erreur portant un statusCode + code pour mapping HTTP direct par les routes (cf. sendManagerError). */ +function httpError(statusCode: number, code: string, message: string): Error { + return Object.assign(new Error(message), { statusCode, code }); +} + +/** Valide un label : non vide après trim, borné. */ +function normLabel(label: unknown): string { + if (typeof label !== 'string') throw httpError(400, 'BAD_REQUEST', 'label is required'); + const v = label.trim(); + if (v === '') throw httpError(400, 'BAD_REQUEST', 'label must not be empty'); + if (v.length > LABEL_MAX) throw httpError(400, 'BAD_REQUEST', `label must be at most ${LABEL_MAX} characters`); + return v; +} + +/** Normalise une description : vide/absente → null, bornée sinon. */ +function normDescription(description: string | null | undefined): string | null { + if (description === null || description === undefined) return null; + const v = description.trim(); + if (v === '') return null; + if (v.length > DESCRIPTION_MAX) throw httpError(400, 'BAD_REQUEST', `description must be at most ${DESCRIPTION_MAX} characters`); + return v; +} + +/** Normalise une couleur : vide/absente → null, doit être un hex `#rrggbb` sinon. */ +function normColor(color: string | null | undefined): string | null { + if (color === null || color === undefined) return null; + const v = color.trim(); + if (v === '') return null; + if (!COLOR_RE.test(v)) throw httpError(400, 'BAD_REQUEST', 'color must be a hex string like #4f46e5'); + return v; +} + +export class GroupManager extends EventEmitter { + constructor(private readonly db: Db) { + super(); + } + + private getGroupRow(id: string): GroupRow | null { + return (this.db.prepare('SELECT * FROM groups WHERE id = ?').get(id) as unknown as GroupRow | undefined) ?? null; + } + + private repoIdsFor(groupId: string): string[] { + const rows = this.db + .prepare('SELECT repo_id FROM group_repos WHERE group_id = ? ORDER BY position ASC, created_at ASC') + .all(groupId) as Array<{ repo_id: string }>; + return rows.map((r) => r.repo_id); + } + + private rowToSummary(row: GroupRow): GroupSummary { + return { + id: row.id, + label: row.label, + description: row.description, + color: row.color, + repoIds: this.repoIdsFor(row.id), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + /** Garde-fou : le repo doit exister (meilleur message que de laisser la FK lever une contrainte opaque). */ + private assertRepoExists(repoId: string): void { + if (typeof repoId !== 'string' || repoId === '') throw httpError(400, 'BAD_REQUEST', 'repoId is required'); + const exists = this.db.prepare('SELECT 1 FROM repos WHERE id = ?').get(repoId); + if (!exists) throw httpError(404, 'REPO_NOT_FOUND', 'No repo with this id'); + } + + /** Position d'insertion suivante dans un groupe (append en queue). */ + private nextPosition(groupId: string): number { + const row = this.db.prepare('SELECT COALESCE(MAX(position), -1) + 1 AS pos FROM group_repos WHERE group_id = ?').get(groupId) as { + pos: number; + }; + return row.pos; + } + + listGroups(): GroupSummary[] { + const rows = this.db.prepare('SELECT * FROM groups ORDER BY position ASC, created_at ASC').all() as unknown as GroupRow[]; + return rows.map((r) => this.rowToSummary(r)); + } + + getGroup(id: string): GroupSummary { + const row = this.getGroupRow(id); + if (!row) throw httpError(404, 'NOT_FOUND', 'No group with this id'); + return this.rowToSummary(row); + } + + createGroup(opts: { label: string; description?: string; color?: string; repoIds?: string[] }): GroupSummary { + const label = normLabel(opts.label); + const description = normDescription(opts.description); + const color = normColor(opts.color); + const repoIds = opts.repoIds ?? []; + if (!Array.isArray(repoIds)) throw httpError(400, 'BAD_REQUEST', 'repoIds must be an array'); + for (const repoId of repoIds) this.assertRepoExists(repoId); + + const now = new Date().toISOString(); + const id = randomUUID(); + const position = this.db.prepare('SELECT COALESCE(MAX(position), -1) + 1 AS pos FROM groups').get() as { pos: number }; + + this.db.exec('BEGIN'); + try { + this.db + .prepare('INSERT INTO groups (id, label, description, color, position, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)') + .run(id, label, description, color, position.pos, now, now); + const insertRepo = this.db.prepare('INSERT OR IGNORE INTO group_repos (group_id, repo_id, position, created_at) VALUES (?, ?, ?, ?)'); + let pos = 0; + const seen = new Set(); + for (const repoId of repoIds) { + if (seen.has(repoId)) continue; + seen.add(repoId); + insertRepo.run(id, repoId, pos++, now); + } + this.db.exec('COMMIT'); + } catch (err) { + this.db.exec('ROLLBACK'); + throw err; + } + + const summary = this.getGroup(id); + this.emit('group_update', summary); + return summary; + } + + updateGroup(id: string, patch: { label?: string; description?: string | null; color?: string | null }): GroupSummary { + const row = this.getGroupRow(id); + if (!row) throw httpError(404, 'NOT_FOUND', 'No group with this id'); + if (patch.label !== undefined) row.label = normLabel(patch.label); + if (patch.description !== undefined) row.description = normDescription(patch.description); + if (patch.color !== undefined) row.color = normColor(patch.color); + const now = new Date().toISOString(); + this.db + .prepare('UPDATE groups SET label = ?, description = ?, color = ?, updated_at = ? WHERE id = ?') + .run(row.label, row.description, row.color, now, id); + const summary = this.getGroup(id); + this.emit('group_update', summary); + return summary; + } + + deleteGroup(id: string): boolean { + // CASCADE (PRAGMA foreign_keys = ON) purge group_repos. + const res = this.db.prepare('DELETE FROM groups WHERE id = ?').run(id); + if (res.changes === 0) return false; + this.emit('group_removed', id); + return true; + } + + addRepo(groupId: string, repoId: string): GroupSummary { + if (!this.getGroupRow(groupId)) throw httpError(404, 'NOT_FOUND', 'No group with this id'); + this.assertRepoExists(repoId); + const now = new Date().toISOString(); + // INSERT OR IGNORE → idempotent (PRIMARY KEY (group_id, repo_id)). + this.db + .prepare('INSERT OR IGNORE INTO group_repos (group_id, repo_id, position, created_at) VALUES (?, ?, ?, ?)') + .run(groupId, repoId, this.nextPosition(groupId), now); + this.touch(groupId, now); + const summary = this.getGroup(groupId); + this.emit('group_update', summary); + return summary; + } + + removeRepo(groupId: string, repoId: string): GroupSummary { + if (!this.getGroupRow(groupId)) throw httpError(404, 'NOT_FOUND', 'No group with this id'); + // DELETE no-op si absent → idempotent. + this.db.prepare('DELETE FROM group_repos WHERE group_id = ? AND repo_id = ?').run(groupId, repoId); + this.touch(groupId, new Date().toISOString()); + const summary = this.getGroup(groupId); + this.emit('group_update', summary); + return summary; + } + + private touch(groupId: string, now: string): void { + this.db.prepare('UPDATE groups SET updated_at = ? WHERE id = ?').run(now, groupId); + } +} diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index ab1489c..0c179c3 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -68,6 +68,31 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [ CREATE INDEX idx_push_subs_token ON push_subscriptions(token_id); `, }, + { + // P5 — groupes de travail. Un groupe = collection de repos (many-to-many). + // Worktrees/sessions NON persistés ici : servis par WorktreeManager/PtyManager + // et filtrés côté client par repoId. CASCADE s'appuie sur PRAGMA foreign_keys = ON (cf. openDb). + id: 5, + sql: ` + CREATE TABLE groups ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + description TEXT, + color TEXT, + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE group_repos ( + group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + PRIMARY KEY (group_id, repo_id) + ); + CREATE INDEX idx_group_repos_repo ON group_repos(repo_id); + `, + }, ]; export type Db = DatabaseSync; diff --git a/packages/server/src/routes/groups.ts b/packages/server/src/routes/groups.ts new file mode 100644 index 0000000..6446a66 --- /dev/null +++ b/packages/server/src/routes/groups.ts @@ -0,0 +1,86 @@ +import type { FastifyInstance } from 'fastify'; +import type { + AddRepoRequest, + CreateGroupRequest, + GroupResponse, + GroupsListResponse, + UpdateGroupRequest, +} from '@arboretum/shared'; +import type { GroupManager } from '../core/group-manager.js'; +import { sendManagerError } from './repos.js'; + +export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): void { + app.get('/api/v1/groups', async (): Promise => ({ groups: gm.listGroups() })); + + app.get('/api/v1/groups/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + try { + return reply.send({ group: gm.getGroup(id) } satisfies GroupResponse); + } catch (err) { + return sendManagerError(reply, err); + } + }); + + app.post('/api/v1/groups', async (req, reply) => { + const body = req.body as Partial | null; + if (!body || typeof body.label !== 'string') { + return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'label is required' } }); + } + try { + const group = gm.createGroup({ + label: body.label, + ...(body.description !== undefined ? { description: body.description } : {}), + ...(body.color !== undefined ? { color: body.color } : {}), + ...(Array.isArray(body.repoIds) ? { repoIds: body.repoIds } : {}), + }); + return reply.status(201).send({ group } satisfies GroupResponse); + } catch (err) { + return sendManagerError(reply, err); + } + }); + + app.patch('/api/v1/groups/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + const body = (req.body as Partial | null) ?? {}; + try { + const group = gm.updateGroup(id, { + ...(body.label !== undefined ? { label: body.label } : {}), + ...(body.description !== undefined ? { description: body.description } : {}), + ...(body.color !== undefined ? { color: body.color } : {}), + }); + return reply.send({ group } satisfies GroupResponse); + } catch (err) { + return sendManagerError(reply, err); + } + }); + + app.delete('/api/v1/groups/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + if (!gm.deleteGroup(id)) { + return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No group with this id' } }); + } + return reply.send({ ok: true }); + }); + + app.post('/api/v1/groups/:id/repos', async (req, reply) => { + const { id } = req.params as { id: string }; + const body = req.body as Partial | null; + if (!body || typeof body.repoId !== 'string') { + return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'repoId is required' } }); + } + try { + return reply.send({ group: gm.addRepo(id, body.repoId) } satisfies GroupResponse); + } catch (err) { + return sendManagerError(reply, err); + } + }); + + app.delete('/api/v1/groups/:id/repos/:repoId', async (req, reply) => { + const { id, repoId } = req.params as { id: string; repoId: string }; + try { + return reply.send({ group: gm.removeRepo(id, repoId) } satisfies GroupResponse); + } catch (err) { + return sendManagerError(reply, err); + } + }); +} diff --git a/packages/server/src/ws/gateway.ts b/packages/server/src/ws/gateway.ts index 3f8dc02..5f40534 100644 --- a/packages/server/src/ws/gateway.ts +++ b/packages/server/src/ws/gateway.ts @@ -5,6 +5,7 @@ import { PROTOCOL_VERSION, encodeBinaryFrame, parseClientMessage, + type GroupSummary, type RepoSummary, type ServerMessage, type SessionSummary, @@ -13,6 +14,7 @@ import { import type { ClientBinding, PtyManager } from '../core/pty-manager.js'; import type { DiscoveryService } from '../core/discovery-service.js'; import type { WorktreeManager } from '../core/worktree-manager.js'; +import type { GroupManager } from '../core/group-manager.js'; const HEARTBEAT_MS = 30_000; @@ -26,6 +28,7 @@ export function registerWsGateway( manager: PtyManager, discovery: DiscoveryService, worktrees: WorktreeManager, + groups: GroupManager, serverVersion: string, ): void { app.get('/ws', { websocket: true }, (socket: WebSocket, req) => { @@ -35,6 +38,7 @@ export function registerWsGateway( let helloDone = false; let subscribedSessions = false; let subscribedWorktrees = false; + let subscribedGroups = false; let alive = true; const send = (msg: ServerMessage): void => { @@ -66,6 +70,12 @@ export function registerWsGateway( const onWorktreeRemoved = (e: { repoId: string; path: string }): void => { if (subscribedWorktrees) send({ type: 'worktree_removed', ...e }); }; + const onGroupUpdate = (group: GroupSummary): void => { + if (subscribedGroups) send({ type: 'group_update', group }); + }; + const onGroupRemoved = (groupId: string): void => { + if (subscribedGroups) send({ type: 'group_removed', groupId }); + }; manager.on('session_update', onSessionUpdate); manager.on('session_exit', onSessionExit); discovery.on('discovery_update', onDiscoveryUpdate); @@ -73,6 +83,8 @@ export function registerWsGateway( worktrees.on('repo_removed', onRepoRemoved); worktrees.on('worktree_update', onWorktreeUpdate); worktrees.on('worktree_removed', onWorktreeRemoved); + groups.on('group_update', onGroupUpdate); + groups.on('group_removed', onGroupRemoved); const heartbeat = setInterval(() => { if (!alive) { @@ -111,6 +123,7 @@ export function registerWsGateway( case 'sub': { subscribedSessions = msg.topics.includes('sessions'); subscribedWorktrees = msg.topics.includes('worktrees'); + subscribedGroups = msg.topics.includes('groups'); return; } case 'attach': { @@ -202,6 +215,8 @@ export function registerWsGateway( worktrees.off('repo_removed', onRepoRemoved); worktrees.off('worktree_update', onWorktreeUpdate); worktrees.off('worktree_removed', onWorktreeRemoved); + groups.off('group_update', onGroupUpdate); + groups.off('group_removed', onGroupRemoved); for (const [, st] of channels) manager.detach(st.sessionId, st.binding); channels.clear(); }); diff --git a/packages/server/test/group-manager.test.ts b/packages/server/test/group-manager.test.ts new file mode 100644 index 0000000..66ed433 --- /dev/null +++ b/packages/server/test/group-manager.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { GroupManager } from '../src/core/group-manager.js'; +import { openDb, type Db } from '../src/db/index.js'; + +/** Insère un repo minimal directement (le GroupManager n'a besoin que de repos.id). */ +function insertRepo(db: Db, id: string, path: string): void { + db.prepare( + "INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at) VALUES (?, ?, ?, NULL, '[]', 0, ?)", + ).run(id, path, id, new Date().toISOString()); +} + +describe('GroupManager', () => { + let db: Db; + let gm: GroupManager; + + beforeEach(() => { + db = openDb(':memory:'); + insertRepo(db, 'repo-a', '/tmp/a'); + insertRepo(db, 'repo-b', '/tmp/b'); + gm = new GroupManager(db); + }); + + it('createGroup : groupe vide, persisté, événement group_update émis', () => { + const spy = vi.fn(); + gm.on('group_update', spy); + const g = gm.createGroup({ label: 'Sprint 42' }); + expect(g).toMatchObject({ label: 'Sprint 42', description: null, color: null, repoIds: [] }); + expect(gm.listGroups()).toHaveLength(1); + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ id: g.id })); + }); + + it('createGroup : repoIds initiaux ordonnés et dédoublonnés', () => { + const g = gm.createGroup({ label: 'Eco', repoIds: ['repo-b', 'repo-a', 'repo-b'] }); + expect(g.repoIds).toEqual(['repo-b', 'repo-a']); + }); + + it('createGroup : repoId inexistant → 404', () => { + expect(() => gm.createGroup({ label: 'X', repoIds: ['nope'] })).toThrow( + expect.objectContaining({ statusCode: 404, code: 'REPO_NOT_FOUND' }), + ); + expect(gm.listGroups()).toHaveLength(0); // rollback de la transaction + }); + + it('createGroup : validations (label vide / trop long, couleur invalide)', () => { + expect(() => gm.createGroup({ label: ' ' })).toThrow(expect.objectContaining({ statusCode: 400 })); + expect(() => gm.createGroup({ label: 'x'.repeat(101) })).toThrow(expect.objectContaining({ statusCode: 400 })); + expect(() => gm.createGroup({ label: 'X', color: 'red' })).toThrow(expect.objectContaining({ statusCode: 400 })); + expect(gm.createGroup({ label: 'X', color: '#4f46e5' }).color).toBe('#4f46e5'); + }); + + it('getGroup : id inconnu → 404', () => { + expect(() => gm.getGroup('nope')).toThrow(expect.objectContaining({ statusCode: 404, code: 'NOT_FOUND' })); + }); + + it('updateGroup : modifie label/description/color et bump updatedAt', () => { + const g = gm.createGroup({ label: 'A' }); + const updated = gm.updateGroup(g.id, { label: 'B', description: 'hello', color: '#000000' }); + expect(updated).toMatchObject({ label: 'B', description: 'hello', color: '#000000' }); + expect(gm.updateGroup(g.id, { description: '' }).description).toBeNull(); // '' → null + }); + + it('addRepo : idempotent (PRIMARY KEY), émet group_update', () => { + const g = gm.createGroup({ label: 'A' }); + const spy = vi.fn(); + gm.on('group_update', spy); + expect(gm.addRepo(g.id, 'repo-a').repoIds).toEqual(['repo-a']); + expect(gm.addRepo(g.id, 'repo-a').repoIds).toEqual(['repo-a']); // pas de doublon + expect(gm.addRepo(g.id, 'repo-b').repoIds).toEqual(['repo-a', 'repo-b']); + expect(spy).toHaveBeenCalledTimes(3); + }); + + it('addRepo : groupe ou repo inexistant → 404', () => { + const g = gm.createGroup({ label: 'A' }); + expect(() => gm.addRepo('nope', 'repo-a')).toThrow(expect.objectContaining({ statusCode: 404, code: 'NOT_FOUND' })); + expect(() => gm.addRepo(g.id, 'nope')).toThrow(expect.objectContaining({ statusCode: 404, code: 'REPO_NOT_FOUND' })); + }); + + it('removeRepo : idempotent (retrait d’un repo absent = no-op)', () => { + const g = gm.createGroup({ label: 'A', repoIds: ['repo-a'] }); + expect(gm.removeRepo(g.id, 'repo-a').repoIds).toEqual([]); + expect(gm.removeRepo(g.id, 'repo-a').repoIds).toEqual([]); // déjà absent → succès + }); + + it('deleteGroup : true + group_removed ; id inconnu → false', () => { + const g = gm.createGroup({ label: 'A' }); + const spy = vi.fn(); + gm.on('group_removed', spy); + expect(gm.deleteGroup(g.id)).toBe(true); + expect(spy).toHaveBeenCalledWith(g.id); + expect(gm.deleteGroup(g.id)).toBe(false); + expect(gm.listGroups()).toHaveLength(0); + }); + + it('CASCADE : supprimer un repo purge la membership (PRAGMA foreign_keys = ON)', () => { + const g = gm.createGroup({ label: 'A', repoIds: ['repo-a', 'repo-b'] }); + expect(gm.getGroup(g.id).repoIds).toEqual(['repo-a', 'repo-b']); + db.prepare('DELETE FROM repos WHERE id = ?').run('repo-a'); + expect(gm.getGroup(g.id).repoIds).toEqual(['repo-b']); + }); +}); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index da82040..2f39dea 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -1,5 +1,5 @@ // Types REST partagés (préfixe /api/v1). -import type { PostCreateHook, RepoSummary, SessionSummary, WorktreeSummary } from './protocol.js'; +import type { GroupSummary, PostCreateHook, RepoSummary, SessionSummary, WorktreeSummary } from './protocol.js'; export interface ApiError { error: { code: string; message: string; details?: unknown }; @@ -91,6 +91,30 @@ export interface AdoptWorktreeRequest { preTrust?: boolean; } +// ---- Groupes de travail (P5) ---- +export interface GroupsListResponse { + groups: GroupSummary[]; +} +export interface GroupResponse { + group: GroupSummary; +} +export interface CreateGroupRequest { + label: string; + description?: string; + color?: string; + /** ids de repos initiaux (défaut : []). */ + repoIds?: string[]; +} +export interface UpdateGroupRequest { + label?: string; + /** null pour effacer. */ + description?: string | null; + color?: string | null; +} +export interface AddRepoRequest { + repoId: string; +} + // ---- Navigateur de répertoires (sélecteur de dossier côté web) ---- export interface FsEntry { name: string; diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index e4d8959..2806aa4 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -160,6 +160,22 @@ export interface WorktreeSummary { sessions: SessionSummary[]; } +// ---- Groupes de travail (P5) ---- +// Un groupe regroupe plusieurs repos pour piloter des sessions Claude en simultané sur +// plusieurs worktrees. Membership légère : seule la liste d'ids de repos est persistée ; +// les repos/worktrees/sessions du groupe sont dérivés par filtrage sur `repoId` côté client. +export interface GroupSummary { + id: string; + label: string; + description: string | null; + /** couleur d'accent UI (hex `#rrggbb`) ou null. */ + color: string | null; + /** ids de repos membres, ordonnés par leur position dans le groupe. */ + repoIds: string[]; + createdAt: string; + updatedAt: string; +} + // ---- Messages client → serveur ---- export type ClientMessage = | { type: 'hello'; protocol: number } @@ -172,7 +188,7 @@ export type ClientMessage = | { type: 'answer'; channel: number; action: 'select' | 'confirm' | 'deny'; optionN?: number } | { type: 'resize'; channel: number; cols: number; rows: number } | { type: 'ack'; channel: number; bytes: number } - | { type: 'sub'; topics: Array<'sessions' | 'worktrees'> } + | { type: 'sub'; topics: Array<'sessions' | 'worktrees' | 'groups'> } | { type: 'ping' }; // ---- Messages serveur → client ---- @@ -187,6 +203,8 @@ export type ServerMessage = | { type: 'repo_removed'; repoId: string } | { type: 'worktree_update'; repoId: string; worktree: WorktreeSummary } | { type: 'worktree_removed'; repoId: string; path: string } + | { type: 'group_update'; group: GroupSummary } + | { type: 'group_removed'; groupId: string } | { type: 'error'; code: ErrorCode; message: string; channel?: number } | { type: 'pong' }; @@ -244,8 +262,8 @@ export function parseClientMessage(raw: string): ClientMessage | null { ? { type: 'ack', channel: m.channel, bytes: m.bytes } : null; case 'sub': - return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions' || t === 'worktrees') - ? { type: 'sub', topics: m.topics as Array<'sessions' | 'worktrees'> } + return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions' || t === 'worktrees' || t === 'groups') + ? { type: 'sub', topics: m.topics as Array<'sessions' | 'worktrees' | 'groups'> } : null; case 'ping': return { type: 'ping' }; diff --git a/packages/shared/test/protocol.test.ts b/packages/shared/test/protocol.test.ts index e3f843e..f81a3df 100644 --- a/packages/shared/test/protocol.test.ts +++ b/packages/shared/test/protocol.test.ts @@ -62,7 +62,7 @@ function assertInvariants(msg: ClientMessage): void { expect(msg.bytes).toBeGreaterThanOrEqual(0); break; case 'sub': - expect(msg.topics.every((t) => t === 'sessions')).toBe(true); + expect(msg.topics.every((t) => t === 'sessions' || t === 'worktrees' || t === 'groups')).toBe(true); break; case 'ping': break; @@ -117,9 +117,14 @@ describe('parseClientMessage — cas valides', () => { expect(parseClientMessage('{"type":"ack","channel":1,"bytes":0}')).toEqual({ type: 'ack', channel: 1, bytes: 0 }); }); - it('sub avec topics sessions/worktrees (et tableau vide accepté)', () => { + it('sub avec topics sessions/worktrees/groups (et tableau vide accepté)', () => { expect(parseClientMessage('{"type":"sub","topics":["sessions"]}')).toEqual({ type: 'sub', topics: ['sessions'] }); expect(parseClientMessage('{"type":"sub","topics":["sessions","worktrees"]}')).toEqual({ type: 'sub', topics: ['sessions', 'worktrees'] }); + expect(parseClientMessage('{"type":"sub","topics":["groups"]}')).toEqual({ type: 'sub', topics: ['groups'] }); + expect(parseClientMessage('{"type":"sub","topics":["sessions","worktrees","groups"]}')).toEqual({ + type: 'sub', + topics: ['sessions', 'worktrees', 'groups'], + }); expect(parseClientMessage('{"type":"sub","topics":[]}')).toEqual({ type: 'sub', topics: [] }); }); diff --git a/packages/web/src/components/CrossRepoFeatureModal.vue b/packages/web/src/components/CrossRepoFeatureModal.vue new file mode 100644 index 0000000..618e392 --- /dev/null +++ b/packages/web/src/components/CrossRepoFeatureModal.vue @@ -0,0 +1,122 @@ + + + diff --git a/packages/web/src/components/TerminalCell.vue b/packages/web/src/components/TerminalCell.vue new file mode 100644 index 0000000..f4654e5 --- /dev/null +++ b/packages/web/src/components/TerminalCell.vue @@ -0,0 +1,39 @@ + + + diff --git a/packages/web/src/components/TerminalGrid.vue b/packages/web/src/components/TerminalGrid.vue new file mode 100644 index 0000000..562573b --- /dev/null +++ b/packages/web/src/components/TerminalGrid.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/web/src/i18n/en.ts b/packages/web/src/i18n/en.ts index 903247f..299b2bb 100644 --- a/packages/web/src/i18n/en.ts +++ b/packages/web/src/i18n/en.ts @@ -61,6 +61,54 @@ export default { dashboard: { title: 'Worktrees', allSessions: 'All sessions', + groups: 'Groups', + }, + groups: { + title: 'Groups', + new: 'New group', + create: 'Create', + creating: 'Creating…', + nameLabel: 'Group name', + namePlaceholder: 'e.g. Payments stack', + colorLabel: 'Color', + reposLabel: 'Repositories', + empty: 'No group yet — create one above.', + noRepos: 'No repository selected.', + noReposInGroup: 'No repository in this group yet — add some below.', + noReposRegistered: 'No repository registered yet — add some from the dashboard first.', + open: 'Open', + edit: 'Edit repositories', + editDone: 'Done', + remove: 'Delete', + confirmDelete: 'Confirm delete', + repoCount: 'no repo | 1 repo | {n} repos', + dashboard: 'Dashboard', + missingRepo: 'Repository unavailable', + removeFromGroup: 'Remove from group', + sessionsActive: 'no active session | 1 active session | {n} active sessions', + viewList: 'Repos', + viewGrid: 'Terminals', + gridEmpty: 'No active session in this group.', + gridCapped: 'Showing {shown} of {total} sessions — close some terminals to see the rest.', + newFeature: 'New cross-repo feature', + }, + crossRepo: { + title: 'New cross-repo feature', + intro: 'Create the same worktree across the selected repos, in one action.', + branchLabel: 'Branch name', + branchPlaceholder: 'feature/…', + newBranch: 'create branch', + baseRefLabel: 'Base ref (optional)', + startLabel: 'Start session', + startNone: 'no session', + reposLabel: 'Apply to', + create: 'Create across {n} repos', + creating: 'Creating…', + retryFailed: 'Retry failed', + close: 'Close', + pending: 'pending…', + ok: 'created', + error: 'failed', }, repos: { add: 'Add repo', diff --git a/packages/web/src/i18n/fr.ts b/packages/web/src/i18n/fr.ts index bfe2bb2..df8e687 100644 --- a/packages/web/src/i18n/fr.ts +++ b/packages/web/src/i18n/fr.ts @@ -63,6 +63,54 @@ const fr: typeof en = { dashboard: { title: 'Worktrees', allSessions: 'Toutes les sessions', + groups: 'Groupes', + }, + groups: { + title: 'Groupes', + new: 'Nouveau groupe', + create: 'Créer', + creating: 'Création…', + nameLabel: 'Nom du groupe', + namePlaceholder: 'ex. Stack paiements', + colorLabel: 'Couleur', + reposLabel: 'Dépôts', + empty: 'Aucun groupe — créez-en un ci-dessus.', + noRepos: 'Aucun dépôt sélectionné.', + noReposInGroup: 'Aucun dépôt dans ce groupe — ajoutez-en ci-dessous.', + noReposRegistered: 'Aucun dépôt enregistré — ajoutez-en d’abord depuis le tableau de bord.', + open: 'Ouvrir', + edit: 'Modifier les dépôts', + editDone: 'Terminé', + remove: 'Supprimer', + confirmDelete: 'Confirmer', + repoCount: 'aucun dépôt | 1 dépôt | {n} dépôts', + dashboard: 'Tableau de bord', + missingRepo: 'Dépôt indisponible', + removeFromGroup: 'Retirer du groupe', + sessionsActive: 'aucune session active | 1 session active | {n} sessions actives', + viewList: 'Dépôts', + viewGrid: 'Terminaux', + gridEmpty: 'Aucune session active dans ce groupe.', + gridCapped: '{shown} sessions affichées sur {total} — fermez des terminaux pour voir le reste.', + newFeature: 'Nouvelle feature cross-repo', + }, + crossRepo: { + title: 'Nouvelle feature cross-repo', + intro: 'Crée le même worktree dans les dépôts sélectionnés, en une seule action.', + branchLabel: 'Nom de branche', + branchPlaceholder: 'feature/…', + newBranch: 'créer la branche', + baseRefLabel: 'Réf. de base (optionnel)', + startLabel: 'Démarrer une session', + startNone: 'aucune session', + reposLabel: 'Appliquer à', + create: 'Créer dans {n} dépôts', + creating: 'Création…', + retryFailed: 'Réessayer les échecs', + close: 'Fermer', + pending: 'en cours…', + ok: 'créé', + error: 'échec', }, repos: { add: 'Ajouter un repo', diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index c8efc13..9269d04 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -34,5 +34,6 @@ async function request(path: string, method: string, body?: unknown): Promise export const api = { get: (path: string): Promise => request(path, 'GET'), post: (path: string, body?: unknown): Promise => request(path, 'POST', body), + patch: (path: string, body?: unknown): Promise => request(path, 'PATCH', body), delete: (path: string): Promise => request(path, 'DELETE'), }; diff --git a/packages/web/src/lib/ws-client.ts b/packages/web/src/lib/ws-client.ts index 4dd38d0..bf899ef 100644 --- a/packages/web/src/lib/ws-client.ts +++ b/packages/web/src/lib/ws-client.ts @@ -28,6 +28,7 @@ export interface TerminalSink { export type SessionEvent = Extract; export type WorktreeEvent = Extract; +export type GroupEvent = Extract; export interface AttachOptions { sessionId: string; @@ -116,6 +117,7 @@ export class WsClient { private awaitingAttached: Attachment[] = []; private readonly sessionListeners = new Set<(e: SessionEvent) => void>(); private readonly worktreeListeners = new Set<(e: WorktreeEvent) => void>(); + private readonly groupListeners = new Set<(e: GroupEvent) => void>(); connect(): void { this.stopped = false; @@ -156,10 +158,11 @@ export class WsClient { } /** topics actifs = union des abonnements courants (une seule connexion partagée). */ - private activeTopics(): Array<'sessions' | 'worktrees'> { - const t: Array<'sessions' | 'worktrees'> = []; + private activeTopics(): Array<'sessions' | 'worktrees' | 'groups'> { + const t: Array<'sessions' | 'worktrees' | 'groups'> = []; if (this.sessionListeners.size > 0) t.push('sessions'); if (this.worktreeListeners.size > 0) t.push('worktrees'); + if (this.groupListeners.size > 0) t.push('groups'); return t; } @@ -187,6 +190,16 @@ export class WsClient { }; } + subscribeGroups(listener: (e: GroupEvent) => void): () => void { + this.groupListeners.add(listener); + this.connect(); + this.sendSub(); + return () => { + this.groupListeners.delete(listener); + this.sendSub(); + }; + } + sendControl(msg: ClientMessage): void { if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) return; this.socket.send(JSON.stringify(msg)); @@ -380,6 +393,11 @@ export class WsClient { for (const cb of this.worktreeListeners) cb(msg); return; } + case 'group_update': + case 'group_removed': { + for (const cb of this.groupListeners) cb(msg); + return; + } case 'error': { if (msg.channel !== undefined) { console.warn(`[ws] channel ${msg.channel}: ${msg.code} — ${msg.message}`); diff --git a/packages/web/src/router/index.ts b/packages/web/src/router/index.ts index 5258407..82f85d3 100644 --- a/packages/web/src/router/index.ts +++ b/packages/web/src/router/index.ts @@ -8,6 +8,8 @@ export const router = createRouter({ { path: '/', name: 'dashboard', component: () => import('../views/DashboardView.vue') }, { path: '/sessions', name: 'sessions', component: () => import('../views/SessionsListView.vue') }, { path: '/sessions/:id', name: 'session', component: () => import('../views/SessionView.vue') }, + { path: '/groups', name: 'groups', component: () => import('../views/GroupsListView.vue') }, + { path: '/groups/:id', name: 'group', component: () => import('../views/GroupView.vue') }, { path: '/:pathMatch(.*)*', redirect: '/' }, ], }); diff --git a/packages/web/src/stores/groups.ts b/packages/web/src/stores/groups.ts new file mode 100644 index 0000000..1dff9a3 --- /dev/null +++ b/packages/web/src/stores/groups.ts @@ -0,0 +1,175 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import type { + CreateGroupRequest, + CreateWorktreeRequest, + GroupResponse, + GroupsListResponse, + GroupSummary, + RepoSummary, + SessionSummary, + UpdateGroupRequest, + WorktreeSummary, +} from '@arboretum/shared'; +import { api, ApiError } from '../lib/api'; +import { wsClient, type GroupEvent } from '../lib/ws-client'; +import { useWorktreesStore } from './worktrees'; + +/** Requête « feature cross-repo » : un worktree (et éventuellement une session) par repo. */ +export type CrossRepoRequest = Omit; + +/** Résultat par repo d'une action cross-repo (tolérance aux échecs partiels). */ +export interface CrossRepoResult { + repoId: string; + status: 'ok' | 'error'; + message?: string; + worktreePath?: string; + sessionId?: string; +} + +export const useGroupsStore = defineStore('groups', () => { + const groups = ref([]); + const selectedGroupId = ref(null); + const loading = ref(false); + const loadError = ref(null); + let unsubscribe: (() => void) | null = null; + + function upsert(group: GroupSummary): void { + const idx = groups.value.findIndex((g) => g.id === group.id); + if (idx >= 0) groups.value.splice(idx, 1, group); + else groups.value.push(group); + } + function removeLocal(id: string): void { + groups.value = groups.value.filter((g) => g.id !== id); + if (selectedGroupId.value === id) selectedGroupId.value = null; + } + function onEvent(e: GroupEvent): void { + if (e.type === 'group_update') upsert(e.group); + else removeLocal(e.groupId); + } + + function byId(id: string): GroupSummary | undefined { + return groups.value.find((g) => g.id === id); + } + + // ---- getters dérivés : réutilisent le store worktrees par filtrage repoId (zéro refetch) ---- + function reposInGroup(groupId: string): RepoSummary[] { + const g = byId(groupId); + if (!g) return []; + const wt = useWorktreesStore(); + return g.repoIds + .map((id) => wt.repos.find((r) => r.id === id)) + .filter((r): r is RepoSummary => r !== undefined); + } + function worktreesInGroup(groupId: string): WorktreeSummary[] { + const g = byId(groupId); + if (!g) return []; + const ids = new Set(g.repoIds); + return useWorktreesStore().worktrees.filter((w) => ids.has(w.repoId)); + } + function sessionsInGroup(groupId: string): SessionSummary[] { + return worktreesInGroup(groupId).flatMap((w) => w.sessions); + } + + async function fetchGroups(): Promise { + loading.value = true; + loadError.value = null; + try { + const res = await api.get('/api/v1/groups'); + groups.value = res.groups; + } catch (err) { + loadError.value = err instanceof Error ? err.message : String(err); + } finally { + loading.value = false; + } + } + + async function createGroup(req: CreateGroupRequest): Promise { + const res = await api.post('/api/v1/groups', req); + upsert(res.group); + return res.group; + } + async function updateGroup(id: string, patch: UpdateGroupRequest): Promise { + const res = await api.patch(`/api/v1/groups/${id}`, patch); + upsert(res.group); + return res.group; + } + async function deleteGroup(id: string): Promise { + await api.delete<{ ok: true }>(`/api/v1/groups/${id}`); + removeLocal(id); + } + async function addRepoToGroup(groupId: string, repoId: string): Promise { + const res = await api.post(`/api/v1/groups/${groupId}/repos`, { repoId }); + upsert(res.group); + return res.group; + } + async function removeRepoFromGroup(groupId: string, repoId: string): Promise { + const res = await api.delete(`/api/v1/groups/${groupId}/repos/${repoId}`); + upsert(res.group); + return res.group; + } + + /** + * Crée le même worktree (et éventuellement une session) dans chaque repo, en une action. + * Orchestration côté client : les worktrees sont indépendants entre repos, donc on tolère + * les échecs partiels (un repo en échec n'empêche pas les autres). Feedback par repo. + */ + async function createCrossRepoFeature( + repoIds: string[], + req: CrossRepoRequest, + onProgress?: (result: CrossRepoResult) => void, + ): Promise { + const wt = useWorktreesStore(); + return Promise.all( + repoIds.map(async (repoId): Promise => { + try { + const res = await wt.createWorktree(repoId, req); + const result: CrossRepoResult = { + repoId, + status: 'ok', + worktreePath: res.worktree.path, + ...(res.session ? { sessionId: res.session.id } : {}), + }; + onProgress?.(result); + return result; + } catch (err) { + const result: CrossRepoResult = { + repoId, + status: 'error', + message: err instanceof ApiError ? err.message : err instanceof Error ? err.message : String(err), + }; + onProgress?.(result); + return result; + } + }), + ); + } + + function startRealtime(): void { + unsubscribe ??= wsClient.subscribeGroups(onEvent); + } + function stopRealtime(): void { + unsubscribe?.(); + unsubscribe = null; + } + + return { + groups, + selectedGroupId, + loading, + loadError, + byId, + reposInGroup, + worktreesInGroup, + sessionsInGroup, + fetchGroups, + createGroup, + updateGroup, + deleteGroup, + addRepoToGroup, + removeRepoFromGroup, + createCrossRepoFeature, + startRealtime, + stopRealtime, + }; +}); diff --git a/packages/web/src/views/DashboardView.vue b/packages/web/src/views/DashboardView.vue index d19dbc8..c1b6083 100644 --- a/packages/web/src/views/DashboardView.vue +++ b/packages/web/src/views/DashboardView.vue @@ -5,6 +5,7 @@

{{ t('dashboard.title') }}

v{{ auth.serverVersion }}
+ {{ t('dashboard.groups') }} {{ t('dashboard.allSessions') }} + +
+ + +
+ + +
+ + +

{{ t('common.loading') }}

+ + + + + + + + + diff --git a/packages/web/src/views/GroupsListView.vue b/packages/web/src/views/GroupsListView.vue new file mode 100644 index 0000000..a9051a3 --- /dev/null +++ b/packages/web/src/views/GroupsListView.vue @@ -0,0 +1,157 @@ + + +