feat: groupes de travail (P5)
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.
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
<a href="README.md">English</a> · <strong>Français</strong>
|
<a href="README.md">English</a> · <strong>Français</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**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.
|
- **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.
|
- **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.
|
- **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-p2.mjs # découverte & reprise de sessions
|
||||||
node packages/server/scripts/acceptance-p3.mjs # worktrees & corrélation 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-p4.mjs # Web Push + commande WS `answer`
|
||||||
|
node packages/server/scripts/acceptance-p5.mjs # groupes de travail : CRUD + broadcast WS + CASCADE
|
||||||
```
|
```
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<strong>English</strong> · <a href="README.fr.md">Français</a>
|
<strong>English</strong> · <a href="README.fr.md">Français</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**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.
|
- **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.
|
- **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.
|
- **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-p2.mjs # session discovery & resume
|
||||||
node packages/server/scripts/acceptance-p3.mjs # worktrees & session correlation
|
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-p4.mjs # Web Push + WS `answer` command
|
||||||
|
node packages/server/scripts/acceptance-p5.mjs # work groups: CRUD + WS broadcast + CASCADE
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
146
packages/server/scripts/acceptance-p5.mjs
Normal file
146
packages/server/scripts/acceptance-p5.mjs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -11,10 +11,12 @@ import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.
|
|||||||
import { PtyManager } from './core/pty-manager.js';
|
import { PtyManager } from './core/pty-manager.js';
|
||||||
import { DiscoveryService } from './core/discovery-service.js';
|
import { DiscoveryService } from './core/discovery-service.js';
|
||||||
import { WorktreeManager } from './core/worktree-manager.js';
|
import { WorktreeManager } from './core/worktree-manager.js';
|
||||||
|
import { GroupManager } from './core/group-manager.js';
|
||||||
import { PushService } from './core/push-service.js';
|
import { PushService } from './core/push-service.js';
|
||||||
import { registerAuthRoutes } from './routes/auth.js';
|
import { registerAuthRoutes } from './routes/auth.js';
|
||||||
import { registerSessionRoutes } from './routes/sessions.js';
|
import { registerSessionRoutes } from './routes/sessions.js';
|
||||||
import { registerRepoRoutes } from './routes/repos.js';
|
import { registerRepoRoutes } from './routes/repos.js';
|
||||||
|
import { registerGroupRoutes } from './routes/groups.js';
|
||||||
import { registerWorktreeRoutes } from './routes/worktrees.js';
|
import { registerWorktreeRoutes } from './routes/worktrees.js';
|
||||||
import { registerPushRoutes } from './routes/push.js';
|
import { registerPushRoutes } from './routes/push.js';
|
||||||
import { registerFsRoutes } from './routes/fs.js';
|
import { registerFsRoutes } from './routes/fs.js';
|
||||||
@@ -35,6 +37,7 @@ export interface AppBundle {
|
|||||||
manager: PtyManager;
|
manager: PtyManager;
|
||||||
discovery: DiscoveryService;
|
discovery: DiscoveryService;
|
||||||
worktrees: WorktreeManager;
|
worktrees: WorktreeManager;
|
||||||
|
groups: GroupManager;
|
||||||
push: PushService;
|
push: PushService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +53,7 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
sessionsDir: config.claudeSessionsDir,
|
sessionsDir: config.claudeSessionsDir,
|
||||||
});
|
});
|
||||||
const worktrees = new WorktreeManager(db, manager, discovery);
|
const worktrees = new WorktreeManager(db, manager, discovery);
|
||||||
|
const groups = new GroupManager(db);
|
||||||
|
|
||||||
void app.register(fastifyCookie);
|
void app.register(fastifyCookie);
|
||||||
void app.register(fastifyWebsocket, {
|
void app.register(fastifyWebsocket, {
|
||||||
@@ -91,13 +95,14 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
|||||||
registerAuthRoutes(app, auth, limiter, serverVersion);
|
registerAuthRoutes(app, auth, limiter, serverVersion);
|
||||||
registerSessionRoutes(app, manager, discovery);
|
registerSessionRoutes(app, manager, discovery);
|
||||||
registerRepoRoutes(app, worktrees);
|
registerRepoRoutes(app, worktrees);
|
||||||
|
registerGroupRoutes(app, groups);
|
||||||
registerWorktreeRoutes(app, worktrees);
|
registerWorktreeRoutes(app, worktrees);
|
||||||
registerPushRoutes(app, push);
|
registerPushRoutes(app, push);
|
||||||
registerFsRoutes(app);
|
registerFsRoutes(app);
|
||||||
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
|
// 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).
|
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
||||||
void app.register(async (scoped) => {
|
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)
|
// 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 };
|
||||||
}
|
}
|
||||||
|
|||||||
201
packages/server/src/core/group-manager.ts
Normal file
201
packages/server/src/core/group-manager.ts
Normal file
@@ -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<GroupManagerEvents> {
|
||||||
|
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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,31 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
|||||||
CREATE INDEX idx_push_subs_token ON push_subscriptions(token_id);
|
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;
|
export type Db = DatabaseSync;
|
||||||
|
|||||||
86
packages/server/src/routes/groups.ts
Normal file
86
packages/server/src/routes/groups.ts
Normal file
@@ -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<GroupsListResponse> => ({ 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<CreateGroupRequest> | 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<UpdateGroupRequest> | 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<AddRepoRequest> | 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
encodeBinaryFrame,
|
encodeBinaryFrame,
|
||||||
parseClientMessage,
|
parseClientMessage,
|
||||||
|
type GroupSummary,
|
||||||
type RepoSummary,
|
type RepoSummary,
|
||||||
type ServerMessage,
|
type ServerMessage,
|
||||||
type SessionSummary,
|
type SessionSummary,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
import type { ClientBinding, PtyManager } from '../core/pty-manager.js';
|
import type { ClientBinding, PtyManager } from '../core/pty-manager.js';
|
||||||
import type { DiscoveryService } from '../core/discovery-service.js';
|
import type { DiscoveryService } from '../core/discovery-service.js';
|
||||||
import type { WorktreeManager } from '../core/worktree-manager.js';
|
import type { WorktreeManager } from '../core/worktree-manager.js';
|
||||||
|
import type { GroupManager } from '../core/group-manager.js';
|
||||||
|
|
||||||
const HEARTBEAT_MS = 30_000;
|
const HEARTBEAT_MS = 30_000;
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ export function registerWsGateway(
|
|||||||
manager: PtyManager,
|
manager: PtyManager,
|
||||||
discovery: DiscoveryService,
|
discovery: DiscoveryService,
|
||||||
worktrees: WorktreeManager,
|
worktrees: WorktreeManager,
|
||||||
|
groups: GroupManager,
|
||||||
serverVersion: string,
|
serverVersion: string,
|
||||||
): void {
|
): void {
|
||||||
app.get('/ws', { websocket: true }, (socket: WebSocket, req) => {
|
app.get('/ws', { websocket: true }, (socket: WebSocket, req) => {
|
||||||
@@ -35,6 +38,7 @@ export function registerWsGateway(
|
|||||||
let helloDone = false;
|
let helloDone = false;
|
||||||
let subscribedSessions = false;
|
let subscribedSessions = false;
|
||||||
let subscribedWorktrees = false;
|
let subscribedWorktrees = false;
|
||||||
|
let subscribedGroups = false;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
|
|
||||||
const send = (msg: ServerMessage): void => {
|
const send = (msg: ServerMessage): void => {
|
||||||
@@ -66,6 +70,12 @@ export function registerWsGateway(
|
|||||||
const onWorktreeRemoved = (e: { repoId: string; path: string }): void => {
|
const onWorktreeRemoved = (e: { repoId: string; path: string }): void => {
|
||||||
if (subscribedWorktrees) send({ type: 'worktree_removed', ...e });
|
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_update', onSessionUpdate);
|
||||||
manager.on('session_exit', onSessionExit);
|
manager.on('session_exit', onSessionExit);
|
||||||
discovery.on('discovery_update', onDiscoveryUpdate);
|
discovery.on('discovery_update', onDiscoveryUpdate);
|
||||||
@@ -73,6 +83,8 @@ export function registerWsGateway(
|
|||||||
worktrees.on('repo_removed', onRepoRemoved);
|
worktrees.on('repo_removed', onRepoRemoved);
|
||||||
worktrees.on('worktree_update', onWorktreeUpdate);
|
worktrees.on('worktree_update', onWorktreeUpdate);
|
||||||
worktrees.on('worktree_removed', onWorktreeRemoved);
|
worktrees.on('worktree_removed', onWorktreeRemoved);
|
||||||
|
groups.on('group_update', onGroupUpdate);
|
||||||
|
groups.on('group_removed', onGroupRemoved);
|
||||||
|
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
if (!alive) {
|
if (!alive) {
|
||||||
@@ -111,6 +123,7 @@ export function registerWsGateway(
|
|||||||
case 'sub': {
|
case 'sub': {
|
||||||
subscribedSessions = msg.topics.includes('sessions');
|
subscribedSessions = msg.topics.includes('sessions');
|
||||||
subscribedWorktrees = msg.topics.includes('worktrees');
|
subscribedWorktrees = msg.topics.includes('worktrees');
|
||||||
|
subscribedGroups = msg.topics.includes('groups');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'attach': {
|
case 'attach': {
|
||||||
@@ -202,6 +215,8 @@ export function registerWsGateway(
|
|||||||
worktrees.off('repo_removed', onRepoRemoved);
|
worktrees.off('repo_removed', onRepoRemoved);
|
||||||
worktrees.off('worktree_update', onWorktreeUpdate);
|
worktrees.off('worktree_update', onWorktreeUpdate);
|
||||||
worktrees.off('worktree_removed', onWorktreeRemoved);
|
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);
|
for (const [, st] of channels) manager.detach(st.sessionId, st.binding);
|
||||||
channels.clear();
|
channels.clear();
|
||||||
});
|
});
|
||||||
|
|||||||
100
packages/server/test/group-manager.test.ts
Normal file
100
packages/server/test/group-manager.test.ts
Normal file
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Types REST partagés (préfixe /api/v1).
|
// 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 {
|
export interface ApiError {
|
||||||
error: { code: string; message: string; details?: unknown };
|
error: { code: string; message: string; details?: unknown };
|
||||||
@@ -91,6 +91,30 @@ export interface AdoptWorktreeRequest {
|
|||||||
preTrust?: boolean;
|
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) ----
|
// ---- Navigateur de répertoires (sélecteur de dossier côté web) ----
|
||||||
export interface FsEntry {
|
export interface FsEntry {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -160,6 +160,22 @@ export interface WorktreeSummary {
|
|||||||
sessions: SessionSummary[];
|
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 ----
|
// ---- Messages client → serveur ----
|
||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
| { type: 'hello'; protocol: number }
|
| { type: 'hello'; protocol: number }
|
||||||
@@ -172,7 +188,7 @@ export type ClientMessage =
|
|||||||
| { type: 'answer'; channel: number; action: 'select' | 'confirm' | 'deny'; optionN?: number }
|
| { type: 'answer'; channel: number; action: 'select' | 'confirm' | 'deny'; optionN?: number }
|
||||||
| { type: 'resize'; channel: number; cols: number; rows: number }
|
| { type: 'resize'; channel: number; cols: number; rows: number }
|
||||||
| { type: 'ack'; channel: number; bytes: number }
|
| { type: 'ack'; channel: number; bytes: number }
|
||||||
| { type: 'sub'; topics: Array<'sessions' | 'worktrees'> }
|
| { type: 'sub'; topics: Array<'sessions' | 'worktrees' | 'groups'> }
|
||||||
| { type: 'ping' };
|
| { type: 'ping' };
|
||||||
|
|
||||||
// ---- Messages serveur → client ----
|
// ---- Messages serveur → client ----
|
||||||
@@ -187,6 +203,8 @@ export type ServerMessage =
|
|||||||
| { type: 'repo_removed'; repoId: string }
|
| { type: 'repo_removed'; repoId: string }
|
||||||
| { type: 'worktree_update'; repoId: string; worktree: WorktreeSummary }
|
| { type: 'worktree_update'; repoId: string; worktree: WorktreeSummary }
|
||||||
| { type: 'worktree_removed'; repoId: string; path: string }
|
| { 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: 'error'; code: ErrorCode; message: string; channel?: number }
|
||||||
| { type: 'pong' };
|
| { type: 'pong' };
|
||||||
|
|
||||||
@@ -244,8 +262,8 @@ export function parseClientMessage(raw: string): ClientMessage | null {
|
|||||||
? { type: 'ack', channel: m.channel, bytes: m.bytes }
|
? { type: 'ack', channel: m.channel, bytes: m.bytes }
|
||||||
: null;
|
: null;
|
||||||
case 'sub':
|
case 'sub':
|
||||||
return Array.isArray(m.topics) && m.topics.every((t) => t === 'sessions' || t === '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'> }
|
? { type: 'sub', topics: m.topics as Array<'sessions' | 'worktrees' | 'groups'> }
|
||||||
: null;
|
: null;
|
||||||
case 'ping':
|
case 'ping':
|
||||||
return { type: 'ping' };
|
return { type: 'ping' };
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function assertInvariants(msg: ClientMessage): void {
|
|||||||
expect(msg.bytes).toBeGreaterThanOrEqual(0);
|
expect(msg.bytes).toBeGreaterThanOrEqual(0);
|
||||||
break;
|
break;
|
||||||
case 'sub':
|
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;
|
break;
|
||||||
case 'ping':
|
case 'ping':
|
||||||
break;
|
break;
|
||||||
@@ -117,9 +117,14 @@ describe('parseClientMessage — cas valides', () => {
|
|||||||
expect(parseClientMessage('{"type":"ack","channel":1,"bytes":0}')).toEqual({ type: 'ack', channel: 1, bytes: 0 });
|
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"]}')).toEqual({ type: 'sub', topics: ['sessions'] });
|
||||||
expect(parseClientMessage('{"type":"sub","topics":["sessions","worktrees"]}')).toEqual({ type: 'sub', topics: ['sessions', 'worktrees'] });
|
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: [] });
|
expect(parseClientMessage('{"type":"sub","topics":[]}')).toEqual({ type: 'sub', topics: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
122
packages/web/src/components/CrossRepoFeatureModal.vue
Normal file
122
packages/web/src/components/CrossRepoFeatureModal.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" @click.self="emit('close')">
|
||||||
|
<div class="flex max-h-[90vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||||
|
<header class="flex items-center gap-2">
|
||||||
|
<h2 class="font-semibold text-zinc-100">{{ t('crossRepo.title') }}</h2>
|
||||||
|
<button class="btn ml-auto text-xs" @click="emit('close')">{{ t('crossRepo.close') }}</button>
|
||||||
|
</header>
|
||||||
|
<p class="text-xs text-zinc-500">{{ t('crossRepo.intro') }}</p>
|
||||||
|
|
||||||
|
<form class="flex flex-col gap-3" @submit.prevent="onSubmit">
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('crossRepo.branchLabel') }}
|
||||||
|
<input v-model="branch" class="input font-mono" :placeholder="t('crossRepo.branchPlaceholder')" required />
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<label class="flex items-center gap-1.5 text-xs text-zinc-400">
|
||||||
|
<input v-model="newBranch" type="checkbox" /> {{ t('crossRepo.newBranch') }}
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('crossRepo.baseRefLabel') }}
|
||||||
|
<input v-model="baseRef" class="input font-mono" placeholder="HEAD" />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('crossRepo.startLabel') }}
|
||||||
|
<select v-model="startSession" class="input">
|
||||||
|
<option :value="null">{{ t('crossRepo.startNone') }}</option>
|
||||||
|
<option value="claude">claude</option>
|
||||||
|
<option value="bash">bash</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('crossRepo.reposLabel') }}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
v-for="repo in repos"
|
||||||
|
:key="repo.id"
|
||||||
|
class="flex items-center gap-2 rounded border border-zinc-800 bg-zinc-950/40 px-2 py-1"
|
||||||
|
>
|
||||||
|
<input v-model="selectedRepoIds" type="checkbox" :value="repo.id" :disabled="running" />
|
||||||
|
<span class="flex-1 text-zinc-300">{{ repo.label }}</span>
|
||||||
|
<span v-if="results[repo.id]" class="text-[11px]" :class="statusClass(repo.id)">
|
||||||
|
{{ statusText(repo.id) }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="submit" class="btn-primary" :disabled="running || branch.trim() === '' || selectedRepoIds.length === 0">
|
||||||
|
{{ running ? t('crossRepo.creating') : t('crossRepo.create', { n: selectedRepoIds.length }) }}
|
||||||
|
</button>
|
||||||
|
<button v-if="hasFailures && !running" type="button" class="btn text-xs" @click="retryFailed">
|
||||||
|
{{ t('crossRepo.retryFailed') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { RepoSummary } from '@arboretum/shared';
|
||||||
|
import { useGroupsStore, type CrossRepoResult } from '../stores/groups';
|
||||||
|
|
||||||
|
const props = defineProps<{ repos: RepoSummary[] }>();
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const groups = useGroupsStore();
|
||||||
|
|
||||||
|
const branch = ref('');
|
||||||
|
const newBranch = ref(true);
|
||||||
|
const baseRef = ref('');
|
||||||
|
const startSession = ref<'claude' | 'bash' | null>(null);
|
||||||
|
const selectedRepoIds = ref<string[]>(props.repos.map((r) => r.id));
|
||||||
|
const results = ref<Record<string, CrossRepoResult>>({});
|
||||||
|
const running = ref(false);
|
||||||
|
|
||||||
|
const hasFailures = computed(() => Object.values(results.value).some((r) => r.status === 'error'));
|
||||||
|
|
||||||
|
function statusClass(repoId: string): string {
|
||||||
|
return results.value[repoId]?.status === 'ok' ? 'text-emerald-400' : 'text-red-400';
|
||||||
|
}
|
||||||
|
function statusText(repoId: string): string {
|
||||||
|
const r = results.value[repoId];
|
||||||
|
if (!r) return '';
|
||||||
|
return r.status === 'ok' ? t('crossRepo.ok') : `${t('crossRepo.error')}: ${r.message ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(repoIds: string[]): Promise<void> {
|
||||||
|
if (repoIds.length === 0) return;
|
||||||
|
running.value = true;
|
||||||
|
for (const id of repoIds) delete results.value[id];
|
||||||
|
try {
|
||||||
|
await groups.createCrossRepoFeature(
|
||||||
|
repoIds,
|
||||||
|
{
|
||||||
|
branch: branch.value.trim(),
|
||||||
|
newBranch: newBranch.value,
|
||||||
|
...(baseRef.value.trim() ? { baseRef: baseRef.value.trim() } : {}),
|
||||||
|
startSession: startSession.value,
|
||||||
|
},
|
||||||
|
(result) => {
|
||||||
|
results.value = { ...results.value, [result.repoId]: result };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
running.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit(): void {
|
||||||
|
void run([...selectedRepoIds.value]);
|
||||||
|
}
|
||||||
|
function retryFailed(): void {
|
||||||
|
void run(Object.values(results.value).filter((r) => r.status === 'error').map((r) => r.repoId));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
39
packages/web/src/components/TerminalCell.vue
Normal file
39
packages/web/src/components/TerminalCell.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<!-- focus-within → anneau visuel : montre quelle cellule reçoit la frappe clavier -->
|
||||||
|
<div class="flex min-h-0 flex-col overflow-hidden rounded-lg border border-zinc-800 bg-[#09090b] focus-within:ring-2 focus-within:ring-sky-600">
|
||||||
|
<header class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1">
|
||||||
|
<SessionStateBadge :session="session" />
|
||||||
|
<span class="truncate font-mono text-xs text-zinc-300" :title="session.cwd">{{ title }}</span>
|
||||||
|
<span class="ml-auto shrink-0 font-mono text-[11px] text-zinc-500">{{ session.command }}</span>
|
||||||
|
</header>
|
||||||
|
<DialogPrompt v-if="isWaiting" :session="session" class="px-2 pt-2" />
|
||||||
|
<div class="min-h-0 flex-1">
|
||||||
|
<TerminalView :session-id="session.id" mode="interactive" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
|
import SessionStateBadge from './SessionStateBadge.vue';
|
||||||
|
import DialogPrompt from './DialogPrompt.vue';
|
||||||
|
import TerminalView from './TerminalView.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: SessionSummary }>();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
|
||||||
|
// libellé : « repo · branche » si le worktree est connu, sinon le dernier segment du cwd.
|
||||||
|
const title = computed(() => {
|
||||||
|
const wt = worktrees.worktrees.find((w) => w.path === props.session.cwd);
|
||||||
|
if (wt) {
|
||||||
|
const repo = worktrees.repos.find((r) => r.id === wt.repoId);
|
||||||
|
const branch = wt.branch ?? wt.head.slice(0, 7);
|
||||||
|
return repo ? `${repo.label} · ${branch}` : branch;
|
||||||
|
}
|
||||||
|
return props.session.cwd.split('/').filter(Boolean).pop() ?? props.session.cwd;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isWaiting = computed(() => props.session.live && props.session.activity === 'waiting' && !!props.session.dialog);
|
||||||
|
</script>
|
||||||
28
packages/web/src/components/TerminalGrid.vue
Normal file
28
packages/web/src/components/TerminalGrid.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<p v-if="sessions.length === 0" class="text-sm text-zinc-500">{{ t('groups.gridEmpty') }}</p>
|
||||||
|
<template v-else>
|
||||||
|
<p v-if="sessions.length > MAX_CELLS" class="text-xs text-amber-400">
|
||||||
|
{{ t('groups.gridCapped', { shown: MAX_CELLS, total: sessions.length }) }}
|
||||||
|
</p>
|
||||||
|
<!-- une seule socket WS multiplexe tous les terminaux ; cap dur pour préserver GPU/canaux.
|
||||||
|
Sur mobile la grille retombe à une colonne (empilement). -->
|
||||||
|
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
<TerminalCell v-for="s in shown" :key="s.id" :session="s" class="h-80" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
|
import TerminalCell from './TerminalCell.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ sessions: SessionSummary[] }>();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const MAX_CELLS = 6;
|
||||||
|
const shown = computed(() => props.sessions.slice(0, MAX_CELLS));
|
||||||
|
</script>
|
||||||
@@ -61,6 +61,54 @@ export default {
|
|||||||
dashboard: {
|
dashboard: {
|
||||||
title: 'Worktrees',
|
title: 'Worktrees',
|
||||||
allSessions: 'All sessions',
|
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: {
|
repos: {
|
||||||
add: 'Add repo',
|
add: 'Add repo',
|
||||||
|
|||||||
@@ -63,6 +63,54 @@ const fr: typeof en = {
|
|||||||
dashboard: {
|
dashboard: {
|
||||||
title: 'Worktrees',
|
title: 'Worktrees',
|
||||||
allSessions: 'Toutes les sessions',
|
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: {
|
repos: {
|
||||||
add: 'Ajouter un repo',
|
add: 'Ajouter un repo',
|
||||||
|
|||||||
@@ -34,5 +34,6 @@ async function request<T>(path: string, method: string, body?: unknown): Promise
|
|||||||
export const api = {
|
export const api = {
|
||||||
get: <T>(path: string): Promise<T> => request<T>(path, 'GET'),
|
get: <T>(path: string): Promise<T> => request<T>(path, 'GET'),
|
||||||
post: <T>(path: string, body?: unknown): Promise<T> => request<T>(path, 'POST', body),
|
post: <T>(path: string, body?: unknown): Promise<T> => request<T>(path, 'POST', body),
|
||||||
|
patch: <T>(path: string, body?: unknown): Promise<T> => request<T>(path, 'PATCH', body),
|
||||||
delete: <T>(path: string): Promise<T> => request<T>(path, 'DELETE'),
|
delete: <T>(path: string): Promise<T> => request<T>(path, 'DELETE'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface TerminalSink {
|
|||||||
|
|
||||||
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' }>;
|
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' }>;
|
||||||
export type WorktreeEvent = Extract<ServerMessage, { type: 'repo_update' | 'repo_removed' | 'worktree_update' | 'worktree_removed' }>;
|
export type WorktreeEvent = Extract<ServerMessage, { type: 'repo_update' | 'repo_removed' | 'worktree_update' | 'worktree_removed' }>;
|
||||||
|
export type GroupEvent = Extract<ServerMessage, { type: 'group_update' | 'group_removed' }>;
|
||||||
|
|
||||||
export interface AttachOptions {
|
export interface AttachOptions {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -116,6 +117,7 @@ export class WsClient {
|
|||||||
private awaitingAttached: Attachment[] = [];
|
private awaitingAttached: Attachment[] = [];
|
||||||
private readonly sessionListeners = new Set<(e: SessionEvent) => void>();
|
private readonly sessionListeners = new Set<(e: SessionEvent) => void>();
|
||||||
private readonly worktreeListeners = new Set<(e: WorktreeEvent) => void>();
|
private readonly worktreeListeners = new Set<(e: WorktreeEvent) => void>();
|
||||||
|
private readonly groupListeners = new Set<(e: GroupEvent) => void>();
|
||||||
|
|
||||||
connect(): void {
|
connect(): void {
|
||||||
this.stopped = false;
|
this.stopped = false;
|
||||||
@@ -156,10 +158,11 @@ export class WsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** topics actifs = union des abonnements courants (une seule connexion partagée). */
|
/** topics actifs = union des abonnements courants (une seule connexion partagée). */
|
||||||
private activeTopics(): Array<'sessions' | 'worktrees'> {
|
private activeTopics(): Array<'sessions' | 'worktrees' | 'groups'> {
|
||||||
const t: Array<'sessions' | 'worktrees'> = [];
|
const t: Array<'sessions' | 'worktrees' | 'groups'> = [];
|
||||||
if (this.sessionListeners.size > 0) t.push('sessions');
|
if (this.sessionListeners.size > 0) t.push('sessions');
|
||||||
if (this.worktreeListeners.size > 0) t.push('worktrees');
|
if (this.worktreeListeners.size > 0) t.push('worktrees');
|
||||||
|
if (this.groupListeners.size > 0) t.push('groups');
|
||||||
return t;
|
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 {
|
sendControl(msg: ClientMessage): void {
|
||||||
if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) return;
|
if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) return;
|
||||||
this.socket.send(JSON.stringify(msg));
|
this.socket.send(JSON.stringify(msg));
|
||||||
@@ -380,6 +393,11 @@ export class WsClient {
|
|||||||
for (const cb of this.worktreeListeners) cb(msg);
|
for (const cb of this.worktreeListeners) cb(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'group_update':
|
||||||
|
case 'group_removed': {
|
||||||
|
for (const cb of this.groupListeners) cb(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
case 'error': {
|
case 'error': {
|
||||||
if (msg.channel !== undefined) {
|
if (msg.channel !== undefined) {
|
||||||
console.warn(`[ws] channel ${msg.channel}: ${msg.code} — ${msg.message}`);
|
console.warn(`[ws] channel ${msg.channel}: ${msg.code} — ${msg.message}`);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export const router = createRouter({
|
|||||||
{ path: '/', name: 'dashboard', component: () => import('../views/DashboardView.vue') },
|
{ path: '/', name: 'dashboard', component: () => import('../views/DashboardView.vue') },
|
||||||
{ path: '/sessions', name: 'sessions', component: () => import('../views/SessionsListView.vue') },
|
{ path: '/sessions', name: 'sessions', component: () => import('../views/SessionsListView.vue') },
|
||||||
{ path: '/sessions/:id', name: 'session', component: () => import('../views/SessionView.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: '/' },
|
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
175
packages/web/src/stores/groups.ts
Normal file
175
packages/web/src/stores/groups.ts
Normal file
@@ -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<CreateWorktreeRequest, 'path'>;
|
||||||
|
|
||||||
|
/** 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<GroupSummary[]>([]);
|
||||||
|
const selectedGroupId = ref<string | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadError = ref<string | null>(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<void> {
|
||||||
|
loading.value = true;
|
||||||
|
loadError.value = null;
|
||||||
|
try {
|
||||||
|
const res = await api.get<GroupsListResponse>('/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<GroupSummary> {
|
||||||
|
const res = await api.post<GroupResponse>('/api/v1/groups', req);
|
||||||
|
upsert(res.group);
|
||||||
|
return res.group;
|
||||||
|
}
|
||||||
|
async function updateGroup(id: string, patch: UpdateGroupRequest): Promise<GroupSummary> {
|
||||||
|
const res = await api.patch<GroupResponse>(`/api/v1/groups/${id}`, patch);
|
||||||
|
upsert(res.group);
|
||||||
|
return res.group;
|
||||||
|
}
|
||||||
|
async function deleteGroup(id: string): Promise<void> {
|
||||||
|
await api.delete<{ ok: true }>(`/api/v1/groups/${id}`);
|
||||||
|
removeLocal(id);
|
||||||
|
}
|
||||||
|
async function addRepoToGroup(groupId: string, repoId: string): Promise<GroupSummary> {
|
||||||
|
const res = await api.post<GroupResponse>(`/api/v1/groups/${groupId}/repos`, { repoId });
|
||||||
|
upsert(res.group);
|
||||||
|
return res.group;
|
||||||
|
}
|
||||||
|
async function removeRepoFromGroup(groupId: string, repoId: string): Promise<GroupSummary> {
|
||||||
|
const res = await api.delete<GroupResponse>(`/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<CrossRepoResult[]> {
|
||||||
|
const wt = useWorktreesStore();
|
||||||
|
return Promise.all(
|
||||||
|
repoIds.map(async (repoId): Promise<CrossRepoResult> => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<h1 class="text-lg font-semibold text-zinc-100">{{ t('dashboard.title') }}</h1>
|
<h1 class="text-lg font-semibold text-zinc-100">{{ t('dashboard.title') }}</h1>
|
||||||
<span v-if="auth.serverVersion" class="text-xs text-zinc-600">v{{ auth.serverVersion }}</span>
|
<span v-if="auth.serverVersion" class="text-xs text-zinc-600">v{{ auth.serverVersion }}</span>
|
||||||
<div class="ml-auto flex items-center gap-2">
|
<div class="ml-auto flex items-center gap-2">
|
||||||
|
<RouterLink :to="{ name: 'groups' }" class="btn">{{ t('dashboard.groups') }}</RouterLink>
|
||||||
<RouterLink :to="{ name: 'sessions' }" class="btn">{{ t('dashboard.allSessions') }}</RouterLink>
|
<RouterLink :to="{ name: 'sessions' }" class="btn">{{ t('dashboard.allSessions') }}</RouterLink>
|
||||||
<button class="btn" :disabled="store.loading" @click="store.fetchAll()">{{ t('sessions.refresh') }}</button>
|
<button class="btn" :disabled="store.loading" @click="store.fetchAll()">{{ t('sessions.refresh') }}</button>
|
||||||
<button
|
<button
|
||||||
|
|||||||
149
packages/web/src/views/GroupView.vue
Normal file
149
packages/web/src/views/GroupView.vue
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<div class="overflow-y-auto">
|
||||||
|
<div class="mx-auto flex max-w-3xl flex-col gap-4 px-4 py-6">
|
||||||
|
<header class="flex flex-wrap items-center gap-3">
|
||||||
|
<RouterLink :to="{ name: 'groups' }" class="text-sm text-zinc-400 hover:text-zinc-200">← {{ t('groups.title') }}</RouterLink>
|
||||||
|
<h1 v-if="group" class="flex items-center gap-2 text-lg font-semibold text-zinc-100">
|
||||||
|
<span class="h-3 w-3 rounded-full" :style="{ backgroundColor: group.color ?? '#52525b' }" />
|
||||||
|
{{ group.label }}
|
||||||
|
</h1>
|
||||||
|
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||||
|
<button v-if="group" class="btn text-xs" :class="editingRepos ? 'border-sky-700 text-sky-300' : ''" @click="editingRepos = !editingRepos">
|
||||||
|
{{ editingRepos ? t('groups.editDone') : t('groups.edit') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn text-xs" :disabled="groupRepos.length === 0" @click="showFeatureModal = true">{{ t('groups.newFeature') }}</button>
|
||||||
|
<div class="flex overflow-hidden rounded border border-zinc-800">
|
||||||
|
<button class="px-2 py-1 text-xs" :class="view === 'list' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400'" @click="view = 'list'">
|
||||||
|
{{ t('groups.viewList') }}
|
||||||
|
</button>
|
||||||
|
<button class="px-2 py-1 text-xs" :class="view === 'grid' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400'" @click="view = 'grid'">
|
||||||
|
{{ t('groups.viewGrid') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn text-xs" :disabled="worktrees.loading" @click="refresh">{{ t('sessions.refresh') }}</button>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="!group" class="text-sm text-zinc-500">{{ t('common.loading') }}</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- édition de la composition : ajouter/retirer des repos déjà enregistrés -->
|
||||||
|
<div v-if="editingRepos" class="flex flex-col gap-1 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 text-xs text-zinc-400">
|
||||||
|
{{ t('groups.reposLabel') }}
|
||||||
|
<p v-if="worktrees.repos.length === 0" class="text-zinc-600">{{ t('groups.noReposRegistered') }}</p>
|
||||||
|
<div v-else class="flex flex-wrap gap-2">
|
||||||
|
<label
|
||||||
|
v-for="repo in worktrees.repos"
|
||||||
|
:key="repo.id"
|
||||||
|
class="flex items-center gap-1.5 rounded border border-zinc-800 bg-zinc-950/40 px-2 py-1"
|
||||||
|
>
|
||||||
|
<input type="checkbox" :checked="group.repoIds.includes(repo.id)" @change="toggleRepo(repo.id)" />
|
||||||
|
<span class="text-zinc-300">{{ repo.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- mode liste : réutilise RepoSection (worktrees + sessions + dialogues inline) -->
|
||||||
|
<template v-if="view === 'list'">
|
||||||
|
<p v-if="groupRepos.length === 0 && missingRepoIds.length === 0" class="text-sm text-zinc-500">{{ t('groups.noReposInGroup') }}</p>
|
||||||
|
<RepoSection v-for="repo in groupRepos" :key="repo.id" :repo="repo" />
|
||||||
|
<div
|
||||||
|
v-for="id in missingRepoIds"
|
||||||
|
:key="id"
|
||||||
|
class="flex items-center gap-2 rounded-lg border border-amber-900/50 bg-amber-950/20 p-3 text-sm text-amber-300"
|
||||||
|
>
|
||||||
|
<span class="flex-1">{{ t('groups.missingRepo') }} <span class="font-mono text-xs text-amber-500/70">{{ id }}</span></span>
|
||||||
|
<button class="btn text-xs" @click="removeRepo(id)">{{ t('groups.removeFromGroup') }}</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- mode grille : terminaux côte à côte -->
|
||||||
|
<TerminalGrid v-else :sessions="activeGroupSessions" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CrossRepoFeatureModal
|
||||||
|
v-if="showFeatureModal && group"
|
||||||
|
:repos="groupRepos"
|
||||||
|
@close="showFeatureModal = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { SessionSummary } from '@arboretum/shared';
|
||||||
|
import { useGroupsStore } from '../stores/groups';
|
||||||
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
|
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||||
|
import RepoSection from '../components/RepoSection.vue';
|
||||||
|
import TerminalGrid from '../components/TerminalGrid.vue';
|
||||||
|
import CrossRepoFeatureModal from '../components/CrossRepoFeatureModal.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
const groups = useGroupsStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
|
||||||
|
const groupId = computed(() => String(route.params.id));
|
||||||
|
const group = computed(() => groups.byId(groupId.value));
|
||||||
|
const view = ref<'list' | 'grid'>('list');
|
||||||
|
const editingRepos = ref(false);
|
||||||
|
const showFeatureModal = ref(false);
|
||||||
|
|
||||||
|
const groupRepos = computed(() => groups.reposInGroup(groupId.value));
|
||||||
|
|
||||||
|
// repoIds du groupe absents du store worktrees (repo supprimé mais membership pas encore purgée côté UI).
|
||||||
|
const missingRepoIds = computed(() => {
|
||||||
|
const g = group.value;
|
||||||
|
if (!g) return [];
|
||||||
|
const known = new Set(worktrees.repos.map((r) => r.id));
|
||||||
|
return g.repoIds.filter((id) => !known.has(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// sessions vivantes et attachables des repos du groupe, en version live (store sessions).
|
||||||
|
const activeGroupSessions = computed<SessionSummary[]>(() => {
|
||||||
|
const byId = new Map<string, SessionSummary>();
|
||||||
|
for (const snap of groups.sessionsInGroup(groupId.value)) {
|
||||||
|
const live = sessions.sessions.find((x) => x.id === snap.id) ?? snap;
|
||||||
|
if (live.live && live.attachable) byId.set(live.id, live);
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void groups.fetchGroups();
|
||||||
|
void worktrees.fetchAll();
|
||||||
|
void sessions.fetchSessions();
|
||||||
|
groups.startRealtime();
|
||||||
|
worktrees.startRealtime();
|
||||||
|
sessions.startRealtime();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
groups.stopRealtime();
|
||||||
|
worktrees.stopRealtime();
|
||||||
|
sessions.stopRealtime();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
await Promise.all([groups.fetchGroups(), worktrees.fetchAll(), sessions.fetchSessions()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRepo(repoId: string): Promise<void> {
|
||||||
|
const g = group.value;
|
||||||
|
if (!g) return;
|
||||||
|
if (g.repoIds.includes(repoId)) await groups.removeRepoFromGroup(g.id, repoId);
|
||||||
|
else await groups.addRepoToGroup(g.id, repoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRepo(repoId: string): Promise<void> {
|
||||||
|
const g = group.value;
|
||||||
|
if (g) await groups.removeRepoFromGroup(g.id, repoId);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
157
packages/web/src/views/GroupsListView.vue
Normal file
157
packages/web/src/views/GroupsListView.vue
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<div class="overflow-y-auto">
|
||||||
|
<div class="mx-auto flex max-w-3xl flex-col gap-4 px-4 py-6">
|
||||||
|
<header class="flex items-center gap-3">
|
||||||
|
<h1 class="text-lg font-semibold text-zinc-100">{{ t('groups.title') }}</h1>
|
||||||
|
<div class="ml-auto flex items-center gap-2">
|
||||||
|
<RouterLink :to="{ name: 'dashboard' }" class="btn">{{ t('groups.dashboard') }}</RouterLink>
|
||||||
|
<button class="btn" :disabled="groups.loading" @click="refresh">{{ t('sessions.refresh') }}</button>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<button class="btn" @click="onLogout">{{ t('common.logout') }}</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- création d'un groupe : nom + sélection des repos déjà enregistrés -->
|
||||||
|
<form class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3" @submit.prevent="onCreate">
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('groups.nameLabel') }}
|
||||||
|
<input v-model="newLabel" type="text" class="input" :placeholder="t('groups.namePlaceholder')" required />
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-col gap-1 text-xs text-zinc-400">
|
||||||
|
{{ t('groups.reposLabel') }}
|
||||||
|
<p v-if="worktrees.repos.length === 0" class="text-zinc-600">{{ t('groups.noReposRegistered') }}</p>
|
||||||
|
<div v-else class="flex flex-wrap gap-2">
|
||||||
|
<label
|
||||||
|
v-for="repo in worktrees.repos"
|
||||||
|
:key="repo.id"
|
||||||
|
class="flex items-center gap-1.5 rounded border border-zinc-800 bg-zinc-950/40 px-2 py-1"
|
||||||
|
>
|
||||||
|
<input v-model="selectedRepoIds" type="checkbox" :value="repo.id" />
|
||||||
|
<span class="text-zinc-300">{{ repo.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="submit" class="btn-primary" :disabled="creating || newLabel.trim() === ''">
|
||||||
|
{{ creating ? t('groups.creating') : t('groups.new') }}
|
||||||
|
</button>
|
||||||
|
<span v-if="createError" class="text-sm text-red-400">{{ createError }}</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p v-if="groups.loadError" class="text-sm text-red-400">{{ groups.loadError }}</p>
|
||||||
|
<p v-else-if="groups.loading && groups.groups.length === 0" class="text-sm text-zinc-500">{{ t('common.loading') }}</p>
|
||||||
|
<p v-else-if="groups.groups.length === 0" class="text-sm text-zinc-500">{{ t('groups.empty') }}</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<article
|
||||||
|
v-for="group in groups.groups"
|
||||||
|
:key="group.id"
|
||||||
|
class="flex items-center gap-3 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-3 w-3 shrink-0 rounded-full"
|
||||||
|
:style="{ backgroundColor: group.color ?? '#52525b' }"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<RouterLink :to="{ name: 'group', params: { id: group.id } }" class="font-semibold text-zinc-100 hover:underline">
|
||||||
|
{{ group.label }}
|
||||||
|
</RouterLink>
|
||||||
|
<p v-if="group.description" class="truncate text-xs text-zinc-500">{{ group.description }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-zinc-500">{{ t('groups.repoCount', group.repoIds.length) }}</span>
|
||||||
|
<span v-if="activeCount(group.id) > 0" class="badge bg-emerald-950 text-emerald-400">
|
||||||
|
{{ t('groups.sessionsActive', activeCount(group.id)) }}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<RouterLink :to="{ name: 'group', params: { id: group.id } }" class="btn text-xs">{{ t('groups.open') }}</RouterLink>
|
||||||
|
<template v-if="confirmingDelete === group.id">
|
||||||
|
<button class="btn-danger text-xs" @click="onDelete(group.id)">{{ t('groups.confirmDelete') }}</button>
|
||||||
|
<button class="btn text-xs" @click="confirmingDelete = null">{{ t('common.cancel') }}</button>
|
||||||
|
</template>
|
||||||
|
<button v-else class="btn-danger text-xs" @click="confirmingDelete = group.id">{{ t('groups.remove') }}</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
import { useGroupsStore } from '../stores/groups';
|
||||||
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
|
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const groups = useGroupsStore();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
|
||||||
|
const newLabel = ref('');
|
||||||
|
const selectedRepoIds = ref<string[]>([]);
|
||||||
|
const creating = ref(false);
|
||||||
|
const createError = ref<string | null>(null);
|
||||||
|
const confirmingDelete = ref<string | null>(null);
|
||||||
|
|
||||||
|
// nombre de sessions vivantes corrélées aux repos du groupe (badge d'aperçu).
|
||||||
|
function activeCount(groupId: string): number {
|
||||||
|
return groups.sessionsInGroup(groupId).filter((s) => s.live).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void groups.fetchGroups();
|
||||||
|
void worktrees.fetchAll();
|
||||||
|
void sessions.fetchSessions();
|
||||||
|
groups.startRealtime();
|
||||||
|
worktrees.startRealtime();
|
||||||
|
sessions.startRealtime();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
groups.stopRealtime();
|
||||||
|
worktrees.stopRealtime();
|
||||||
|
sessions.stopRealtime();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
await Promise.all([groups.fetchGroups(), worktrees.fetchAll(), sessions.fetchSessions()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreate(): Promise<void> {
|
||||||
|
creating.value = true;
|
||||||
|
createError.value = null;
|
||||||
|
try {
|
||||||
|
await groups.createGroup({ label: newLabel.value.trim(), repoIds: [...selectedRepoIds.value] });
|
||||||
|
newLabel.value = '';
|
||||||
|
selectedRepoIds.value = [];
|
||||||
|
} catch (err) {
|
||||||
|
createError.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
creating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(id: string): Promise<void> {
|
||||||
|
confirmingDelete.value = null;
|
||||||
|
try {
|
||||||
|
await groups.deleteGroup(id);
|
||||||
|
} catch (err) {
|
||||||
|
createError.value = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLogout(): Promise<void> {
|
||||||
|
groups.stopRealtime();
|
||||||
|
worktrees.stopRealtime();
|
||||||
|
sessions.stopRealtime();
|
||||||
|
await auth.logout();
|
||||||
|
await router.replace({ name: 'login' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user