From ce224fd470e9edcb7fa21f80e44fd8e116b3f07c Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 4 Aug 2026 23:18:40 +0200 Subject: [PATCH] fix(web): les fichiers s'ouvrent enfin, et le travail git par projet au centre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 3 + packages/desktop/CHANGELOG.md | 6 + packages/server/CHANGELOG.md | 34 ++ packages/server/scripts/verify-editor.mjs | 403 ++++++++++++++++++ packages/server/scripts/verify-ui.mjs | 40 +- packages/server/src/core/worktree-manager.ts | 12 + packages/server/test/worktree-manager.test.ts | 27 ++ .../web/src/components/ide/ActivityBar.vue | 9 +- .../web/src/components/ide/ChangesView.vue | 129 ++++++ .../components/ide/ChangesWorktreeBlock.vue | 148 +++++++ .../web/src/components/ide/EditorArea.vue | 286 +++++++++---- .../web/src/components/ide/EditorTabs.vue | 50 ++- packages/web/src/components/ide/GitPanel.vue | 150 ++++--- packages/web/src/components/ide/IdeShell.vue | 12 +- .../src/components/ide/ProjectTreeNode.vue | 5 +- .../src/components/ui/SegmentedControl.vue | 4 + .../components/workspace/CommitHistory.vue | 5 +- .../src/components/workspace/CommitPanel.vue | 55 ++- packages/web/src/composables/useMonaco.ts | 25 +- .../src/composables/useRealtimeBootstrap.ts | 6 + .../src/composables/useWatchedWorktrees.ts | 12 +- packages/web/src/i18n/en.ts | 22 + packages/web/src/i18n/fr.ts | 22 + packages/web/src/lib/changes-model.ts | 86 ++++ packages/web/src/lib/monaco-setup.ts | 35 +- packages/web/src/stores/changes.ts | 127 ++++++ packages/web/src/stores/ide.ts | 78 +++- packages/web/src/stores/worktrees.ts | 8 - packages/web/src/views/help-content.ts | 20 +- packages/web/test/changes-model.test.ts | 127 ++++++ packages/web/test/changes-store.test.ts | 169 ++++++++ packages/web/test/ide-store.test.ts | 75 +++- packages/web/test/use-monaco.test.ts | 32 ++ packages/web/test/watched-worktrees.test.ts | 30 ++ 34 files changed, 2026 insertions(+), 226 deletions(-) create mode 100644 packages/server/scripts/verify-editor.mjs create mode 100644 packages/web/src/components/ide/ChangesView.vue create mode 100644 packages/web/src/components/ide/ChangesWorktreeBlock.vue create mode 100644 packages/web/src/lib/changes-model.ts create mode 100644 packages/web/src/stores/changes.ts create mode 100644 packages/web/test/changes-model.test.ts create mode 100644 packages/web/test/changes-store.test.ts create mode 100644 packages/web/test/use-monaco.test.ts 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 + //