P3-C: dashboard worktree-first + acceptance P3 (MVP complet)

Vue racine consolidée : repos → worktrees avec état git ET état de session
corrélé. Complète le MVP (P2 découverte/reprise + P3-A worktrees + P3-B états fins).

- web/lib/ws-client: abonnement multi-topics (sessions + worktrees), subscribeWorktrees.
- web/stores/worktrees: repos + worktrees, CRUD, temps réel (repo_update/worktree_*).
- web/views/DashboardView (route racine /), components RepoSection + WorktreeCard ;
  SessionStateBadge réutilisé pour l'état des sessions corrélées.
- router: / = dashboard, /sessions = liste à plat (sessions hors worktree), nav croisée.
- i18n EN/FR (dashboard/repos/worktrees).
- fix: @xterm/headless est CommonJS → chargé via createRequire (l'import nommé ESM
  échouait sous Node natif, masqué par esbuild en test) ; détecté par l'acceptation.
- scripts/acceptance-p3.mjs: repo git tmp → enregistrement, worktree + hook, broadcast
  WS worktree_update, corrélation session bash, suppression (409 sans force, 200 avec).

Vérifs : typecheck, 159 tests, build (vue-tsc), acceptations P1/P2/P3 ALL GREEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan LEROY
2026-06-12 18:46:45 +02:00
parent 33a41e7a30
commit c224abe108
13 changed files with 643 additions and 8 deletions

View File

@@ -0,0 +1,87 @@
<template>
<div class="rounded border border-zinc-800 bg-zinc-900/40 p-2">
<div class="flex items-center gap-2">
<span class="font-mono text-sm text-zinc-200">{{ branchLabel }}</span>
<span v-if="worktree.isMain" class="badge bg-zinc-800 text-zinc-400">{{ t('worktrees.main') }}</span>
<span v-if="worktree.locked" class="badge bg-zinc-800 text-zinc-500">{{ t('worktrees.locked') }}</span>
<span v-if="worktree.prunable" class="badge bg-zinc-800 text-zinc-500">{{ t('worktrees.prunable') }}</span>
<span class="ml-auto flex items-center gap-2 text-xs text-zinc-500">
<span v-if="worktree.git.ahead">{{ worktree.git.ahead }}</span>
<span v-if="worktree.git.behind">{{ worktree.git.behind }}</span>
<span v-if="worktree.git.dirtyCount" class="text-amber-400"> {{ t('worktrees.dirty', { n: worktree.git.dirtyCount }) }}</span>
<span v-else class="text-zinc-600">{{ t('worktrees.clean') }}</span>
</span>
</div>
<p class="truncate font-mono text-xs text-zinc-500" :title="worktree.path">{{ worktree.path }}</p>
<div class="mt-1 flex flex-wrap items-center gap-2">
<RouterLink
v-for="s in worktree.sessions"
:key="s.id"
:to="{ name: 'session', params: { id: s.id } }"
class="flex items-center gap-1 rounded bg-zinc-800/60 px-1.5 py-0.5 transition-colors hover:bg-zinc-800"
>
<SessionStateBadge :session="s" />
<span class="font-mono text-[11px] text-zinc-400">{{ s.command }}</span>
</RouterLink>
<span v-if="worktree.sessions.length === 0" class="text-xs text-zinc-600">{{ t('worktrees.noSession') }}</span>
<div v-if="!worktree.isMain" class="ml-auto flex items-center gap-2">
<template v-if="confirming">
<span v-if="error" class="text-xs text-amber-400">{{ error }}</span>
<button class="btn-danger text-xs" :disabled="busy" @click="onDelete">
{{ forceNeeded ? t('worktrees.forceDelete') : t('worktrees.confirmDelete') }}
</button>
<button class="btn text-xs" @click="reset">{{ t('common.cancel') }}</button>
</template>
<button v-else class="btn-danger text-xs" @click="confirming = true">{{ t('worktrees.delete') }}</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { WorktreeSummary } from '@arboretum/shared';
import { useWorktreesStore } from '../stores/worktrees';
import { ApiError } from '../lib/api';
import SessionStateBadge from './SessionStateBadge.vue';
const props = defineProps<{ worktree: WorktreeSummary }>();
const { t } = useI18n();
const store = useWorktreesStore();
const confirming = ref(false);
const forceNeeded = ref(false);
const error = ref<string | null>(null);
const busy = ref(false);
const branchLabel = computed(() =>
props.worktree.branch ?? (props.worktree.detached ? `${t('worktrees.detached')} ${props.worktree.head.slice(0, 7)}` : '—'),
);
function reset(): void {
confirming.value = false;
forceNeeded.value = false;
error.value = null;
}
async function onDelete(): Promise<void> {
busy.value = true;
error.value = null;
try {
await store.deleteWorktree(props.worktree.repoId, props.worktree.path, forceNeeded.value);
// succès : l'événement worktree_removed (ou le retrait local) démonte la carte
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
forceNeeded.value = true; // dirty ou session vivante → proposer la suppression forcée
error.value = err.message;
} else {
error.value = err instanceof Error ? err.message : String(err);
}
} finally {
busy.value = false;
}
}
</script>