fix(web): les fichiers s'ouvrent enfin, et le travail git par projet au centre

Deux défauts d'usage de la zone centrale de l'IDE.

1. Ouvrir un fichier affichait une page vide. Le conteneur de l'unique instance
   Monaco vivait sous le `v-if="activeTab"` : monté sans onglet actif (premier
   usage, ou après avoir tout fermé), `onMounted` sortait en silence sur
   `!host.value`, `ready` restait faux pour toujours, et les deux watchers
   d'affichage étaient gardés par lui. Le conteneur est désormais toujours monté
   (v-show, jamais v-if : sous un v-if, fermer le dernier onglet détruisait le
   DOM et l'éditeur gardait une référence sur un noeud détaché) et l'éditeur est
   créé paresseusement depuis `showTab`, devenu l'unique entonnoir d'affichage.
   `useMonaco` ne mémoïse plus un échec, l'attente et l'erreur sont visibles, et
   la relecture disque ne se tait plus. Sur mobile, ouvrir un fichier amène enfin
   la zone centrale au premier plan et la valeur morte `mobilePanel = 'sidebar'`
   disparaît (migrée).

   Trouvé par l'E2E : seul l'`editor.worker` était fourni, donc ouvrir un `.ts`
   lançait le service TypeScript contre un worker qui n'en connaissait pas les
   méthodes, et jetait un rejet non géré à chaque ouverture. Les workers de
   langage sont fournis (chunks à la demande), diagnostics sémantiques coupés
   (sans tsconfig ni node_modules, ils inventeraient des erreurs).

2. Le statut git était à l'étroit et mono-projet. La zone centrale porte un mode
   « Fichiers / Changements » (bascule à icônes dans la barre d'onglets, donc
   accessible même sans onglet ouvert). La vue Changements montre un bloc par
   worktree pour TOUS les projets ouverts : fichiers indexés et non indexés, diff
   déplié sur place, commit, push, fetch, pull et historique par bloc. Le panneau
   Git de la barre latérale devient l'index (compteurs déjà diffusés par WS, donc
   zéro requête) et le badge de la barre d'activité, codé à 0, compte enfin les
   worktrees à traiter.

   Au passage : `canPush` était faux dans les deux sens (actif sans rien à
   pousser, bloqué en étant seulement en retard), l'amend d'un message seul est
   accepté comme le serveur le permettait déjà, le rebase est proposé dès qu'on
   est en retard, une confirmation d'abandon ne survit plus au rechargement de la
   liste, et `commit`/`fetch`/`push` émettent enfin `worktree_changes` (le
   compteur « n non poussés » restait périmé juste après un push).

