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:
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,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user