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,93 @@
<template>
<section class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3">
<header class="flex items-center gap-2">
<h2 class="font-semibold text-zinc-100">{{ repo.label }}</h2>
<span v-if="!repo.valid" class="badge bg-red-950 text-red-400">{{ t('repos.invalid') }}</span>
<span class="truncate font-mono text-xs text-zinc-500" :title="repo.path">{{ repo.path }}</span>
<div class="ml-auto flex items-center gap-2">
<button class="btn text-xs" @click="creating = !creating">{{ t('worktrees.new') }}</button>
<button class="btn text-xs" :disabled="busy" @click="onPrune">{{ t('worktrees.prune') }}</button>
<button class="btn-danger text-xs" @click="onRemove">{{ t('repos.remove') }}</button>
</div>
</header>
<form v-if="creating" class="flex flex-wrap items-end gap-2 rounded border border-zinc-800 bg-zinc-950/40 p-2" @submit.prevent="onCreate">
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
{{ t('worktrees.branch') }}
<input v-model="branch" class="input font-mono" :placeholder="repo.defaultBranch ? `feature/…` : 'feature/…'" required />
</label>
<label class="flex flex-col gap-1 text-xs text-zinc-400">
{{ t('worktrees.start') }}
<select v-model="startSession" class="input">
<option :value="null">{{ t('worktrees.startNone') }}</option>
<option value="claude">claude</option>
<option value="bash">bash</option>
</select>
</label>
<label class="flex items-center gap-1 text-xs text-zinc-400"><input v-model="newBranch" type="checkbox" /> {{ t('worktrees.newBranch') }}</label>
<button type="submit" class="btn-primary text-xs" :disabled="busy || branch.trim() === ''">
{{ busy ? t('worktrees.creating') : t('worktrees.create') }}
</button>
</form>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
<div class="flex flex-col gap-1.5">
<WorktreeCard v-for="w in worktrees" :key="w.path" :worktree="w" />
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { RepoSummary } from '@arboretum/shared';
import { useWorktreesStore } from '../stores/worktrees';
import WorktreeCard from './WorktreeCard.vue';
const props = defineProps<{ repo: RepoSummary }>();
const { t } = useI18n();
const store = useWorktreesStore();
const worktrees = computed(() => store.worktreesForRepo(props.repo.id));
const creating = ref(false);
const branch = ref('');
const newBranch = ref(true);
const startSession = ref<'claude' | 'bash' | null>(null);
const busy = ref(false);
const error = ref<string | null>(null);
async function onCreate(): Promise<void> {
busy.value = true;
error.value = null;
try {
await store.createWorktree(props.repo.id, { branch: branch.value.trim(), newBranch: newBranch.value, startSession: startSession.value });
branch.value = '';
creating.value = false;
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
async function onPrune(): Promise<void> {
busy.value = true;
error.value = null;
try {
await store.prune(props.repo.id);
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
busy.value = false;
}
}
async function onRemove(): Promise<void> {
error.value = null;
try {
await store.removeRepo(props.repo.id);
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
}
}
</script>

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>

View File

@@ -48,6 +48,39 @@ export default {
waiting: 'waiting',
},
},
dashboard: {
title: 'Worktrees',
allSessions: 'All sessions',
},
repos: {
add: 'Add repo',
adding: 'Adding…',
remove: 'Remove',
pathLabel: 'Repository path',
pathPlaceholder: '/absolute/path/to/repo',
empty: 'No repository registered yet — add one above.',
invalid: 'unavailable',
},
worktrees: {
new: 'New worktree',
create: 'Create',
creating: 'Creating…',
branch: 'Branch',
newBranch: 'create branch',
start: 'Start',
startNone: 'no session',
delete: 'Delete',
confirmDelete: 'Confirm delete',
forceDelete: 'Force delete',
prune: 'Prune',
main: 'main',
detached: 'detached',
locked: 'locked',
prunable: 'prunable',
clean: 'clean',
dirty: '{n} changed',
noSession: 'no session',
},
terminal: {
observer: 'observer (read-only)',
sessionEnded: 'Session ended',

View File

@@ -50,6 +50,39 @@ const fr: typeof en = {
waiting: 'en attente',
},
},
dashboard: {
title: 'Worktrees',
allSessions: 'Toutes les sessions',
},
repos: {
add: 'Ajouter un repo',
adding: 'Ajout…',
remove: 'Retirer',
pathLabel: 'Chemin du dépôt',
pathPlaceholder: '/chemin/absolu/du/repo',
empty: 'Aucun dépôt enregistré — ajoutez-en un ci-dessus.',
invalid: 'indisponible',
},
worktrees: {
new: 'Nouveau worktree',
create: 'Créer',
creating: 'Création…',
branch: 'Branche',
newBranch: 'créer la branche',
start: 'Démarrer',
startNone: 'aucune session',
delete: 'Supprimer',
confirmDelete: 'Confirmer',
forceDelete: 'Forcer la suppression',
prune: 'Élaguer',
main: 'principal',
detached: 'détaché',
locked: 'verrouillé',
prunable: 'élaguable',
clean: 'propre',
dirty: '{n} modifié(s)',
noSession: 'aucune session',
},
terminal: {
observer: 'observateur (lecture seule)',
sessionEnded: 'Session terminée',

View File

@@ -27,6 +27,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 interface AttachOptions {
sessionId: string;
@@ -104,6 +105,7 @@ export class WsClient {
/** FIFO des attaches en attente : le serveur répond aux 'attach' dans l'ordre */
private awaitingAttached: Attachment[] = [];
private readonly sessionListeners = new Set<(e: SessionEvent) => void>();
private readonly worktreeListeners = new Set<(e: WorktreeEvent) => void>();
connect(): void {
this.stopped = false;
@@ -143,13 +145,35 @@ export class WsClient {
return promise;
}
/** topics actifs = union des abonnements courants (une seule connexion partagée). */
private activeTopics(): Array<'sessions' | 'worktrees'> {
const t: Array<'sessions' | 'worktrees'> = [];
if (this.sessionListeners.size > 0) t.push('sessions');
if (this.worktreeListeners.size > 0) t.push('worktrees');
return t;
}
private sendSub(): void {
this.sendControl({ type: 'sub', topics: this.activeTopics() });
}
subscribeSessions(listener: (e: SessionEvent) => void): () => void {
this.sessionListeners.add(listener);
this.connect();
if (this.sessionListeners.size === 1) this.sendControl({ type: 'sub', topics: ['sessions'] });
this.sendSub();
return () => {
this.sessionListeners.delete(listener);
if (this.sessionListeners.size === 0) this.sendControl({ type: 'sub', topics: [] });
this.sendSub();
};
}
subscribeWorktrees(listener: (e: WorktreeEvent) => void): () => void {
this.worktreeListeners.add(listener);
this.connect();
this.sendSub();
return () => {
this.worktreeListeners.delete(listener);
this.sendSub();
};
}
@@ -288,7 +312,7 @@ export class WsClient {
this.ready = true;
this.backoffMs = BACKOFF_MIN_MS;
this.status.value = 'open';
if (this.sessionListeners.size > 0) this.sendControl({ type: 'sub', topics: ['sessions'] });
this.sendSub();
// ré-attache des terminaux encore ouverts (nouveaux canaux côté serveur)
for (const att of this.attachments) {
if (!att.closed) this.sendAttach(att);
@@ -339,6 +363,13 @@ export class WsClient {
for (const cb of this.sessionListeners) cb(msg);
return;
}
case 'repo_update':
case 'repo_removed':
case 'worktree_update':
case 'worktree_removed': {
for (const cb of this.worktreeListeners) cb(msg);
return;
}
case 'error': {
if (msg.channel !== undefined) {
console.warn(`[ws] channel ${msg.channel}: ${msg.code}${msg.message}`);

View File

@@ -5,7 +5,8 @@ export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue') },
{ path: '/', name: 'sessions', component: () => import('../views/SessionsListView.vue') },
{ 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: '/:pathMatch(.*)*', redirect: '/' },
],
@@ -15,6 +16,6 @@ export const router = createRouter({
router.beforeEach(async (to) => {
const auth = useAuthStore();
if (auth.authenticated === null) await auth.check();
if (to.name === 'login') return auth.authenticated ? { name: 'sessions' } : true;
if (to.name === 'login') return auth.authenticated ? { name: 'dashboard' } : true;
return auth.authenticated ? true : { name: 'login', query: { redirect: to.fullPath } };
});

View File

@@ -0,0 +1,126 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import type {
CreateWorktreeRequest,
CreateWorktreeResponse,
RepoResponse,
ReposListResponse,
RepoSummary,
WorktreeSummary,
WorktreesListResponse,
} from '@arboretum/shared';
import { api } from '../lib/api';
import { wsClient, type WorktreeEvent } from '../lib/ws-client';
export const useWorktreesStore = defineStore('worktrees', () => {
const repos = ref<RepoSummary[]>([]);
const worktrees = ref<WorktreeSummary[]>([]);
const loading = ref(false);
const loadError = ref<string | null>(null);
let unsubscribe: (() => void) | null = null;
function upsertRepo(repo: RepoSummary): void {
const idx = repos.value.findIndex((r) => r.id === repo.id);
if (idx >= 0) repos.value.splice(idx, 1, repo);
else repos.value.push(repo);
}
function removeRepoLocal(id: string): void {
repos.value = repos.value.filter((r) => r.id !== id);
worktrees.value = worktrees.value.filter((w) => w.repoId !== id);
}
function upsertWorktree(wt: WorktreeSummary): void {
const idx = worktrees.value.findIndex((w) => w.path === wt.path);
if (idx >= 0) worktrees.value.splice(idx, 1, wt);
else worktrees.value.push(wt);
}
function removeWorktreeLocal(path: string): void {
worktrees.value = worktrees.value.filter((w) => w.path !== path);
}
function onEvent(e: WorktreeEvent): void {
if (e.type === 'repo_update') upsertRepo(e.repo);
else if (e.type === 'repo_removed') removeRepoLocal(e.repoId);
else if (e.type === 'worktree_update') upsertWorktree(e.worktree);
else removeWorktreeLocal(e.path);
}
function worktreesForRepo(repoId: string): WorktreeSummary[] {
return worktrees.value
.filter((w) => w.repoId === repoId)
.sort((a, b) => Number(b.isMain) - Number(a.isMain) || a.path.localeCompare(b.path));
}
async function fetchAll(): Promise<void> {
loading.value = true;
loadError.value = null;
try {
const [r, w] = await Promise.all([
api.get<ReposListResponse>('/api/v1/repos'),
api.get<WorktreesListResponse>('/api/v1/worktrees'),
]);
repos.value = r.repos;
worktrees.value = w.worktrees;
} catch (err) {
loadError.value = err instanceof Error ? err.message : String(err);
} finally {
loading.value = false;
}
}
async function refreshRepoWorktrees(repoId: string): Promise<void> {
const w = await api.get<WorktreesListResponse>(`/api/v1/repos/${repoId}/worktrees`);
for (const wt of w.worktrees) upsertWorktree(wt);
}
async function addRepo(path: string, label?: string): Promise<RepoSummary> {
const res = await api.post<RepoResponse>('/api/v1/repos', { path, ...(label ? { label } : {}) });
upsertRepo(res.repo);
await refreshRepoWorktrees(res.repo.id);
return res.repo;
}
async function removeRepo(id: string): Promise<void> {
await api.delete<{ ok: true }>(`/api/v1/repos/${id}`);
removeRepoLocal(id);
}
async function createWorktree(repoId: string, req: CreateWorktreeRequest): Promise<CreateWorktreeResponse> {
const res = await api.post<CreateWorktreeResponse>(`/api/v1/repos/${repoId}/worktrees`, req);
upsertWorktree(res.worktree);
return res;
}
async function deleteWorktree(repoId: string, path: string, force: boolean): Promise<void> {
await api.delete<{ ok: true }>(`/api/v1/repos/${repoId}/worktrees?path=${encodeURIComponent(path)}&force=${force}`);
removeWorktreeLocal(path);
}
async function prune(repoId: string): Promise<void> {
await api.post<{ ok: true }>(`/api/v1/repos/${repoId}/worktrees/prune`);
await refreshRepoWorktrees(repoId);
}
function startRealtime(): void {
unsubscribe ??= wsClient.subscribeWorktrees(onEvent);
}
function stopRealtime(): void {
unsubscribe?.();
unsubscribe = null;
}
return {
repos,
worktrees,
loading,
loadError,
worktreesForRepo,
fetchAll,
addRepo,
removeRepo,
createWorktree,
deleteWorktree,
prune,
startRealtime,
stopRealtime,
};
});

View File

@@ -0,0 +1,82 @@
<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('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: 'sessions' }" class="btn">{{ t('dashboard.allSessions') }}</RouterLink>
<button class="btn" :disabled="store.loading" @click="store.fetchAll()">{{ t('sessions.refresh') }}</button>
<LanguageSwitcher />
<button class="btn" @click="onLogout">{{ t('common.logout') }}</button>
</div>
</header>
<form class="flex flex-col gap-2 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 sm:flex-row sm:items-end" @submit.prevent="onAddRepo">
<label class="flex flex-1 flex-col gap-1 text-xs text-zinc-400">
{{ t('repos.pathLabel') }}
<input v-model="newPath" type="text" class="input font-mono" :placeholder="t('repos.pathPlaceholder')" required />
</label>
<button type="submit" class="btn-primary whitespace-nowrap" :disabled="adding || newPath.trim() === ''">
{{ adding ? t('repos.adding') : t('repos.add') }}
</button>
</form>
<p v-if="addError" class="text-sm text-red-400">{{ addError }}</p>
<p v-if="store.loadError" class="text-sm text-red-400">{{ store.loadError }}</p>
<p v-else-if="store.loading && store.repos.length === 0" class="text-sm text-zinc-500">{{ t('common.loading') }}</p>
<p v-else-if="store.repos.length === 0" class="text-sm text-zinc-500">{{ t('repos.empty') }}</p>
<RepoSection v-for="repo in store.repos" :key="repo.id" :repo="repo" />
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth';
import { useWorktreesStore } from '../stores/worktrees';
import { useSessionsStore } from '../stores/sessions';
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
import RepoSection from '../components/RepoSection.vue';
const { t } = useI18n();
const router = useRouter();
const auth = useAuthStore();
const store = useWorktreesStore();
const sessions = useSessionsStore();
const newPath = ref('');
const adding = ref(false);
const addError = ref<string | null>(null);
onMounted(() => {
void store.fetchAll();
store.startRealtime();
// les sessions alimentent la corrélation worktree↔session (cwd) côté serveur ; on suit aussi
// leurs mises à jour temps réel pour que les badges d'état se rafraîchissent.
sessions.startRealtime();
});
async function onAddRepo(): Promise<void> {
adding.value = true;
addError.value = null;
try {
await store.addRepo(newPath.value.trim());
newPath.value = '';
} catch (err) {
addError.value = err instanceof Error ? err.message : String(err);
} finally {
adding.value = false;
}
}
async function onLogout(): Promise<void> {
store.stopRealtime();
sessions.stopRealtime();
await auth.logout();
await router.replace({ name: 'login' });
}
</script>

View File

@@ -5,6 +5,7 @@
<h1 class="text-lg font-semibold text-zinc-100">{{ t('sessions.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: 'dashboard' }" class="btn">{{ t('dashboard.title') }}</RouterLink>
<button class="btn" :disabled="store.loading" @click="store.fetchSessions()">
{{ t('sessions.refresh') }}
</button>