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:
@@ -22,6 +22,7 @@ npm run pack # build + npm pack du tarball git-arboretum
|
|||||||
|
|
||||||
node packages/server/scripts/acceptance-p1.mjs # acceptation E2E P1 (daemon réel + client WS réel)
|
node packages/server/scripts/acceptance-p1.mjs # acceptation E2E P1 (daemon réel + client WS réel)
|
||||||
node packages/server/scripts/acceptance-p2.mjs # acceptation E2E P2 (découverte/reprise : faux ~/.claude + faux binaire claude)
|
node packages/server/scripts/acceptance-p2.mjs # acceptation E2E P2 (découverte/reprise : faux ~/.claude + faux binaire claude)
|
||||||
|
node packages/server/scripts/acceptance-p3.mjs # acceptation E2E P3 (worktrees : repo git tmp + hook + corrélation session)
|
||||||
```
|
```
|
||||||
|
|
||||||
L'acceptation exige `npm run build` au préalable (elle lance `dist/index.js`) et utilise la commande `bash` plutôt que `claude` pour ne pas consommer de quota.
|
L'acceptation exige `npm run build` au préalable (elle lance `dist/index.js`) et utilise la commande `bash` plutôt que `claude` pour ne pas consommer de quota.
|
||||||
|
|||||||
141
packages/server/scripts/acceptance-p3.mjs
Normal file
141
packages/server/scripts/acceptance-p3.mjs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Acceptation P3 (sans navigateur, sans quota Claude) : worktrees multi-repo + dashboard.
|
||||||
|
// Vrai daemon + vrai repo git tmp. Couvre : enregistrement repo, création worktree avec hook
|
||||||
|
// post-create, broadcast WS worktree_update, corrélation worktree↔session (bash), suppression.
|
||||||
|
import { spawn, execFileSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, dirname, basename } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
|
||||||
|
const PORT = 7543;
|
||||||
|
const ORIGIN = `http://127.0.0.1:${PORT}`;
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const results = [];
|
||||||
|
const check = (name, ok, detail = '') => {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p3-'));
|
||||||
|
const repo = join(tmp, 'demo-repo');
|
||||||
|
const wtPath = join(tmp, 'demo-repo-wt-feat');
|
||||||
|
execFileSync('mkdir', ['-p', repo]);
|
||||||
|
const git = (...args) => execFileSync('git', args, { cwd: repo, stdio: 'pipe' });
|
||||||
|
git('init', '-b', 'main');
|
||||||
|
git('config', 'user.email', 'test@arboretum.dev');
|
||||||
|
git('config', 'user.name', 'Test');
|
||||||
|
execFileSync('bash', ['-lc', 'echo "# demo" > README.md'], { cwd: repo });
|
||||||
|
git('add', '-A');
|
||||||
|
git('commit', '-m', 'init');
|
||||||
|
|
||||||
|
const srv = spawn(
|
||||||
|
'node',
|
||||||
|
[join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude')],
|
||||||
|
{ env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||||
|
);
|
||||||
|
let srvOut = '';
|
||||||
|
srv.stdout.on('data', (d) => (srvOut += d));
|
||||||
|
srv.stderr.on('data', (d) => (srvOut += d));
|
||||||
|
|
||||||
|
function wsClient(cookie) {
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${PORT}/ws`, { headers: { Origin: ORIGIN, Cookie: cookie } });
|
||||||
|
const state = { msgs: [] };
|
||||||
|
ws.on('message', (data, isBinary) => {
|
||||||
|
if (!isBinary) state.msgs.push(JSON.parse(String(data)));
|
||||||
|
});
|
||||||
|
const waitMsg = async (pred, timeout = 8000) => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
while (Date.now() - t0 < timeout) {
|
||||||
|
const m = state.msgs.find(pred);
|
||||||
|
if (m) return m;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return { ws, state, waitMsg, send: (m) => ws.send(JSON.stringify(m)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const j = (path, method, cookie, body) =>
|
||||||
|
fetch(`${ORIGIN}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { Origin: ORIGIN, Cookie: cookie, ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
||||||
|
...(body ? { body: JSON.stringify(body) } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sleep(1500);
|
||||||
|
const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0];
|
||||||
|
check('boot + token bootstrap', !!token);
|
||||||
|
|
||||||
|
const login = await fetch(`${ORIGIN}/api/v1/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Origin: ORIGIN },
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
});
|
||||||
|
const cookie = login.headers.get('set-cookie')?.split(';')[0] ?? '';
|
||||||
|
check('login → cookie', login.status === 200);
|
||||||
|
|
||||||
|
const c = wsClient(cookie);
|
||||||
|
await new Promise((res, rej) => (c.ws.on('open', res), c.ws.on('error', rej)));
|
||||||
|
c.send({ type: 'hello', protocol: 1 });
|
||||||
|
await c.waitMsg((m) => m.type === 'hello_ok');
|
||||||
|
c.send({ type: 'sub', topics: ['sessions', 'worktrees'] });
|
||||||
|
|
||||||
|
// Enregistrement du repo (avec un hook post-create).
|
||||||
|
const addRepo = await j('/api/v1/repos', 'POST', cookie, {
|
||||||
|
path: repo,
|
||||||
|
postCreateHooks: [{ id: 'h1', label: 'touch', run: 'touch hook-ok.txt', enabled: true }],
|
||||||
|
});
|
||||||
|
const repoSummary = (await addRepo.json()).repo;
|
||||||
|
check('POST /repos → 201 (repo valide)', addRepo.status === 201 && repoSummary?.valid === true);
|
||||||
|
|
||||||
|
const wlist0 = await (await j('/api/v1/worktrees', 'GET', cookie)).json();
|
||||||
|
check('GET /worktrees → main worktree présent', wlist0.worktrees.some((w) => w.isMain && w.branch === 'main'));
|
||||||
|
|
||||||
|
// Création d'un worktree + hook + session bash.
|
||||||
|
const created = await j(`/api/v1/repos/${repoSummary.id}/worktrees`, 'POST', cookie, {
|
||||||
|
branch: 'feat',
|
||||||
|
newBranch: true,
|
||||||
|
runHooks: true,
|
||||||
|
startSession: 'bash',
|
||||||
|
});
|
||||||
|
const createdBody = await created.json();
|
||||||
|
check('POST worktree → 201', created.status === 201 && createdBody.worktree?.branch === 'feat');
|
||||||
|
check('hook post-create exécuté', createdBody.hookResults?.[0]?.exitCode === 0 && existsSync(join(wtPath, 'hook-ok.txt')));
|
||||||
|
check('startSession bash → session managée', createdBody.session?.command === 'bash');
|
||||||
|
|
||||||
|
const pushed = await c.waitMsg((m) => m.type === 'worktree_update' && m.worktree?.branch === 'feat');
|
||||||
|
check('broadcast WS worktree_update', !!pushed);
|
||||||
|
|
||||||
|
// Corrélation worktree ↔ session par cwd.
|
||||||
|
await sleep(300);
|
||||||
|
const wlist1 = await (await j('/api/v1/worktrees', 'GET', cookie)).json();
|
||||||
|
const feat = wlist1.worktrees.find((w) => w.branch === 'feat');
|
||||||
|
check('corrélation worktree ↔ session (cwd)', feat?.sessions?.some((s) => s.command === 'bash' && s.live));
|
||||||
|
|
||||||
|
// Suppression forcée (une session vit dans le worktree).
|
||||||
|
const refused = await j(`/api/v1/repos/${repoSummary.id}/worktrees?path=${encodeURIComponent(wtPath)}&force=false`, 'DELETE', cookie);
|
||||||
|
check('delete sans force (session live) → 409', refused.status === 409);
|
||||||
|
const del = await j(`/api/v1/repos/${repoSummary.id}/worktrees?path=${encodeURIComponent(wtPath)}&force=true`, 'DELETE', cookie);
|
||||||
|
check('delete force → 200', del.status === 200);
|
||||||
|
const removed = await c.waitMsg((m) => m.type === 'worktree_removed', 8000);
|
||||||
|
check('broadcast WS worktree_removed', !!removed);
|
||||||
|
|
||||||
|
c.ws.close();
|
||||||
|
} catch (err) {
|
||||||
|
check('exception', false, String(err));
|
||||||
|
} finally {
|
||||||
|
srv.kill('SIGTERM');
|
||||||
|
await sleep(1500);
|
||||||
|
check('arrêt propre du daemon (SIGTERM)', srv.exitCode === 0 || srv.signalCode === null || srv.exitCode === null);
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
const failed = results.filter((r) => !r.ok);
|
||||||
|
console.log(failed.length === 0 ? '\nACCEPTANCE P3: ALL GREEN' : `\nACCEPTANCE P3: ${failed.length} FAILURE(S)`);
|
||||||
|
process.exit(failed.length === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
// Reconstruction d'écran via @xterm/headless : terminal headless PERSISTANT par session, alimenté
|
// Reconstruction d'écran via @xterm/headless : terminal headless PERSISTANT par session, alimenté
|
||||||
// incrémentalement par le flux PTY. Remplace le strip ANSI naïf (qui « mange les espaces » et casse
|
// incrémentalement par le flux PTY. Remplace le strip ANSI naïf (qui « mange les espaces » et casse
|
||||||
// la détection des dialogues — verdict S1/S3). Aucune dépendance DOM (usage Node).
|
// la détection des dialogues — verdict S1/S3). Aucune dépendance DOM (usage Node).
|
||||||
import { Terminal } from '@xterm/headless';
|
import { createRequire } from 'node:module';
|
||||||
|
import type { Terminal as XtermTerminal } from '@xterm/headless';
|
||||||
|
|
||||||
|
// @xterm/headless est publié en CommonJS : sous Node ESM natif l'import nommé échoue
|
||||||
|
// (cjs-module-lexer ne détecte pas l'export). On charge via require, typé par l'import type.
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { Terminal } = require('@xterm/headless') as { Terminal: typeof XtermTerminal };
|
||||||
|
|
||||||
export class ScreenReader {
|
export class ScreenReader {
|
||||||
private readonly term: Terminal;
|
private readonly term: XtermTerminal;
|
||||||
|
|
||||||
constructor(cols = 120, rows = 40) {
|
constructor(cols = 120, rows = 40) {
|
||||||
this.term = new Terminal({ cols, rows, scrollback: 0, allowProposedApi: true });
|
this.term = new Terminal({ cols, rows, scrollback: 0, allowProposedApi: true });
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
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>
|
||||||
@@ -48,6 +48,39 @@ export default {
|
|||||||
waiting: 'waiting',
|
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: {
|
terminal: {
|
||||||
observer: 'observer (read-only)',
|
observer: 'observer (read-only)',
|
||||||
sessionEnded: 'Session ended',
|
sessionEnded: 'Session ended',
|
||||||
|
|||||||
@@ -50,6 +50,39 @@ const fr: typeof en = {
|
|||||||
waiting: 'en attente',
|
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: {
|
terminal: {
|
||||||
observer: 'observateur (lecture seule)',
|
observer: 'observateur (lecture seule)',
|
||||||
sessionEnded: 'Session terminée',
|
sessionEnded: 'Session terminée',
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface TerminalSink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type SessionEvent = Extract<ServerMessage, { type: 'session_update' | 'session_exit' }>;
|
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 {
|
export interface AttachOptions {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -104,6 +105,7 @@ export class WsClient {
|
|||||||
/** FIFO des attaches en attente : le serveur répond aux 'attach' dans l'ordre */
|
/** FIFO des attaches en attente : le serveur répond aux 'attach' dans l'ordre */
|
||||||
private awaitingAttached: Attachment[] = [];
|
private awaitingAttached: Attachment[] = [];
|
||||||
private readonly sessionListeners = new Set<(e: SessionEvent) => void>();
|
private readonly sessionListeners = new Set<(e: SessionEvent) => void>();
|
||||||
|
private readonly worktreeListeners = new Set<(e: WorktreeEvent) => void>();
|
||||||
|
|
||||||
connect(): void {
|
connect(): void {
|
||||||
this.stopped = false;
|
this.stopped = false;
|
||||||
@@ -143,13 +145,35 @@ export class WsClient {
|
|||||||
return promise;
|
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 {
|
subscribeSessions(listener: (e: SessionEvent) => void): () => void {
|
||||||
this.sessionListeners.add(listener);
|
this.sessionListeners.add(listener);
|
||||||
this.connect();
|
this.connect();
|
||||||
if (this.sessionListeners.size === 1) this.sendControl({ type: 'sub', topics: ['sessions'] });
|
this.sendSub();
|
||||||
return () => {
|
return () => {
|
||||||
this.sessionListeners.delete(listener);
|
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.ready = true;
|
||||||
this.backoffMs = BACKOFF_MIN_MS;
|
this.backoffMs = BACKOFF_MIN_MS;
|
||||||
this.status.value = 'open';
|
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)
|
// ré-attache des terminaux encore ouverts (nouveaux canaux côté serveur)
|
||||||
for (const att of this.attachments) {
|
for (const att of this.attachments) {
|
||||||
if (!att.closed) this.sendAttach(att);
|
if (!att.closed) this.sendAttach(att);
|
||||||
@@ -339,6 +363,13 @@ export class WsClient {
|
|||||||
for (const cb of this.sessionListeners) cb(msg);
|
for (const cb of this.sessionListeners) cb(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'repo_update':
|
||||||
|
case 'repo_removed':
|
||||||
|
case 'worktree_update':
|
||||||
|
case 'worktree_removed': {
|
||||||
|
for (const cb of this.worktreeListeners) cb(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
case 'error': {
|
case 'error': {
|
||||||
if (msg.channel !== undefined) {
|
if (msg.channel !== undefined) {
|
||||||
console.warn(`[ws] channel ${msg.channel}: ${msg.code} — ${msg.message}`);
|
console.warn(`[ws] channel ${msg.channel}: ${msg.code} — ${msg.message}`);
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ export const router = createRouter({
|
|||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue') },
|
{ 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: '/sessions/:id', name: 'session', component: () => import('../views/SessionView.vue') },
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||||
],
|
],
|
||||||
@@ -15,6 +16,6 @@ export const router = createRouter({
|
|||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
if (auth.authenticated === null) await auth.check();
|
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 } };
|
return auth.authenticated ? true : { name: 'login', query: { redirect: to.fullPath } };
|
||||||
});
|
});
|
||||||
|
|||||||
126
packages/web/src/stores/worktrees.ts
Normal file
126
packages/web/src/stores/worktrees.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
82
packages/web/src/views/DashboardView.vue
Normal file
82
packages/web/src/views/DashboardView.vue
Normal 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>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<h1 class="text-lg font-semibold text-zinc-100">{{ t('sessions.title') }}</h1>
|
<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>
|
<span v-if="auth.serverVersion" class="text-xs text-zinc-600">v{{ auth.serverVersion }}</span>
|
||||||
<div class="ml-auto flex items-center gap-2">
|
<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()">
|
<button class="btn" :disabled="store.loading" @click="store.fetchSessions()">
|
||||||
{{ t('sessions.refresh') }}
|
{{ t('sessions.refresh') }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user