Vérifié : 528 tests, acceptations P7/P9/P14/P15, `verify-ui` (4 captures
nouvelles) et un `verify-editor` qui prouve par clics réels l'ouverture d'un
fichier sans onglet persisté, la fermeture/réouverture, la bascule mobile et le
chunk éditeur absent. Ce dernier échoue bien si l'on remet le conteneur sous un
v-if.
This commit is contained in:
2026-08-04 23:18:40 +02:00
parent 114fbc8ba0
commit ce224fd470
34 changed files with 2026 additions and 226 deletions
+34
View File
@@ -3,6 +3,40 @@
Notable changes to `@johanleroy/git-arboretum` (the Arboretum daemon). The VS Code
extension keeps its own changelog in `packages/vscode/CHANGELOG.md`.
## 3.6.0
Files open again, and uncommitted work gets a real surface: one block per project, in the centre of
the IDE. Fully additive, no protocol version bump.
- **Opening a file showed an empty page.** The container of the single Monaco instance lived inside a
`v-if`, so mounting the IDE with no open tab (first use, or after closing everything) bailed out
silently and the editor was never created: the first file you opened rendered nothing, with no
spinner and no error. The container is now always mounted and the editor is created on demand.
Closing the last tab no longer destroys it either.
- **The editor now says when it cannot load.** A missing editor chunk (a stale `index.html` after an
update, a network drop) used to be memoized as a permanent failure, silently, for the whole
session. It reports the failure, offers Retry and Reload page, and shows a loading state while the
chunk and the file are fetched. A file that disappears under the editor is reported instead of
being swallowed.
- **No more console errors when opening TypeScript.** Only the base worker was provided, so the
TypeScript language service kept calling a worker that did not implement its methods, throwing on
every single file open. The language workers are shipped now (loaded on demand). Semantic
diagnostics stay off on purpose: with no tsconfig and no node_modules, they would invent errors.
- **Changes: your uncommitted work, per project, in the centre.** Two buttons on the right of the tab
bar switch the centre between Files and Changes. Changes shows one block per worktree across every
open project, each with its staged and unstaged files, its diff unfolded in place, its commit,
push, fetch and pull, and its own history. The Git panel in the sidebar becomes the index: what
each project is worth at a glance, one click to open the matching block. The activity bar badge
finally counts the worktrees that need attention.
- **Push and commit buttons tell the truth.** Push was enabled with nothing to push and disabled when
merely behind; it now follows what git will actually do. Amending just a message is allowed, as the
server already did. A rebase is offered as soon as the branch is behind, not only after a
fast-forward fails.
- **Commit, fetch and push refresh what they change.** They only broadcast a worktree update, so the
"n unpushed" counter stayed stale right after a push, and files just committed were still listed.
- On mobile, tapping a file in the explorer now brings the editor to the front, and the Git tab opens
the Changes view (the sidebar index is a desktop affordance).
## 3.5.1
Completes the terminal copy & paste of 3.5.0, which only worked in a browser.
+403
View File
@@ -0,0 +1,403 @@
#!/usr/bin/env node
// Vérification E2E de la ZONE CENTRALE de l'IDE, par interaction réelle (pas des captures) : daemon
// temporaire isolé + Chromium headless piloté en CDP + cookie de session injecté. On clique dans
// l'arbre comme un utilisateur, puis on lit le DOM de Monaco.
//
// Ce que ce script prouve, et qu'aucune capture ne prouvait :
// (a) ouvrir un fichier alors qu'AUCUN onglet n'est persisté affiche réellement son contenu. C'est
// le cas nominal qui restait vide et muet : le conteneur de Monaco vivait sous un `v-if`, donc
// l'éditeur n'était jamais créé et aucun watcher ne retentait ;
// (b) fermer le dernier onglet puis réouvrir un fichier réaffiche le contenu (le conteneur ne doit
// pas être détruit, sinon l'éditeur pointe sur un noeud détaché) ;
// (c) sur mobile, toucher un fichier amène la zone centrale au premier plan (l'arbre et l'éditeur
// étant mutuellement exclusifs sous 768 px, un contenu visible EST la preuve de la bascule) ;
// (d) chunk de l'éditeur introuvable : un message et des actions de récupération, pas une zone vide ;
// (e) la bascule Fichiers / Changements liste bien les fichiers modifiés et permet de committer.
//
// Usage : node packages/server/scripts/verify-editor.mjs
// Prérequis : `npm run build` puis `node packages/server/scripts/copy-web.mjs`.
import { spawn, execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } 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 = 7412;
const CDP_PORT = 9335;
const ORIGIN = `http://127.0.0.1:${PORT}`;
const MARKER = 'ARB_EDITOR_RENDERED_4242';
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const results = [];
const check = (name, ok, detail = '') => {
results.push({ name, ok, detail });
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `: ${detail}` : ''}`);
};
function findChromium() {
for (const bin of ['chromium', 'chromium-browser', 'google-chrome', 'google-chrome-stable']) {
try {
return execFileSync('which', [bin]).toString().trim();
} catch {
/* essai suivant */
}
}
return null;
}
/** Client CDP minimal : un seul socket, corrélation par id, sessionId pour la cible attachée. */
function cdp(url) {
const ws = new WebSocket(url, { perMessageDeflate: false, maxPayload: 256 * 1024 * 1024 });
let nextId = 1;
const pending = new Map();
const events = [];
ws.on('message', (raw) => {
const msg = JSON.parse(String(raw));
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
return;
}
if (msg.method) events.push(msg);
});
const ready = new Promise((res, rej) => (ws.on('open', res), ws.on('error', rej)));
const send = (method, params = {}, sessionId) =>
new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
setTimeout(() => pending.has(id) && (pending.delete(id), reject(new Error(`CDP timeout: ${method}`))), 30_000);
});
return { ws, ready, send, events };
}
const tmp = mkdtempSync(join(tmpdir(), 'arb-verify-editor-'));
let srv = null;
let browser = null;
try {
const publicIndex = join(serverDir, 'public', 'index.html');
check('SPA copiée dans packages/server/public', existsSync(publicIndex), publicIndex);
// --- dépôt de démonstration : un fichier sonde EN PREMIÈRE LIGNE (Monaco virtualise le rendu) ---
const repo = join(tmp, 'demo-repo');
mkdirSync(repo, { recursive: true });
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');
writeFileSync(join(repo, 'README.md'), '# demo\n');
writeFileSync(join(repo, 'probe.ts'), `export const probe = '${MARKER}';\n`);
git('add', '-A');
git('commit', '-m', 'commit initial');
// du travail non commité, pour la vue Changements
writeFileSync(join(repo, 'dirty.txt'), 'travail en cours\n');
srv = spawn(
'node',
[join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 't.db'), '--claude-home', join(tmp, 'claude'), '--no-discover'],
{ env: { ...process.env, XDG_DATA_HOME: join(tmp, 'xdg'), ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] },
);
let srvOut = '';
srv.stdout.on('data', (d) => (srvOut += d));
srv.stderr.on('data', (d) => (srvOut += d));
for (let i = 0; i < 60 && !/arb_[0-9a-f]{16,}/.test(srvOut); i++) await sleep(150);
const token = /arb_[0-9a-f]{16,}/.exec(srvOut)?.[0];
check('daemon temporaire démarré + token', !!token);
const login = await fetch(`${ORIGIN}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: ORIGIN },
body: JSON.stringify({ token }),
});
const sessionCookie = (login.headers.getSetCookie?.() ?? []).map((c) => c.split(';')[0]).find((c) => c.startsWith('arb_session='));
check('login → cookie de session', !!sessionCookie);
const cookieValue = sessionCookie?.slice('arb_session='.length) ?? '';
const j = (path, method, body) =>
fetch(`${ORIGIN}${path}`, {
method,
headers: { Origin: ORIGIN, Cookie: sessionCookie ?? '', ...(body ? { 'Content-Type': 'application/json' } : {}) },
...(body ? { body: JSON.stringify(body) } : {}),
});
const repoRes = await (await j('/api/v1/repos', 'POST', { path: repo })).json();
const repoId = repoRes.repo?.id;
const repoLabel = repoRes.repo?.label;
check('dépôt de démonstration enregistré', !!repoId, repoLabel ?? '');
const chromeBin = findChromium();
check('Chromium disponible', !!chromeBin, chromeBin ?? 'introuvable');
if (!chromeBin) throw new Error('Chromium introuvable : impossible de vérifier le rendu');
browser = spawn(
chromeBin,
[
'--headless=new',
`--remote-debugging-port=${CDP_PORT}`,
`--user-data-dir=${join(tmp, 'chrome')}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-gpu',
'--hide-scrollbars',
],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
let wsUrl = null;
for (let i = 0; i < 80 && !wsUrl; i++) {
await sleep(200);
try {
wsUrl = (await (await fetch(`http://127.0.0.1:${CDP_PORT}/json/version`)).json()).webSocketDebuggerUrl;
} catch {
/* pas encore prêt */
}
}
check('Chromium en écoute CDP', !!wsUrl);
const client = cdp(wsUrl);
await client.ready;
// localStorage est partagé par les cibles d'un même profil : on l'efface pour chaque scénario.
// AUCUN onglet persisté : c'est précisément le cas qui restait vide. `arboretum.locale` n'est pas
// un persistedRef (chaîne brute, pas de JSON).
const seed = (extra = '') =>
`localStorage.clear();localStorage.setItem('arb.theme', '"dark"');localStorage.setItem('arboretum.locale', 'en');${extra}`;
/** Ouvre une cible isolée, authentifiée, avec un état de vue amorcé. */
async function openTarget({ width = 1440, height = 900, extraSeed = '', blocked = [] } = {}) {
const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' });
const { sessionId } = await client.send('Target.attachToTarget', { targetId, flatten: true });
await client.send('Runtime.enable', {}, sessionId);
await client.send('Log.enable', {}, sessionId);
await client.send('Network.enable', {}, sessionId);
await client.send('Page.enable', {}, sessionId);
await client.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 500 }, sessionId);
await client.send('Network.setCookie', { name: 'arb_session', value: cookieValue, domain: '127.0.0.1', path: '/', httpOnly: true }, sessionId);
if (blocked.length > 0) await client.send('Network.setBlockedURLs', { urls: blocked }, sessionId);
await client.send('Page.addScriptToEvaluateOnNewDocument', { source: seed(extraSeed) }, sessionId);
const before = client.events.length;
await client.send('Page.navigate', { url: `${ORIGIN}/ide` }, sessionId);
return { targetId, sessionId, before };
}
const evaluate = async (expression, sessionId) =>
(await client.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId)).result?.value;
/** Attend qu'une condition devienne vraie (jamais de sleep fixe sur un rendu asynchrone). */
async function waitFor(fn, tries = 60, delay = 250) {
for (let i = 0; i < tries; i++) {
if (await fn()) return true;
await sleep(delay);
}
return false;
}
// Clic sur une ligne par son libellé exact : les libellés vivent dans un <span> à l'intérieur du
// <button> de la ligne (arbre de projets comme arbre de fichiers).
const clickRow = (label, sessionId) =>
evaluate(
`(() => {
const span = [...document.querySelectorAll('button span')].find((s) => s.textContent.trim() === ${JSON.stringify(label)});
const btn = span?.closest('button');
if (!btn) return false;
btn.click();
return true;
})()`,
sessionId,
);
// Monaco VIRTUALISE : seules les lignes visibles existent dans le DOM, et les espaces sortent en
// U+00A0. La sonde est donc en première ligne, et on normalise avant comparaison.
const editorText = async (sessionId) =>
(await evaluate(
`(() => {
const lines = document.querySelector('.monaco-editor .view-lines');
return lines ? lines.textContent.replace(/\\u00a0/g, ' ') : '';
})()`,
sessionId,
)) ?? '';
const editorBox = async (sessionId) =>
JSON.parse(
(await evaluate(
`JSON.stringify((() => {
const el = document.querySelector('.monaco-editor');
if (!el) return null;
const r = el.getBoundingClientRect();
return { w: Math.round(r.width), h: Math.round(r.height) };
})())`,
sessionId,
)) ?? 'null',
);
const describeError = (e) => {
const d = e.params?.exceptionDetails;
if (d) {
const ex = d.exception ?? {};
const where = d.url ? ` @${d.url}:${d.lineNumber ?? '?'}` : '';
return `${d.text ?? ''} ${ex.description ?? ex.value ?? ''}${where}`.trim();
}
return (e.params?.args ?? []).map((a) => a.description ?? a.value ?? a.type).join(' ');
};
const consoleErrors = (sessionId, before) =>
client.events
.slice(before)
.filter((e) => e.sessionId === sessionId)
.filter((e) => (e.method === 'Runtime.consoleAPICalled' && e.params?.type === 'error') || e.method === 'Runtime.exceptionThrown')
.map(describeError)
.filter((m) => m && !/favicon|manifest\.webmanifest/i.test(m));
/** Déroule l'arbre jusqu'au fichier sonde : dépôt, puis branche, puis fichier. */
async function openProbeFile(sessionId) {
if (!(await waitFor(() => clickRow(repoLabel, sessionId)))) return false;
if (!(await waitFor(() => clickRow('main', sessionId)))) return false;
return waitFor(() => clickRow('probe.ts', sessionId));
}
// --- (a) desktop, aucun onglet persisté : le cas nominal qui restait vide ---
{
const { targetId, sessionId, before } = await openTarget();
const treeReady = await waitFor(async () => !!(await clickRow(repoLabel, sessionId)));
check('a · arbre de projets rendu', treeReady);
// L'éditeur ne doit pas exister avant qu'un fichier soit demandé (chargement paresseux préservé).
check('a · Monaco non chargé avant ouverture d un fichier', (await editorBox(sessionId)) === null);
check('a · navigation jusqu au fichier', await waitFor(() => clickRow('main', sessionId)) && (await waitFor(() => clickRow('probe.ts', sessionId))));
const shown = await waitFor(async () => (await editorText(sessionId)).includes(MARKER));
check('a · le contenu du fichier est réellement rendu', shown, shown ? '' : await editorText(sessionId));
const box = await editorBox(sessionId);
check('a · l éditeur occupe une surface réelle', !!box && box.w > 200 && box.h > 100, JSON.stringify(box));
check('a · la gouttière de numéros est rendue', !!(await evaluate("!!document.querySelector('.margin-view-overlays .line-numbers')", sessionId)));
const errs = consoleErrors(sessionId, before);
check('a · aucune erreur console', errs.length === 0, errs.slice(0, 3).join(' | '));
await client.send('Target.closeTarget', { targetId });
}
// --- (b) fermer le dernier onglet, puis réouvrir : le conteneur ne doit pas avoir été détruit ---
{
const { targetId, sessionId, before } = await openTarget();
check('b · fichier ouvert une première fois', (await openProbeFile(sessionId)) && (await waitFor(async () => (await editorText(sessionId)).includes(MARKER))));
const closed = await evaluate(
`(() => {
const tab = [...document.querySelectorAll('[title="probe.ts"]')].find((e) => e.querySelector('button'));
const btn = tab?.querySelector('button');
if (!btn) return false;
btn.click();
return true;
})()`,
sessionId,
);
check('b · dernier onglet fermé', !!closed);
const tabsLeft = await evaluate("JSON.parse(localStorage.getItem('arb.ide.tabs') ?? '[]').length", sessionId);
check('b · plus aucun onglet', tabsLeft === 0, `restants: ${tabsLeft}`);
// Le point à prouver : le CONTENEUR survit (v-show, pas v-if). Sans modèle, Monaco détruit sa
// vue de lui-même (`.monaco-editor` disparaît) et la recrée au prochain setModel : c'est normal.
// Ce qui ne doit jamais disparaître, c'est le conteneur, sinon l'instance garde une référence sur
// un noeud détaché et la zone reste définitivement vide.
const hostAlive = await waitFor(
async () => (await evaluate("!!document.querySelector('[data-editor-host]')", sessionId)) === true,
12,
);
check('b · le conteneur de l éditeur survit à la fermeture', hostAlive);
check(
'b · état vide affiché',
await waitFor(async () => String(await evaluate('document.body.innerText', sessionId)).includes('No file open'), 12),
);
check('b · fichier réouvert', await waitFor(() => clickRow('probe.ts', sessionId)));
check('b · le contenu revient après réouverture', await waitFor(async () => (await editorText(sessionId)).includes(MARKER)));
const errs = consoleErrors(sessionId, before);
check('b · aucune erreur console', errs.length === 0, errs.slice(0, 3).join(' | '));
await client.send('Target.closeTarget', { targetId });
}
// --- (c) mobile : l'arbre et l'éditeur sont mutuellement exclusifs, donc un contenu visible
// prouve que l'ouverture d'un fichier a bien amené la zone centrale au premier plan ---
{
const { targetId, sessionId } = await openTarget({ width: 390, height: 844 });
check('c · fichier ouvert depuis l explorateur mobile', await openProbeFile(sessionId));
check('c · le contenu s affiche (le panneau a basculé)', await waitFor(async () => (await editorText(sessionId)).includes(MARKER)));
const treeGone = await evaluate(
`![...document.querySelectorAll('button span')].some((s) => s.textContent.trim() === 'probe.ts')`,
sessionId,
);
check('c · l explorateur a laissé la place à l éditeur', !!treeGone);
await client.send('Target.closeTarget', { targetId });
}
// --- (d) chunk de l'éditeur introuvable : message + actions, puis récupération au rechargement ---
// On bloque UNIQUEMENT le module d'import dynamique. Surtout pas *vendor-monaco* : le chunk
// d'entrée l'importe statiquement, le bloquer tuerait toute la SPA.
{
const { targetId, sessionId } = await openTarget({ blocked: ['*monaco-setup*'] });
check('d · fichier demandé malgré le chunk bloqué', await openProbeFile(sessionId));
const said = await waitFor(async () =>
String(await evaluate('document.body.innerText', sessionId)).includes('The code editor could not be loaded'),
);
check('d · l échec est annoncé au lieu d une zone vide', said);
const actions = await evaluate(
`(() => {
const labels = [...document.querySelectorAll('button')].map((b) => b.textContent.trim());
return labels.includes('Retry') && labels.includes('Reload page');
})()`,
sessionId,
);
check('d · des actions de récupération sont proposées', !!actions);
await client.send('Network.setBlockedURLs', { urls: [] }, sessionId);
await client.send('Page.reload', {}, sessionId);
check('d · après rechargement, le fichier s ouvre', (await openProbeFile(sessionId)) && (await waitFor(async () => (await editorText(sessionId)).includes(MARKER))));
await client.send('Target.closeTarget', { targetId });
}
// --- (e) bascule Fichiers / Changements : la liste des fichiers non commités et le commit ---
{
const { targetId, sessionId, before } = await openTarget({
extraSeed:
`localStorage.setItem('arb.ide.centerMode', '"changes"');` +
`localStorage.setItem('arb.ide.expandedRepos', ${JSON.stringify(JSON.stringify([repoId]))});` +
`localStorage.setItem('arb.ide.changesExpanded', ${JSON.stringify(JSON.stringify([`${repoId}\0${repo}`]))});`,
});
const listed = await waitFor(async () => String(await evaluate('document.body.innerText', sessionId)).includes('dirty.txt'));
check('e · la vue Changements liste le fichier non commité', listed);
const hasCommit = await evaluate(
`[...document.querySelectorAll('button')].some((b) => b.textContent.trim().startsWith('Commit'))`,
sessionId,
);
check('e · le panneau de commit est présent dans le bloc', !!hasCommit);
// La bascule de mode vit dans la barre d'onglets, donc elle est là même sans aucun onglet ouvert.
const switched = await evaluate(
`(() => {
const btn = [...document.querySelectorAll('button[title]')].find((b) => b.getAttribute('title') === 'Files');
if (!btn) return false;
btn.click();
return true;
})()`,
sessionId,
);
check('e · la bascule Fichiers est accessible sans onglet ouvert', !!switched);
check(
'e · retour au mode Fichiers',
await waitFor(async () => String(await evaluate('document.body.innerText', sessionId)).includes('No file open')),
);
const errs = consoleErrors(sessionId, before);
check('e · aucune erreur console', errs.length === 0, errs.slice(0, 3).join(' | '));
await client.send('Target.closeTarget', { targetId });
}
client.ws.close();
} catch (err) {
check('exception', false, String(err));
} finally {
browser?.kill('SIGTERM');
srv?.kill('SIGTERM');
await sleep(1200);
rmSync(tmp, { recursive: true, force: true });
const failed = results.filter((r) => !r.ok);
console.log(failed.length === 0 ? '\nVERIFY EDITOR: ALL GREEN' : `\nVERIFY EDITOR: ${failed.length} FAILURE(S)`);
process.exit(failed.length === 0 ? 0 : 1);
}
+36 -4
View File
@@ -130,6 +130,11 @@ try {
check('groupe de démonstration créé', !!groupRes.group?.id);
const sess = await (await j('/api/v1/sessions', 'POST', { cwd: repo, command: 'bash' })).json();
check('session bash de démonstration', !!sess.session?.id);
// Un fichier indexé et un non indexé dans le checkout principal : la vue Changements doit montrer
// ses DEUX sections (« Staged » et « Changes »), sinon la capture ne prouve que la moitié.
writeFileSync(join(repo, 'notes.md'), 'brouillon\n');
const staged = await j(`/api/v1/repos/${repoId}/worktrees/stage`, 'POST', { path: repo, files: ['src/app.ts'] });
check('un fichier indexé pour la démonstration', staged.ok);
// --- Chromium headless en CDP ---
const chromeBin = findChromium();
@@ -167,7 +172,24 @@ try {
const expanded = JSON.stringify(JSON.stringify([repoId]));
const context = JSON.stringify(JSON.stringify({ repoId, wtPath: repo }));
const seedExplorer = `localStorage.setItem('arb.ide.expandedRepos', ${expanded});localStorage.setItem('arb.ide.context', ${context});`;
const seedGit = `${seedExplorer}localStorage.setItem('arb.ide.activity', '"git"');localStorage.setItem('arb.history.open', 'true');`;
// Index Git de la sidebar : compteurs par projet, sans liste de fichiers (celle-ci vit au centre).
const seedGit = `${seedExplorer}localStorage.setItem('arb.ide.activity', '"git"');`;
// Vue Changements de la zone centrale : blocs dépliés du checkout principal ET du worktree de
// feature, plus l'historique du premier (sa clé de repli est désormais PAR worktree).
const changesKeys = JSON.stringify(
JSON.stringify([`${repoId}\0${repo}`, ...(wtRes.worktree?.path ? [`${repoId}\0${wtRes.worktree.path}`] : [])]),
);
const seedChanges =
`${seedExplorer}localStorage.setItem('arb.ide.centerMode', '"changes"');` +
`localStorage.setItem('arb.ide.changesExpanded', ${changesKeys});` +
`localStorage.setItem(${JSON.stringify(`arb.history.open:${repoId}\0${repo}`)}, 'true');`;
const seedChangesMobile = `${seedChanges}localStorage.setItem('arb.ide.mobilePanel', '"changes"');`;
// Zone d'édition : un onglet persisté, jamais capturé jusqu'ici.
const editorTabId = `${repoId}\0${repo}\0src/app.ts`;
const seedEditor =
`${seedExplorer}localStorage.setItem('arb.ide.tabs', ${JSON.stringify(
JSON.stringify([{ id: editorTabId, repoId, wtPath: repo, file: 'src/app.ts', view: 'editor' }]),
)});localStorage.setItem('arb.ide.activeTab', ${JSON.stringify(JSON.stringify(editorTabId))});`;
// Panneau Groupes avec le groupe ET le worktree dépliés : c'est la vue qui porte l'arborescence de
// fichiers des membres du groupe, sinon jamais capturée.
const seedGroups =
@@ -175,11 +197,17 @@ try {
`localStorage.setItem('arb.ide.expandedGroups', ${JSON.stringify(JSON.stringify([groupRes.group?.id]))});` +
`localStorage.setItem('arb.ide.expandedWts', ${JSON.stringify(JSON.stringify([repo]))});`;
// `expect` : une capture non vide ne prouve pas que le bon composant s'est rendu. On exige donc un
// fragment de texte propre à la surface visée.
const shots = [
{ name: 'ide-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedExplorer },
{ name: 'ide-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedExplorer },
{ name: 'git-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedGit },
{ name: 'git-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedGit },
{ name: 'git-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedGit, expect: 'feature/demo' },
{ name: 'git-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedGit, expect: 'feature/demo' },
{ name: 'changes-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedChanges, expect: 'wip.txt' },
{ name: 'changes-light-desktop', theme: 'light', width: 1440, height: 900, seed: seedChanges, expect: 'wip.txt' },
{ name: 'changes-dark-mobile', theme: 'dark', width: 390, height: 844, seed: seedChangesMobile, expect: 'wip.txt' },
{ name: 'editor-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedEditor, expect: 'src/app.ts' },
{ name: 'groups-dark-desktop', theme: 'dark', width: 1440, height: 900, seed: seedGroups },
{ name: 'ide-dark-mobile', theme: 'dark', width: 390, height: 844, seed: seedExplorer },
{ name: 'ide-light-mobile', theme: 'light', width: 390, height: 844, seed: seedExplorer },
@@ -196,9 +224,12 @@ try {
await client.send('Network.setCookie', { name: 'arb_session', value: cookieValue, domain: '127.0.0.1', path: '/', httpOnly: true }, sessionId);
// Thème : la SPA lit `arb.theme` avant le premier paint (script anti-FOUC).
await client.send('Page.enable', {}, sessionId);
// `localStorage.clear()` d'abord : les cibles d'un même profil Chromium le PARTAGENT, donc sans
// ça une capture héritait de l'état de vue de la précédente (une capture de la zone d'édition
// montrait la vue Changements laissée par celle d'avant).
await client.send(
'Page.addScriptToEvaluateOnNewDocument',
{ source: `localStorage.setItem('arb.theme', ${JSON.stringify(JSON.stringify(shot.theme))});${shot.seed ?? ''}` },
{ source: `localStorage.clear();localStorage.setItem('arb.theme', ${JSON.stringify(JSON.stringify(shot.theme))});${shot.seed ?? ''}` },
sessionId,
);
const before = client.events.length;
@@ -208,6 +239,7 @@ try {
const text = await client.send('Runtime.evaluate', { expression: 'document.body.innerText', returnByValue: true }, sessionId);
const rendered = String(text.result?.value ?? '');
check(`${shot.name} : page rendue`, rendered.length > 20, `${rendered.length} caractères`);
if (shot.expect) check(`${shot.name} : contenu attendu`, rendered.includes(shot.expect), shot.expect);
const errs = client.events
.slice(before)
@@ -564,6 +564,11 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
throw httpError(400, 'COMMIT_FAILED', (err as Error).message);
}
this.factsCache.delete(repoId);
// Un commit vide l'index et déplace HEAD : sans cette émission, les vues à contenu paresseux
// continuaient d'afficher les fichiers qu'on venait de committer, et l'historique ne voyait pas
// le nouveau commit (le watcher FS finissait par le rattraper, mais seulement si ce worktree
// était observé, et avec le délai du debounce).
this.emit('worktree_changes', { repoId, path: w.path });
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
});
}
@@ -676,6 +681,10 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
return this.withLock(repoId, async () => {
await fetchRemote(w.path).catch((err) => { throw httpError(400, 'FETCH_FAILED', (err as Error).message); });
this.factsCache.delete(repoId);
// Un fetch ne touche pas l'arbre de travail, mais il change ce que valent les compteurs et
// l'historique : uniformité « toute mutation git ré-arme les vues à contenu paresseux », sans
// quoi ce bouton est le seul du panneau à ne rien rafraîchir de visible.
this.emit('worktree_changes', { repoId, path: w.path });
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
});
}
@@ -793,6 +802,9 @@ export class WorktreeManager extends EventEmitter<WorktreeManagerEvents> {
throw httpError(400, 'PUSH_FAILED', (err as Error).message);
}
this.factsCache.delete(repoId);
// Corrige le badge « n commits non poussés » de l'historique, qui restait périmé après un push :
// son seul déclencheur est ce jeton d'invalidation.
this.emit('worktree_changes', { repoId, path: w.path });
return (await this.emitWorktree(row, w.path)) as WorktreeSummary;
});
}
@@ -212,6 +212,33 @@ describe('WorktreeManager', () => {
expect(w.git.dirtyCount).toBe(0);
});
// Les vues à contenu paresseux (liste de fichiers modifiés, historique) ne se rechargent QUE sur
// `worktree_changes`. Il manquait sur commit, fetch et push, d'où un badge « non poussé » périmé et
// des fichiers déjà commités encore listés.
it('commit, fetch et push émettent worktree_changes (pas seulement worktree_update)', async () => {
const repo = makeTmpRepo();
const remote = mkdtempSync(join(tmpdir(), 'arb-remote-'));
dirs.push(remote);
execFileSync('git', ['init', '--bare', '-b', 'main'], { cwd: remote, stdio: 'pipe' });
execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: repo, stdio: 'pipe' });
const r = await wt.addRepo({ path: repo });
const changed: Array<{ repoId: string; path: string }> = [];
wt.on('worktree_changes', (e) => changed.push(e));
writeFileSync(join(repo, 'f.txt'), 'x\n');
await wt.commitWorktree(r.id, repo, 'add f');
expect(changed).toHaveLength(1);
const pushed = await wt.pushWorktree(r.id, repo);
expect(changed).toHaveLength(2);
expect(pushed.git.ahead).toBe(0); // documente le nouveau canPush : plus rien à pousser
await wt.fetch(r.id, repo);
expect(changed).toHaveLength(3);
expect(changed.every((e) => e.repoId === r.id && resolve(e.path) === resolve(repo))).toBe(true);
});
it('promoteWorktree : la branche du worktree devient le checkout principal, worktree supprimé, ancienne branche conservée', async () => {
const repo = makeTmpRepo();
const r = await wt.addRepo({ path: repo });