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.
This commit is contained in:
93
packages/web/src/components/RepoSection.vue
Normal file
93
packages/web/src/components/RepoSection.vue
Normal 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>
|
||||
87
packages/web/src/components/WorktreeCard.vue
Normal file
87
packages/web/src/components/WorktreeCard.vue
Normal 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>
|
||||
Reference in New Issue
Block a user