diff --git a/.gitignore b/.gitignore index bc036c7..033c81d 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ CLAUDE.md # Spike scratch output spikes/**/tmp/ spikes/**/captures/ + +# captures des scripts de verification visuelle (verify-ui.mjs) +.ui-shots/ diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 02b02a0..93d9445 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -4,6 +4,12 @@ Notable changes to the Arboretum desktop app (`packages/desktop`). The daemon an extension keep their own changelogs in `packages/server/CHANGELOG.md` and `packages/vscode/CHANGELOG.md`. +## 0.2.3 + +Ships the daemon 3.6.0. Files open again (the editor area could stay blank), and uncommitted work +gets a real surface in the centre of the IDE: one block per project, with staging, commit and push. +The Electron shell itself is unchanged. + ## 0.2.2 - **Clipboard bridge.** The renderer cannot use `navigator.clipboard` (Electron rejects it with diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index 51f78dd..769630f 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -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. diff --git a/packages/server/scripts/verify-editor.mjs b/packages/server/scripts/verify-editor.mjs new file mode 100644 index 0000000..feb84cc --- /dev/null +++ b/packages/server/scripts/verify-editor.mjs @@ -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 à l'intérieur du + //