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. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,5 +34,6 @@ async function request<T>(path: string, method: string, body?: unknown): Promise
|
||||
export const api = {
|
||||
get: <T>(path: string): Promise<T> => request<T>(path, 'GET'),
|
||||
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'),
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface TerminalSink {
|
||||
|
||||
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 GroupEvent = Extract<ServerMessage, { type: 'group_update' | 'group_removed' }>;
|
||||
|
||||
export interface AttachOptions {
|
||||
sessionId: string;
|
||||
@@ -116,6 +117,7 @@ export class WsClient {
|
||||
private awaitingAttached: Attachment[] = [];
|
||||
private readonly sessionListeners = new Set<(e: SessionEvent) => void>();
|
||||
private readonly worktreeListeners = new Set<(e: WorktreeEvent) => void>();
|
||||
private readonly groupListeners = new Set<(e: GroupEvent) => void>();
|
||||
|
||||
connect(): void {
|
||||
this.stopped = false;
|
||||
@@ -156,10 +158,11 @@ export class WsClient {
|
||||
}
|
||||
|
||||
/** topics actifs = union des abonnements courants (une seule connexion partagée). */
|
||||
private activeTopics(): Array<'sessions' | 'worktrees'> {
|
||||
const t: Array<'sessions' | 'worktrees'> = [];
|
||||
private activeTopics(): Array<'sessions' | 'worktrees' | 'groups'> {
|
||||
const t: Array<'sessions' | 'worktrees' | 'groups'> = [];
|
||||
if (this.sessionListeners.size > 0) t.push('sessions');
|
||||
if (this.worktreeListeners.size > 0) t.push('worktrees');
|
||||
if (this.groupListeners.size > 0) t.push('groups');
|
||||
return t;
|
||||
}
|
||||
|
||||
@@ -187,6 +190,16 @@ export class WsClient {
|
||||
};
|
||||
}
|
||||
|
||||
subscribeGroups(listener: (e: GroupEvent) => void): () => void {
|
||||
this.groupListeners.add(listener);
|
||||
this.connect();
|
||||
this.sendSub();
|
||||
return () => {
|
||||
this.groupListeners.delete(listener);
|
||||
this.sendSub();
|
||||
};
|
||||
}
|
||||
|
||||
sendControl(msg: ClientMessage): void {
|
||||
if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) return;
|
||||
this.socket.send(JSON.stringify(msg));
|
||||
@@ -380,6 +393,11 @@ export class WsClient {
|
||||
for (const cb of this.worktreeListeners) cb(msg);
|
||||
return;
|
||||
}
|
||||
case 'group_update':
|
||||
case 'group_removed': {
|
||||
for (const cb of this.groupListeners) cb(msg);
|
||||
return;
|
||||
}
|
||||
case 'error': {
|
||||
if (msg.channel !== undefined) {
|
||||
console.warn(`[ws] channel ${msg.channel}: ${msg.code} — ${msg.message}`);
|
||||
|
||||
Reference in New Issue
Block a user