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:
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: {
|
||||
title: 'Worktrees',
|
||||
allSessions: 'All sessions',
|
||||
groups: 'Groups',
|
||||
},
|
||||
groups: {
|
||||
title: 'Groups',
|
||||
new: 'New group',
|
||||
create: 'Create',
|
||||
creating: 'Creating…',
|
||||
nameLabel: 'Group name',
|
||||
namePlaceholder: 'e.g. Payments stack',
|
||||
colorLabel: 'Color',
|
||||
reposLabel: 'Repositories',
|
||||
empty: 'No group yet — create one above.',
|
||||
noRepos: 'No repository selected.',
|
||||
noReposInGroup: 'No repository in this group yet — add some below.',
|
||||
noReposRegistered: 'No repository registered yet — add some from the dashboard first.',
|
||||
open: 'Open',
|
||||
edit: 'Edit repositories',
|
||||
editDone: 'Done',
|
||||
remove: 'Delete',
|
||||
confirmDelete: 'Confirm delete',
|
||||
repoCount: 'no repo | 1 repo | {n} repos',
|
||||
dashboard: 'Dashboard',
|
||||
missingRepo: 'Repository unavailable',
|
||||
removeFromGroup: 'Remove from group',
|
||||
sessionsActive: 'no active session | 1 active session | {n} active sessions',
|
||||
viewList: 'Repos',
|
||||
viewGrid: 'Terminals',
|
||||
gridEmpty: 'No active session in this group.',
|
||||
gridCapped: 'Showing {shown} of {total} sessions — close some terminals to see the rest.',
|
||||
newFeature: 'New cross-repo feature',
|
||||
},
|
||||
crossRepo: {
|
||||
title: 'New cross-repo feature',
|
||||
intro: 'Create the same worktree across the selected repos, in one action.',
|
||||
branchLabel: 'Branch name',
|
||||
branchPlaceholder: 'feature/…',
|
||||
newBranch: 'create branch',
|
||||
baseRefLabel: 'Base ref (optional)',
|
||||
startLabel: 'Start session',
|
||||
startNone: 'no session',
|
||||
reposLabel: 'Apply to',
|
||||
create: 'Create across {n} repos',
|
||||
creating: 'Creating…',
|
||||
retryFailed: 'Retry failed',
|
||||
close: 'Close',
|
||||
pending: 'pending…',
|
||||
ok: 'created',
|
||||
error: 'failed',
|
||||
},
|
||||
repos: {
|
||||
add: 'Add repo',
|
||||
|
||||
@@ -63,6 +63,54 @@ const fr: typeof en = {
|
||||
dashboard: {
|
||||
title: 'Worktrees',
|
||||
allSessions: 'Toutes les sessions',
|
||||
groups: 'Groupes',
|
||||
},
|
||||
groups: {
|
||||
title: 'Groupes',
|
||||
new: 'Nouveau groupe',
|
||||
create: 'Créer',
|
||||
creating: 'Création…',
|
||||
nameLabel: 'Nom du groupe',
|
||||
namePlaceholder: 'ex. Stack paiements',
|
||||
colorLabel: 'Couleur',
|
||||
reposLabel: 'Dépôts',
|
||||
empty: 'Aucun groupe — créez-en un ci-dessus.',
|
||||
noRepos: 'Aucun dépôt sélectionné.',
|
||||
noReposInGroup: 'Aucun dépôt dans ce groupe — ajoutez-en ci-dessous.',
|
||||
noReposRegistered: 'Aucun dépôt enregistré — ajoutez-en d’abord depuis le tableau de bord.',
|
||||
open: 'Ouvrir',
|
||||
edit: 'Modifier les dépôts',
|
||||
editDone: 'Terminé',
|
||||
remove: 'Supprimer',
|
||||
confirmDelete: 'Confirmer',
|
||||
repoCount: 'aucun dépôt | 1 dépôt | {n} dépôts',
|
||||
dashboard: 'Tableau de bord',
|
||||
missingRepo: 'Dépôt indisponible',
|
||||
removeFromGroup: 'Retirer du groupe',
|
||||
sessionsActive: 'aucune session active | 1 session active | {n} sessions actives',
|
||||
viewList: 'Dépôts',
|
||||
viewGrid: 'Terminaux',
|
||||
gridEmpty: 'Aucune session active dans ce groupe.',
|
||||
gridCapped: '{shown} sessions affichées sur {total} — fermez des terminaux pour voir le reste.',
|
||||
newFeature: 'Nouvelle feature cross-repo',
|
||||
},
|
||||
crossRepo: {
|
||||
title: 'Nouvelle feature cross-repo',
|
||||
intro: 'Crée le même worktree dans les dépôts sélectionnés, en une seule action.',
|
||||
branchLabel: 'Nom de branche',
|
||||
branchPlaceholder: 'feature/…',
|
||||
newBranch: 'créer la branche',
|
||||
baseRefLabel: 'Réf. de base (optionnel)',
|
||||
startLabel: 'Démarrer une session',
|
||||
startNone: 'aucune session',
|
||||
reposLabel: 'Appliquer à',
|
||||
create: 'Créer dans {n} dépôts',
|
||||
creating: 'Création…',
|
||||
retryFailed: 'Réessayer les échecs',
|
||||
close: 'Fermer',
|
||||
pending: 'en cours…',
|
||||
ok: 'créé',
|
||||
error: 'échec',
|
||||
},
|
||||
repos: {
|
||||
add: 'Ajouter un repo',
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -8,6 +8,8 @@ export const router = createRouter({
|
||||
{ path: '/', name: 'dashboard', component: () => import('../views/DashboardView.vue') },
|
||||
{ path: '/sessions', name: 'sessions', component: () => import('../views/SessionsListView.vue') },
|
||||
{ path: '/sessions/:id', name: 'session', component: () => import('../views/SessionView.vue') },
|
||||
{ path: '/groups', name: 'groups', component: () => import('../views/GroupsListView.vue') },
|
||||
{ path: '/groups/:id', name: 'group', component: () => import('../views/GroupView.vue') },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
],
|
||||
});
|
||||
|
||||
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>
|
||||
<span v-if="auth.serverVersion" class="text-xs text-zinc-600">v{{ auth.serverVersion }}</span>
|
||||
<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>
|
||||
<button class="btn" :disabled="store.loading" @click="store.fetchAll()">{{ t('sessions.refresh') }}</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