From 75efecf93f587585a2bf3bcdbb500e18d7c33aa2 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Sat, 27 Jun 2026 13:47:10 +0200 Subject: [PATCH] feat(p8): vue IDE /workspace (arbre + Monaco + diff + terminal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend: garde-fou conflit d'édition par mtime (GET renvoie mtime, PUT refuse en 409 STALE_FILE si baseMtime périmé) - lib: wt-key (base64url du chemin worktree), diff-parse (parseur pur du diff unifié → hunks) - composables: useMonaco (import dynamique → chunk isolé), useWorkspaceLayout (largeurs/panneau persistés) - components/workspace: GitStatusBadge, FileTree+FileTreeNode (lazy fs/list includeFiles), ChangedFilesPanel, DiffViewer (coloration +/-), MonacoEditor (Ctrl+S + bannière conflit reload/overwrite) - views/WorkspaceView: 3 colonnes desktop redimensionnables + SegmentedControl mobile, header live (GitStatusBadge), watchWorktree → refresh diff/changements, terminal de la session corrélée - router meta 'ide' + route /workspace/:repoId/:wt ; App.vue plein écran sans AppShell ; WorktreeCard bouton « Ouvrir l'IDE » - deps: monaco-editor (lazy, vendor-monaco isolé, 0 impact bundle initial) ; vite manualChunks - i18n EN+FR (workspace/editor/diff/git) ; tests diff-parse + wt-key ; acceptance-p8.mjs (mtime/409 + diff) --- package-lock.json | 29 +++ packages/server/scripts/acceptance-p8.mjs | 122 ++++++++++ packages/server/src/routes/files.ts | 12 +- packages/shared/src/api.ts | 6 + packages/web/package.json | 1 + packages/web/src/App.vue | 3 +- packages/web/src/components/WorktreeCard.vue | 4 + .../workspace/ChangedFilesPanel.vue | 58 +++++ .../src/components/workspace/DiffViewer.vue | 76 +++++++ .../web/src/components/workspace/FileTree.vue | 57 +++++ .../src/components/workspace/FileTreeNode.vue | 82 +++++++ .../components/workspace/GitStatusBadge.vue | 27 +++ .../src/components/workspace/MonacoEditor.vue | 146 ++++++++++++ packages/web/src/composables/useMonaco.ts | 10 + .../web/src/composables/useWorkspaceLayout.ts | 33 +++ packages/web/src/i18n/en.ts | 33 +++ packages/web/src/i18n/fr.ts | 33 +++ packages/web/src/lib/diff-parse.ts | 71 ++++++ packages/web/src/lib/git-api.ts | 5 +- packages/web/src/lib/monaco-setup.ts | 13 ++ packages/web/src/lib/wt-key.ts | 16 ++ packages/web/src/router/index.ts | 6 +- packages/web/src/views/WorkspaceView.vue | 212 ++++++++++++++++++ packages/web/test/diff-parse.test.ts | 60 +++++ packages/web/test/wt-key.test.ts | 24 ++ packages/web/vite.config.ts | 9 + 26 files changed, 1142 insertions(+), 6 deletions(-) create mode 100644 packages/server/scripts/acceptance-p8.mjs create mode 100644 packages/web/src/components/workspace/ChangedFilesPanel.vue create mode 100644 packages/web/src/components/workspace/DiffViewer.vue create mode 100644 packages/web/src/components/workspace/FileTree.vue create mode 100644 packages/web/src/components/workspace/FileTreeNode.vue create mode 100644 packages/web/src/components/workspace/GitStatusBadge.vue create mode 100644 packages/web/src/components/workspace/MonacoEditor.vue create mode 100644 packages/web/src/composables/useMonaco.ts create mode 100644 packages/web/src/composables/useWorkspaceLayout.ts create mode 100644 packages/web/src/lib/diff-parse.ts create mode 100644 packages/web/src/lib/monaco-setup.ts create mode 100644 packages/web/src/lib/wt-key.ts create mode 100644 packages/web/src/views/WorkspaceView.vue create mode 100644 packages/web/test/diff-parse.test.ts create mode 100644 packages/web/test/wt-key.test.ts diff --git a/package-lock.json b/package-lock.json index d817396..337f893 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3886,6 +3886,12 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", + "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", + "license": "(MPL-2.0 OR Apache-2.0)" + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -5512,6 +5518,18 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5664,6 +5682,16 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/monaco-editor": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.54.0.tgz", + "integrity": "sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==", + "license": "MIT", + "dependencies": { + "dompurify": "3.1.7", + "marked": "14.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8476,6 +8504,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", + "monaco-editor": "^0.54.0", "pinia": "^3.0.4", "vue": "^3.5.38", "vue-i18n": "^11.4.5", diff --git a/packages/server/scripts/acceptance-p8.mjs b/packages/server/scripts/acceptance-p8.mjs new file mode 100644 index 0000000..def615b --- /dev/null +++ b/packages/server/scripts/acceptance-p8.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +// Acceptation P8 (sans navigateur) : API fichiers de l'éditeur Monaco + garde-fou conflit (mtime). +// Vrai daemon + vrai repo git tmp. Couvre : GET content (+mtime), PUT avec baseMtime correct → 200, +// PUT avec baseMtime périmé → 409 STALE_FILE, PUT sans baseMtime (overwrite) → 200, diff après +// édition cohérent, fs/list?includeFiles=1 (isFile), refus de traversal. +import { spawn, execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const PORT = 7552; +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-p8-')); +const repo = join(tmp, 'demo-repo'); +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'); +writeFileSync(join(repo, 'README.md'), '# demo\n'); +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'), '--no-discover'], + { 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)); + +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 addRepo = await j('/api/v1/repos', 'POST', cookie, { path: repo }); + const repoId = (await addRepo.json()).repo.id; + check('POST /repos → 201', addRepo.status === 201 && !!repoId); + const enc = encodeURIComponent(repo); + + // ---- GET content : contenu + mtime ---- + const get1 = await (await j(`/api/v1/repos/${repoId}/files/content?wt=${enc}&path=README.md`, 'GET', cookie)).json(); + check('GET /files/content : contenu + mtime', get1.content === '# demo\n' && typeof get1.mtime === 'number'); + const mtime0 = get1.mtime; + + // ---- PUT avec baseMtime correct → 200 + nouveau mtime ---- + const put1 = await j('/api/v1/repos/' + repoId + '/files/content', 'PUT', cookie, { + wt: repo, path: 'README.md', content: '# demo\nedited via editor\n', baseMtime: mtime0, + }); + const put1Body = await put1.json(); + check('PUT (baseMtime correct) → 200 + mtime', put1.status === 200 && typeof put1Body.mtime === 'number'); + + // ---- PUT avec baseMtime périmé (l'ancien) → 409 STALE_FILE ---- + const putStale = await j('/api/v1/repos/' + repoId + '/files/content', 'PUT', cookie, { + wt: repo, path: 'README.md', content: 'concurrent overwrite\n', baseMtime: mtime0, + }); + const staleBody = await putStale.json(); + check('PUT (baseMtime périmé) → 409 STALE_FILE', putStale.status === 409 && staleBody.error?.code === 'STALE_FILE'); + + // ---- PUT sans baseMtime (overwrite forcé) → 200 ---- + const putForce = await j('/api/v1/repos/' + repoId + '/files/content', 'PUT', cookie, { + wt: repo, path: 'README.md', content: '# demo\nforced\n', + }); + check('PUT (sans baseMtime, overwrite) → 200', putForce.status === 200); + + // ---- diff après édition ---- + const ch = await (await j(`/api/v1/repos/${repoId}/worktrees/changes?path=${enc}`, 'GET', cookie)).json(); + check('GET /changes : README.md modifié', (ch.changes ?? []).some((c) => c.path === 'README.md' && c.unstaged)); + const diff = await (await j(`/api/v1/repos/${repoId}/worktrees/diff?path=${enc}&file=README.md`, 'GET', cookie)).json(); + check('GET /diff : contient la ligne ajoutée', typeof diff.diff === 'string' && diff.diff.includes('+forced')); + + // ---- création d'un nouveau fichier + lecture du langage ---- + const putNew = await j('/api/v1/repos/' + repoId + '/files/content', 'PUT', cookie, { wt: repo, path: 'src/app.ts', content: 'export const x = 1\n' }); + check('PUT (création src/app.ts) → 200', putNew.status === 200); + const getNew = await (await j(`/api/v1/repos/${repoId}/files/content?wt=${enc}&path=src/app.ts`, 'GET', cookie)).json(); + check('GET nouveau fichier : langage typescript', getNew.language === 'typescript'); + + // ---- fs/list?includeFiles=1 : remonte les fichiers (arbre IDE) ---- + const fs = await (await j(`/api/v1/fs/list?path=${enc}&includeFiles=1`, 'GET', cookie)).json(); + const readme = (fs.entries ?? []).find((e) => e.name === 'README.md'); + check('fs/list?includeFiles=1 : README.md (isFile)', readme?.isFile === true); + + // ---- refus de traversal ---- + const trav = await j(`/api/v1/repos/${repoId}/files/content`, 'PUT', cookie, { wt: repo, path: '../escape.txt', content: 'x' }); + check('PUT traversal ../ refusé', trav.status === 400 || trav.status === 403); +} 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 P8: ALL GREEN' : `\nACCEPTANCE P8: ${failed.length} FAILURE(S)`); + process.exit(failed.length === 0 ? 0 : 1); +} diff --git a/packages/server/src/routes/files.ts b/packages/server/src/routes/files.ts index db1402d..e6d1bfa 100644 --- a/packages/server/src/routes/files.ts +++ b/packages/server/src/routes/files.ts @@ -54,6 +54,7 @@ export function registerFileRoutes(app: FastifyInstance, wt: WorktreeManager, db content: buf.toString('utf-8'), encoding: 'utf-8', size: st.size, + mtime: st.mtimeMs, ...(lang ? { language: lang } : {}), }; return reply.send(res); @@ -74,10 +75,19 @@ export function registerFileRoutes(app: FastifyInstance, wt: WorktreeManager, db } try { const abs = await wt.assertPathInWorktree(id, body.wt, body.path); + // Garde-fou conflit (P8) : si le client a chargé le fichier (baseMtime) et qu'il a changé + // depuis (ex. Claude a écrit en parallèle), on refuse pour ne pas écraser silencieusement. + if (typeof body.baseMtime === 'number') { + const cur = await stat(abs).catch(() => null); + if (cur && cur.isFile() && cur.mtimeMs !== body.baseMtime) { + return reply.status(409).send({ error: { code: 'STALE_FILE', message: 'File changed on disk since it was loaded' } }); + } + } await mkdir(dirname(abs), { recursive: true }); // dirname reste sous le worktree (abs validé) await writeFile(abs, body.content, 'utf-8'); + const after = await stat(abs); recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'file.write', resourceId: id, details: { path: body.path } }); - const res: WriteFileResponse = { ok: true, size: Buffer.byteLength(body.content, 'utf-8') }; + const res: WriteFileResponse = { ok: true, size: Buffer.byteLength(body.content, 'utf-8'), mtime: after.mtimeMs }; return reply.send(res); } catch (err) { return sendManagerError(reply, err); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index e052bd9..ef4d51b 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -240,6 +240,8 @@ export interface FileContentResponse { content: string; encoding: 'utf-8'; size: number; + /** mtime du fichier en ms (st.mtimeMs) — base du garde-fou de conflit d'édition (P8). */ + mtime: number; /** langage déduit de l'extension (pour Monaco), si reconnu. */ language?: string; } @@ -250,10 +252,14 @@ export interface WriteFileRequest { /** chemin relatif au worktree (POSIX). */ path: string; content: string; + /** mtime (ms) lu au chargement ; si fourni et que le fichier a changé depuis → 409 STALE_FILE (P8). */ + baseMtime?: number; } export interface WriteFileResponse { ok: true; size: number; + /** mtime (ms) du fichier après écriture — le client le réutilise comme nouvelle base. */ + mtime: number; } /** P7 — corps commun des mutations de staging/discard. */ export interface WorktreeFilesRequest { diff --git a/packages/web/package.json b/packages/web/package.json index 9d13d67..4eaf41f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -14,6 +14,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", + "monaco-editor": "^0.54.0", "pinia": "^3.0.4", "vue": "^3.5.38", "vue-i18n": "^11.4.5", diff --git a/packages/web/src/App.vue b/packages/web/src/App.vue index b71e397..b7e80a7 100644 --- a/packages/web/src/App.vue +++ b/packages/web/src/App.vue @@ -1,7 +1,8 @@ + + diff --git a/packages/web/src/components/workspace/DiffViewer.vue b/packages/web/src/components/workspace/DiffViewer.vue new file mode 100644 index 0000000..062976a --- /dev/null +++ b/packages/web/src/components/workspace/DiffViewer.vue @@ -0,0 +1,76 @@ + + + diff --git a/packages/web/src/components/workspace/FileTree.vue b/packages/web/src/components/workspace/FileTree.vue new file mode 100644 index 0000000..3428cef --- /dev/null +++ b/packages/web/src/components/workspace/FileTree.vue @@ -0,0 +1,57 @@ + + + diff --git a/packages/web/src/components/workspace/FileTreeNode.vue b/packages/web/src/components/workspace/FileTreeNode.vue new file mode 100644 index 0000000..41dd203 --- /dev/null +++ b/packages/web/src/components/workspace/FileTreeNode.vue @@ -0,0 +1,82 @@ + + + diff --git a/packages/web/src/components/workspace/GitStatusBadge.vue b/packages/web/src/components/workspace/GitStatusBadge.vue new file mode 100644 index 0000000..0bbf59e --- /dev/null +++ b/packages/web/src/components/workspace/GitStatusBadge.vue @@ -0,0 +1,27 @@ + + + diff --git a/packages/web/src/components/workspace/MonacoEditor.vue b/packages/web/src/components/workspace/MonacoEditor.vue new file mode 100644 index 0000000..e03dea4 --- /dev/null +++ b/packages/web/src/components/workspace/MonacoEditor.vue @@ -0,0 +1,146 @@ + + + diff --git a/packages/web/src/composables/useMonaco.ts b/packages/web/src/composables/useMonaco.ts new file mode 100644 index 0000000..2a0040f --- /dev/null +++ b/packages/web/src/composables/useMonaco.ts @@ -0,0 +1,10 @@ +// Chargement paresseux de Monaco : le gros paquet n'est tiré qu'à la première ouverture d'un +// fichier dans l'IDE. Mémoïsé (un seul import partagé entre toutes les instances d'éditeur). +import type * as Monaco from 'monaco-editor'; + +let monacoPromise: Promise | null = null; + +export function loadMonaco(): Promise { + monacoPromise ??= import('../lib/monaco-setup').then((m) => m.monaco); + return monacoPromise; +} diff --git a/packages/web/src/composables/useWorkspaceLayout.ts b/packages/web/src/composables/useWorkspaceLayout.ts new file mode 100644 index 0000000..073c0ce --- /dev/null +++ b/packages/web/src/composables/useWorkspaceLayout.ts @@ -0,0 +1,33 @@ +// Disposition de la vue IDE persistée en localStorage : largeurs des colonnes (desktop) et +// panneau actif (mobile). Refs réactives ; chaque changement est réécrit en localStorage. +import { ref, watch, type Ref } from 'vue'; + +export type MobilePanel = 'files' | 'editor' | 'diff' | 'terminal'; + +function persistedRef(key: string, initial: T): Ref { + let start = initial; + try { + const raw = localStorage.getItem(key); + if (raw != null) start = JSON.parse(raw) as T; + } catch { + /* localStorage indisponible / JSON corrompu : on garde le défaut */ + } + const r = ref(start) as Ref; + watch(r, (v) => { + try { + localStorage.setItem(key, JSON.stringify(v)); + } catch { + /* quota / mode privé : silencieux */ + } + }); + return r; +} + +export function useWorkspaceLayout() { + // largeurs en px des colonnes latérales ; la colonne centrale (éditeur/diff) flexe. + const leftWidth = persistedRef('arb.ws.leftWidth', 280); + const rightWidth = persistedRef('arb.ws.rightWidth', 460); + // `string` (et non MobilePanel) pour rester compatible avec le v-model de SegmentedControl. + const mobilePanel = persistedRef('arb.ws.mobilePanel', 'files'); + return { leftWidth, rightWidth, mobilePanel }; +} diff --git a/packages/web/src/i18n/en.ts b/packages/web/src/i18n/en.ts index 15bb659..790af6f 100644 --- a/packages/web/src/i18n/en.ts +++ b/packages/web/src/i18n/en.ts @@ -202,6 +202,39 @@ export default { create: 'Create', creating: 'Creating…', }, + workspace: { + open: 'Open IDE', + files: 'Files', + changes: 'Changes', + noChanges: 'No changes', + changesTruncated: 'Too many changes — list truncated.', + terminal: 'Terminal', + editor: 'Editor', + diff: 'Diff', + noFileOpen: 'No file open', + noFileHint: 'Pick a file in the tree to edit it, or a changed file to view its diff.', + noSession: 'No session here', + noSessionHint: 'Start a session on this worktree from the dashboard to get a terminal.', + }, + editor: { + save: 'Save', + unsaved: 'Unsaved changes', + conflict: 'This file changed on disk since you opened it.', + reload: 'Reload', + overwrite: 'Overwrite', + }, + diff: { + binary: 'Binary file — no text diff.', + tooLarge: 'Diff too large to display.', + noChanges: 'No changes in this file.', + }, + git: { + ahead: 'commits ahead', + behind: 'commits behind', + staged: 'staged', + unstaged: 'unstaged', + conflicts: 'conflicts', + }, terminal: { observer: 'observer (read-only)', sessionEnded: 'Session ended', diff --git a/packages/web/src/i18n/fr.ts b/packages/web/src/i18n/fr.ts index fce2d53..196e3e5 100644 --- a/packages/web/src/i18n/fr.ts +++ b/packages/web/src/i18n/fr.ts @@ -204,6 +204,39 @@ const fr: typeof en = { create: 'Créer', creating: 'Création…', }, + workspace: { + open: 'Ouvrir l’IDE', + files: 'Fichiers', + changes: 'Changements', + noChanges: 'Aucun changement', + changesTruncated: 'Trop de changements — liste tronquée.', + terminal: 'Terminal', + editor: 'Éditeur', + diff: 'Diff', + noFileOpen: 'Aucun fichier ouvert', + noFileHint: 'Choisissez un fichier dans l’arbre pour l’éditer, ou un fichier modifié pour voir son diff.', + noSession: 'Aucune session ici', + noSessionHint: 'Lancez une session sur ce worktree depuis le tableau de bord pour obtenir un terminal.', + }, + editor: { + save: 'Enregistrer', + unsaved: 'Modifications non enregistrées', + conflict: 'Ce fichier a changé sur le disque depuis son ouverture.', + reload: 'Recharger', + overwrite: 'Écraser', + }, + diff: { + binary: 'Fichier binaire — pas de diff texte.', + tooLarge: 'Diff trop volumineux à afficher.', + noChanges: 'Aucun changement dans ce fichier.', + }, + git: { + ahead: 'commits en avance', + behind: 'commits en retard', + staged: 'indexé', + unstaged: 'non indexé', + conflicts: 'conflits', + }, terminal: { observer: 'observateur (lecture seule)', sessionEnded: 'Session terminée', diff --git a/packages/web/src/lib/diff-parse.ts b/packages/web/src/lib/diff-parse.ts new file mode 100644 index 0000000..176eb8f --- /dev/null +++ b/packages/web/src/lib/diff-parse.ts @@ -0,0 +1,71 @@ +// Parseur PUR d'un diff unifié git → hunks/lignes typées (testable sans DOM, modèle de +// parseWorktreePorcelain côté serveur). Consommé par DiffViewer (P8). Ne dépend de rien. + +export type DiffLineType = 'context' | 'add' | 'del'; + +export interface DiffLine { + type: DiffLineType; + /** texte de la ligne sans le marqueur de tête (+/-/espace). */ + content: string; + /** numéro de ligne côté ancien fichier (null pour une addition). */ + oldLine: number | null; + /** numéro de ligne côté nouveau fichier (null pour une suppression). */ + newLine: number | null; +} + +export interface DiffHunk { + /** ligne d'en-tête `@@ -a,b +c,d @@ …`. */ + header: string; + oldStart: number; + newStart: number; + lines: DiffLine[]; +} + +export interface ParsedDiff { + hunks: DiffHunk[]; + /** compteurs agrégés (lignes ajoutées / supprimées). */ + additions: number; + deletions: number; +} + +const HUNK_RE = /^@@+ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; + +/** Parse un diff unifié git (un seul fichier) en hunks. Tolérant : ignore les en-têtes de fichier. */ +export function parseUnifiedDiff(diff: string): ParsedDiff { + const hunks: DiffHunk[] = []; + let additions = 0; + let deletions = 0; + let current: DiffHunk | null = null; + let oldNo = 0; + let newNo = 0; + + for (const raw of diff.split('\n')) { + const m = HUNK_RE.exec(raw); + if (m) { + oldNo = Number(m[1]); + newNo = Number(m[3]); + current = { header: raw, oldStart: oldNo, newStart: newNo, lines: [] }; + hunks.push(current); + continue; + } + if (!current) continue; // en-têtes (diff --git, index, ---, +++) avant le premier @@ + + // marqueur git « \ No newline at end of file » : rattaché à la ligne précédente, ignoré. + if (raw.startsWith('\\')) continue; + + const marker = raw[0]; + const content = raw.slice(1); + if (marker === '+') { + current.lines.push({ type: 'add', content, oldLine: null, newLine: newNo++ }); + additions++; + } else if (marker === '-') { + current.lines.push({ type: 'del', content, oldLine: oldNo++, newLine: null }); + deletions++; + } else if (marker === ' ' || raw === '') { + // ligne de contexte (ou ligne vide en contexte). + current.lines.push({ type: 'context', content, oldLine: oldNo++, newLine: newNo++ }); + } + } + + return { hunks, additions, deletions }; +} diff --git a/packages/web/src/lib/git-api.ts b/packages/web/src/lib/git-api.ts index 888c212..e4052b3 100644 --- a/packages/web/src/lib/git-api.ts +++ b/packages/web/src/lib/git-api.ts @@ -39,6 +39,7 @@ export const gitApi = { readFile: (repoId: string, wt: string, path: string): Promise => api.get(`/api/v1/repos/${repoId}/files/content?wt=${enc(wt)}&path=${enc(path)}`), - writeFile: (repoId: string, wt: string, path: string, content: string): Promise => - api.put(`/api/v1/repos/${repoId}/files/content`, { wt, path, content }), + // baseMtime : mtime lu au chargement ; le serveur refuse en 409 STALE_FILE si le fichier a changé depuis. + writeFile: (repoId: string, wt: string, path: string, content: string, baseMtime?: number): Promise => + api.put(`/api/v1/repos/${repoId}/files/content`, { wt, path, content, ...(baseMtime != null ? { baseMtime } : {}) }), }; diff --git a/packages/web/src/lib/monaco-setup.ts b/packages/web/src/lib/monaco-setup.ts new file mode 100644 index 0000000..1dc9115 --- /dev/null +++ b/packages/web/src/lib/monaco-setup.ts @@ -0,0 +1,13 @@ +// Configuration Monaco isolée dans son propre module, chargé UNIQUEMENT en import dynamique +// (cf. composables/useMonaco) → chunk séparé, zéro impact sur le bundle initial. +// Workers via Vite (?worker) : on ne fournit que le worker de base. La colorisation syntaxique +// (Monarch) tourne sur le thread principal et ne dépend d'aucun worker ; les services de langage +// avancés (TS/JSON) dégradent proprement — suffisant pour éditer + sauver dans le navigateur. +import * as monaco from 'monaco-editor'; +import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; + +(self as unknown as { MonacoEnvironment: monaco.Environment }).MonacoEnvironment = { + getWorker: () => new EditorWorker(), +}; + +export { monaco }; diff --git a/packages/web/src/lib/wt-key.ts b/packages/web/src/lib/wt-key.ts new file mode 100644 index 0000000..105c84f --- /dev/null +++ b/packages/web/src/lib/wt-key.ts @@ -0,0 +1,16 @@ +// Encodage du chemin absolu d'un worktree pour le mettre dans l'URL (/workspace/:repoId/:wt). +// base64url (sans `+`, `/`, `=`) → segment d'URL propre, réversible. Le chemin est déjà la clé de +// corrélation worktree↔session (cwd) ; on ne l'expose jamais en clair dans la route. + +/** Encode un chemin absolu en base64url (sûr en segment d'URL). */ +export function encodeWtKey(path: string): string { + const b64 = btoa(unescape(encodeURIComponent(path))); + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** Décode une clé base64url vers le chemin absolu d'origine. Lève si la clé est invalide. */ +export function decodeWtKey(key: string): string { + const b64 = key.replace(/-/g, '+').replace(/_/g, '/'); + const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4)); + return decodeURIComponent(escape(atob(b64 + pad))); +} diff --git a/packages/web/src/router/index.ts b/packages/web/src/router/index.ts index 5d0db4a..2f2c23f 100644 --- a/packages/web/src/router/index.ts +++ b/packages/web/src/router/index.ts @@ -2,10 +2,11 @@ import { createRouter, createWebHistory } from 'vue-router'; import { useAuthStore } from '../stores/auth'; // Type du layout choisi par App.vue : 'bare' (login, sans coquille), -// 'shell' (sidebar + contenu centré), 'fullbleed' (terminal plein écran dans la coquille). +// 'shell' (sidebar + contenu centré), 'fullbleed' (terminal plein écran dans la coquille), +// 'ide' (vue Workspace plein écran SANS coquille — gère son propre layout 3 panneaux). declare module 'vue-router' { interface RouteMeta { - layout?: 'bare' | 'shell' | 'fullbleed'; + layout?: 'bare' | 'shell' | 'fullbleed' | 'ide'; } } @@ -16,6 +17,7 @@ export const router = createRouter({ { path: '/', name: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { layout: 'shell' } }, { path: '/sessions', name: 'sessions', component: () => import('../views/SessionsListView.vue'), meta: { layout: 'shell' } }, { path: '/sessions/:id', name: 'session', component: () => import('../views/SessionView.vue'), meta: { layout: 'fullbleed' } }, + { path: '/workspace/:repoId/:wt', name: 'workspace', component: () => import('../views/WorkspaceView.vue'), meta: { layout: 'ide' } }, { path: '/groups', name: 'groups', component: () => import('../views/GroupsListView.vue'), meta: { layout: 'shell' } }, { path: '/groups/:id', name: 'group', component: () => import('../views/GroupView.vue'), meta: { layout: 'shell' } }, { path: '/settings', name: 'settings', component: () => import('../views/SettingsView.vue'), meta: { layout: 'shell' } }, diff --git a/packages/web/src/views/WorkspaceView.vue b/packages/web/src/views/WorkspaceView.vue new file mode 100644 index 0000000..0de2c30 --- /dev/null +++ b/packages/web/src/views/WorkspaceView.vue @@ -0,0 +1,212 @@ + + + diff --git a/packages/web/test/diff-parse.test.ts b/packages/web/test/diff-parse.test.ts new file mode 100644 index 0000000..75c73c0 --- /dev/null +++ b/packages/web/test/diff-parse.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { parseUnifiedDiff } from '../src/lib/diff-parse'; + +const SAMPLE = `diff --git a/README.md b/README.md +index 1234567..89abcde 100644 +--- a/README.md ++++ b/README.md +@@ -1,3 +1,4 @@ + # demo ++edited line + second +-old third ++new third +@@ -10,2 +11,2 @@ + ten +-eleven ++ELEVEN`; + +describe('parseUnifiedDiff', () => { + it('ignore les en-têtes de fichier avant le premier @@', () => { + const r = parseUnifiedDiff(SAMPLE); + expect(r.hunks).toHaveLength(2); + expect(r.hunks[0].header).toBe('@@ -1,3 +1,4 @@'); + }); + + it('compte additions et suppressions', () => { + const r = parseUnifiedDiff(SAMPLE); + expect(r.additions).toBe(3); // edited line, new third, ELEVEN + expect(r.deletions).toBe(2); // old third, eleven + }); + + it('numérote correctement les lignes ancien/nouveau', () => { + const { hunks } = parseUnifiedDiff(SAMPLE); + const lines = hunks[0].lines; + expect(lines[0]).toMatchObject({ type: 'context', content: ' # demo'.slice(1), oldLine: 1, newLine: 1 }); + const add = lines.find((l) => l.type === 'add'); + expect(add).toMatchObject({ oldLine: null, newLine: 2, content: 'edited line' }); + const del = lines.find((l) => l.type === 'del'); + expect(del).toMatchObject({ newLine: null, content: 'old third' }); + }); + + it('ignore le marqueur « \\ No newline at end of file »', () => { + const diff = `@@ -1 +1 @@\n-a\n+b\n\\ No newline at end of file`; + const r = parseUnifiedDiff(diff); + expect(r.additions).toBe(1); + expect(r.deletions).toBe(1); + expect(r.hunks[0].lines).toHaveLength(2); + }); + + it('diff vide → aucun hunk', () => { + expect(parseUnifiedDiff('').hunks).toHaveLength(0); + }); + + it('gère un fichier untracked (--- /dev/null)', () => { + const diff = `--- /dev/null\n+++ b/new.txt\n@@ -0,0 +1,2 @@\n+line one\n+line two`; + const r = parseUnifiedDiff(diff); + expect(r.additions).toBe(2); + expect(r.hunks[0].lines.every((l) => l.type === 'add')).toBe(true); + }); +}); diff --git a/packages/web/test/wt-key.test.ts b/packages/web/test/wt-key.test.ts new file mode 100644 index 0000000..d9bd8cd --- /dev/null +++ b/packages/web/test/wt-key.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { encodeWtKey, decodeWtKey } from '../src/lib/wt-key'; + +describe('wt-key (base64url)', () => { + const paths = [ + '/home/johan/WebstormProjects/arboretum', + '/srv/code/feature-branch', + '/tmp/with space/and-dash', + '/projets/accentué/café/é', + '/a/b/c/d/e/f/g', + ]; + + it('round-trip encode → decode pour divers chemins', () => { + for (const p of paths) expect(decodeWtKey(encodeWtKey(p))).toBe(p); + }); + + it('produit un segment d’URL sûr (pas de + / =)', () => { + for (const p of paths) { + const key = encodeWtKey(p); + expect(key).not.toMatch(/[+/=]/); + expect(encodeURIComponent(key)).toBe(key); // déjà URL-safe + } + }); +}); diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 89e7f0d..ea2f393 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -7,6 +7,15 @@ const DAEMON_WS = 'ws://127.0.0.1:7317'; export default defineConfig({ plugins: [vue(), tailwindcss()], + build: { + rollupOptions: { + output: { + // Monaco (lourd) isolé dans son propre chunk ; chargé uniquement à l'ouverture d'un fichier + // (import dynamique via composables/useMonaco) → zéro impact sur le bundle initial. + manualChunks: (id) => (id.includes('monaco-editor') ? 'vendor-monaco' : undefined), + }, + }, + }, server: { proxy: { // L'Origin est réécrite vers celle du daemon : son check Origin strict