feat(p8): vue IDE /workspace (arbre + Monaco + diff + terminal)
- 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)
This commit is contained in:
29
package-lock.json
generated
29
package-lock.json
generated
@@ -3886,6 +3886,12 @@
|
|||||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
"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": {
|
"node_modules/domutils": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
@@ -5512,6 +5518,18 @@
|
|||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -5664,6 +5682,16 @@
|
|||||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -8476,6 +8504,7 @@
|
|||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-webgl": "^0.19.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
"monaco-editor": "^0.54.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.38",
|
"vue": "^3.5.38",
|
||||||
"vue-i18n": "^11.4.5",
|
"vue-i18n": "^11.4.5",
|
||||||
|
|||||||
122
packages/server/scripts/acceptance-p8.mjs
Normal file
122
packages/server/scripts/acceptance-p8.mjs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ export function registerFileRoutes(app: FastifyInstance, wt: WorktreeManager, db
|
|||||||
content: buf.toString('utf-8'),
|
content: buf.toString('utf-8'),
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
size: st.size,
|
size: st.size,
|
||||||
|
mtime: st.mtimeMs,
|
||||||
...(lang ? { language: lang } : {}),
|
...(lang ? { language: lang } : {}),
|
||||||
};
|
};
|
||||||
return reply.send(res);
|
return reply.send(res);
|
||||||
@@ -74,10 +75,19 @@ export function registerFileRoutes(app: FastifyInstance, wt: WorktreeManager, db
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const abs = await wt.assertPathInWorktree(id, body.wt, body.path);
|
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 mkdir(dirname(abs), { recursive: true }); // dirname reste sous le worktree (abs validé)
|
||||||
await writeFile(abs, body.content, 'utf-8');
|
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 } });
|
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);
|
return reply.send(res);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return sendManagerError(reply, err);
|
return sendManagerError(reply, err);
|
||||||
|
|||||||
@@ -240,6 +240,8 @@ export interface FileContentResponse {
|
|||||||
content: string;
|
content: string;
|
||||||
encoding: 'utf-8';
|
encoding: 'utf-8';
|
||||||
size: number;
|
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. */
|
/** langage déduit de l'extension (pour Monaco), si reconnu. */
|
||||||
language?: string;
|
language?: string;
|
||||||
}
|
}
|
||||||
@@ -250,10 +252,14 @@ export interface WriteFileRequest {
|
|||||||
/** chemin relatif au worktree (POSIX). */
|
/** chemin relatif au worktree (POSIX). */
|
||||||
path: string;
|
path: string;
|
||||||
content: 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 {
|
export interface WriteFileResponse {
|
||||||
ok: true;
|
ok: true;
|
||||||
size: number;
|
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. */
|
/** P7 — corps commun des mutations de staging/discard. */
|
||||||
export interface WorktreeFilesRequest {
|
export interface WorktreeFilesRequest {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-webgl": "^0.19.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
"monaco-editor": "^0.54.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.38",
|
"vue": "^3.5.38",
|
||||||
"vue-i18n": "^11.4.5",
|
"vue-i18n": "^11.4.5",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-dvh flex-col">
|
<div class="flex h-dvh flex-col">
|
||||||
<WsBanner v-if="wsReconnecting" />
|
<WsBanner v-if="wsReconnecting" />
|
||||||
<AppShell v-if="layout !== 'bare'" :fullbleed="layout === 'fullbleed'">
|
<!-- 'ide' et 'bare' : plein écran sans coquille (la vue gère son propre layout). -->
|
||||||
|
<AppShell v-if="layout !== 'bare' && layout !== 'ide'" :fullbleed="layout === 'fullbleed'">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
<RouterView v-else class="min-h-0 flex-1" />
|
<RouterView v-else class="min-h-0 flex-1" />
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
<p class="truncate font-mono text-xs text-zinc-500" :title="worktree.path">{{ worktree.path }}</p>
|
<p class="truncate font-mono text-xs text-zinc-500" :title="worktree.path">{{ worktree.path }}</p>
|
||||||
|
|
||||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
<RouterLink class="btn text-xs" :to="{ name: 'workspace', params: { repoId: worktree.repoId, wt: encodeWtKey(worktree.path) } }">
|
||||||
|
{{ t('workspace.open') }}
|
||||||
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="s in visibleSessions"
|
v-for="s in visibleSessions"
|
||||||
:key="s.id"
|
:key="s.id"
|
||||||
@@ -118,6 +121,7 @@ import type { SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
|||||||
import { useWorktreesStore } from '../stores/worktrees';
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
import { useSessionsStore } from '../stores/sessions';
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
import { ApiError } from '../lib/api';
|
import { ApiError } from '../lib/api';
|
||||||
|
import { encodeWtKey } from '../lib/wt-key';
|
||||||
import SessionStateBadge from './SessionStateBadge.vue';
|
import SessionStateBadge from './SessionStateBadge.vue';
|
||||||
import DialogPrompt from './DialogPrompt.vue';
|
import DialogPrompt from './DialogPrompt.vue';
|
||||||
|
|
||||||
|
|||||||
58
packages/web/src/components/workspace/ChangedFilesPanel.vue
Normal file
58
packages/web/src/components/workspace/ChangedFilesPanel.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col border-t border-zinc-800">
|
||||||
|
<div class="flex items-center gap-1 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">
|
||||||
|
<GitCompare :size="13" /> {{ t('workspace.changes') }}
|
||||||
|
<span v-if="changes.length" class="rounded bg-zinc-800 px-1 text-[10px] text-zinc-400">{{ changes.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="min-h-0 flex-1 overflow-auto px-1 pb-2">
|
||||||
|
<p v-if="changes.length === 0" class="px-2 py-1 text-xs text-zinc-600">{{ t('workspace.noChanges') }}</p>
|
||||||
|
<button
|
||||||
|
v-for="c in changes"
|
||||||
|
:key="c.path"
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-2 rounded px-2 py-0.5 text-left text-xs hover:bg-zinc-800/60"
|
||||||
|
:class="active === c.path ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-300'"
|
||||||
|
:title="c.renamedFrom ? `${c.renamedFrom} → ${c.path}` : c.path"
|
||||||
|
@click="emit('select', { file: c.path, staged: c.staged && !c.unstaged })"
|
||||||
|
>
|
||||||
|
<span class="w-3 shrink-0 text-center font-mono font-bold" :class="statusTone(c)">{{ statusLetter(c) }}</span>
|
||||||
|
<span class="min-w-0 flex-1 truncate font-mono">{{ basename(c.path) }}<span class="text-zinc-600">{{ dir(c.path) }}</span></span>
|
||||||
|
<span v-if="c.staged" class="shrink-0 text-emerald-500" :title="t('git.staged')">●</span>
|
||||||
|
<span v-if="c.insertions != null && c.insertions > 0" class="shrink-0 text-emerald-500">+{{ c.insertions }}</span>
|
||||||
|
<span v-if="c.deletions != null && c.deletions > 0" class="shrink-0 text-rose-500">−{{ c.deletions }}</span>
|
||||||
|
</button>
|
||||||
|
<p v-if="truncated" class="px-2 py-1 text-[11px] text-amber-500">{{ t('workspace.changesTruncated') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { GitCompare } from '@lucide/vue';
|
||||||
|
import type { FileChange } from '@arboretum/shared';
|
||||||
|
|
||||||
|
defineProps<{ changes: FileChange[]; active: string | null; truncated: boolean }>();
|
||||||
|
const emit = defineEmits<{ select: [sel: { file: string; staged: boolean }] }>();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
function statusLetter(c: FileChange): string {
|
||||||
|
if (c.conflicted) return 'U';
|
||||||
|
if (c.untracked) return '?';
|
||||||
|
const x = c.worktreeStatus !== '.' && c.worktreeStatus !== ' ' && c.worktreeStatus ? c.worktreeStatus : c.indexStatus;
|
||||||
|
return (x || 'M').toUpperCase();
|
||||||
|
}
|
||||||
|
function statusTone(c: FileChange): string {
|
||||||
|
if (c.conflicted) return 'text-rose-400';
|
||||||
|
if (c.untracked) return 'text-zinc-400';
|
||||||
|
const l = statusLetter(c);
|
||||||
|
return l === 'A' ? 'text-emerald-400' : l === 'D' ? 'text-rose-400' : l === 'R' ? 'text-sky-400' : 'text-amber-400';
|
||||||
|
}
|
||||||
|
function basename(p: string): string {
|
||||||
|
const i = p.lastIndexOf('/');
|
||||||
|
return i >= 0 ? p.slice(i + 1) : p;
|
||||||
|
}
|
||||||
|
function dir(p: string): string {
|
||||||
|
const i = p.lastIndexOf('/');
|
||||||
|
return i >= 0 ? ` ${p.slice(0, i)}` : '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
76
packages/web/src/components/workspace/DiffViewer.vue
Normal file
76
packages/web/src/components/workspace/DiffViewer.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<p v-if="loading" class="p-3 text-xs text-zinc-500">{{ t('fs.loading') }}</p>
|
||||||
|
<p v-else-if="error" class="p-3 text-xs text-rose-400">{{ error }}</p>
|
||||||
|
<p v-else-if="binary" class="p-3 text-xs text-zinc-500">{{ t('diff.binary') }}</p>
|
||||||
|
<p v-else-if="tooLarge" class="p-3 text-xs text-amber-500">{{ t('diff.tooLarge') }}</p>
|
||||||
|
<p v-else-if="parsed.hunks.length === 0" class="p-3 text-xs text-zinc-500">{{ t('diff.noChanges') }}</p>
|
||||||
|
<div v-else class="min-h-0 flex-1 overflow-auto">
|
||||||
|
<div class="flex items-center gap-3 border-b border-zinc-800 px-3 py-1 text-[11px] text-zinc-500">
|
||||||
|
<span class="text-emerald-500">+{{ parsed.additions }}</span>
|
||||||
|
<span class="text-rose-500">−{{ parsed.deletions }}</span>
|
||||||
|
<span v-if="staged" class="rounded bg-emerald-950 px-1 text-emerald-400">{{ t('git.staged') }}</span>
|
||||||
|
</div>
|
||||||
|
<table class="w-full border-collapse font-mono text-xs leading-5">
|
||||||
|
<tbody>
|
||||||
|
<template v-for="(h, hi) in parsed.hunks" :key="hi">
|
||||||
|
<tr class="bg-zinc-900/60 text-sky-300/80">
|
||||||
|
<td class="select-none px-2 text-right text-zinc-700" />
|
||||||
|
<td class="select-none px-2 text-right text-zinc-700" />
|
||||||
|
<td class="whitespace-pre px-2">{{ h.header }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="(l, li) in h.lines" :key="`${hi}-${li}`" :class="rowClass(l.type)">
|
||||||
|
<td class="w-10 select-none px-2 text-right text-zinc-600">{{ l.oldLine ?? '' }}</td>
|
||||||
|
<td class="w-10 select-none px-2 text-right text-zinc-600">{{ l.newLine ?? '' }}</td>
|
||||||
|
<td class="whitespace-pre px-2">{{ marker(l.type) }}{{ l.content }}</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { gitApi } from '../../lib/git-api';
|
||||||
|
import { parseUnifiedDiff, type DiffLineType, type ParsedDiff } from '../../lib/diff-parse';
|
||||||
|
|
||||||
|
const props = defineProps<{ repoId: string; wt: string; file: string; staged: boolean; version: number }>();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const binary = ref(false);
|
||||||
|
const tooLarge = ref(false);
|
||||||
|
const parsed = ref<ParsedDiff>({ hunks: [], additions: 0, deletions: 0 });
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
binary.value = false;
|
||||||
|
tooLarge.value = false;
|
||||||
|
try {
|
||||||
|
const res = await gitApi.diff(props.repoId, props.wt, props.file, props.staged);
|
||||||
|
binary.value = res.binary;
|
||||||
|
tooLarge.value = res.tooLarge;
|
||||||
|
parsed.value = res.binary || res.tooLarge ? { hunks: [], additions: 0, deletions: 0 } : parseUnifiedDiff(res.diff);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.repoId, props.wt, props.file, props.staged, props.version], load, { immediate: true });
|
||||||
|
|
||||||
|
function rowClass(type: DiffLineType): string {
|
||||||
|
if (type === 'add') return 'bg-emerald-950/40 text-emerald-200';
|
||||||
|
if (type === 'del') return 'bg-rose-950/40 text-rose-200';
|
||||||
|
return 'text-zinc-300';
|
||||||
|
}
|
||||||
|
function marker(type: DiffLineType): string {
|
||||||
|
return type === 'add' ? '+' : type === 'del' ? '-' : ' ';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
57
packages/web/src/components/workspace/FileTree.vue
Normal file
57
packages/web/src/components/workspace/FileTree.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<div class="flex items-center gap-1 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">
|
||||||
|
<FolderTree :size="13" /> {{ t('workspace.files') }}
|
||||||
|
<button type="button" class="ml-auto rounded p-0.5 hover:bg-zinc-800 hover:text-zinc-300" :title="t('common.refresh')" @click="load">
|
||||||
|
<RefreshCw :size="12" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="min-h-0 flex-1 overflow-auto px-1 pb-2">
|
||||||
|
<p v-if="loading" class="px-2 py-1 text-xs text-zinc-600">{{ t('fs.loading') }}</p>
|
||||||
|
<p v-else-if="error" class="px-2 py-1 text-xs text-rose-400">{{ error }}</p>
|
||||||
|
<p v-else-if="entries.length === 0" class="px-2 py-1 text-xs text-zinc-600">{{ t('fs.empty') }}</p>
|
||||||
|
<FileTreeNode
|
||||||
|
v-for="e in entries"
|
||||||
|
:key="e.path"
|
||||||
|
:entry="e"
|
||||||
|
:wt="wt"
|
||||||
|
:depth="0"
|
||||||
|
:active="active"
|
||||||
|
@open="(p) => emit('open', p)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { FolderTree, RefreshCw } from '@lucide/vue';
|
||||||
|
import type { FsEntry, FsListResponse } from '@arboretum/shared';
|
||||||
|
import { api } from '../../lib/api';
|
||||||
|
import FileTreeNode from './FileTreeNode.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ wt: string; active: string | null }>();
|
||||||
|
const emit = defineEmits<{ open: [relPath: string] }>();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const entries = ref<FsEntry[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const res = await api.get<FsListResponse>(`/api/v1/fs/list?path=${encodeURIComponent(props.wt)}&includeFiles=1`);
|
||||||
|
entries.value = [...res.entries].sort((a, b) => Number(!!a.isFile) - Number(!!b.isFile) || a.name.localeCompare(b.name));
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
watch(() => props.wt, load);
|
||||||
|
</script>
|
||||||
82
packages/web/src/components/workspace/FileTreeNode.vue
Normal file
82
packages/web/src/components/workspace/FileTreeNode.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center gap-1 rounded py-0.5 pr-2 text-left text-xs hover:bg-zinc-800/60"
|
||||||
|
:class="isActive ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-300'"
|
||||||
|
:style="{ paddingLeft: `${depth * 12 + 6}px` }"
|
||||||
|
:title="entry.name"
|
||||||
|
@click="onClick"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="entry.isFile ? File : expanded ? ChevronDown : ChevronRight"
|
||||||
|
:size="14"
|
||||||
|
class="shrink-0 text-zinc-500"
|
||||||
|
/>
|
||||||
|
<span class="truncate font-mono">{{ entry.name }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<template v-if="!entry.isFile && expanded">
|
||||||
|
<p v-if="loading" class="py-0.5 text-xs text-zinc-600" :style="{ paddingLeft: `${(depth + 1) * 12 + 6}px` }">…</p>
|
||||||
|
<p v-else-if="error" class="py-0.5 text-xs text-rose-400" :style="{ paddingLeft: `${(depth + 1) * 12 + 6}px` }">{{ error }}</p>
|
||||||
|
<FileTreeNode
|
||||||
|
v-for="c in children"
|
||||||
|
:key="c.path"
|
||||||
|
:entry="c"
|
||||||
|
:wt="wt"
|
||||||
|
:depth="depth + 1"
|
||||||
|
:active="active"
|
||||||
|
@open="(p) => emit('open', p)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { ChevronDown, ChevronRight, File } from '@lucide/vue';
|
||||||
|
import type { FsEntry, FsListResponse } from '@arboretum/shared';
|
||||||
|
import { api } from '../../lib/api';
|
||||||
|
|
||||||
|
const props = defineProps<{ entry: FsEntry; wt: string; depth: number; active: string | null }>();
|
||||||
|
const emit = defineEmits<{ open: [relPath: string] }>();
|
||||||
|
|
||||||
|
const expanded = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const children = ref<FsEntry[]>([]);
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
// chemin relatif au worktree (POSIX) — clé utilisée par l'API fichiers / diff.
|
||||||
|
const relPath = computed(() => (props.entry.path.startsWith(props.wt) ? props.entry.path.slice(props.wt.length + 1) : props.entry.name));
|
||||||
|
const isActive = computed(() => props.entry.isFile && props.active === relPath.value);
|
||||||
|
|
||||||
|
async function loadChildren(): Promise<void> {
|
||||||
|
if (loaded) return;
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
const res = await api.get<FsListResponse>(`/api/v1/fs/list?path=${encodeURIComponent(props.entry.path)}&includeFiles=1`);
|
||||||
|
children.value = sortEntries(res.entries);
|
||||||
|
loaded = true;
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(): void {
|
||||||
|
if (props.entry.isFile) {
|
||||||
|
emit('open', relPath.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expanded.value = !expanded.value;
|
||||||
|
if (expanded.value) void loadChildren();
|
||||||
|
}
|
||||||
|
|
||||||
|
// dossiers d'abord, puis fichiers, tri alphabétique.
|
||||||
|
function sortEntries(entries: FsEntry[]): FsEntry[] {
|
||||||
|
return [...entries].sort((a, b) => Number(!!a.isFile) - Number(!!b.isFile) || a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
27
packages/web/src/components/workspace/GitStatusBadge.vue
Normal file
27
packages/web/src/components/workspace/GitStatusBadge.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<span class="flex items-center gap-2 text-xs">
|
||||||
|
<span class="flex items-center gap-1 font-mono text-zinc-300">
|
||||||
|
<GitBranch :size="13" />{{ branch ?? t('worktrees.detached') }}
|
||||||
|
</span>
|
||||||
|
<span v-if="git.ahead" class="text-emerald-400" :title="t('git.ahead')">↑{{ git.ahead }}</span>
|
||||||
|
<span v-if="git.behind" class="text-amber-400" :title="t('git.behind')">↓{{ git.behind }}</span>
|
||||||
|
<span v-if="git.stagedCount" class="text-emerald-400" :title="t('git.staged')">●{{ git.stagedCount }}</span>
|
||||||
|
<span v-if="git.unstagedCount" class="text-amber-400" :title="t('git.unstaged')">○{{ git.unstagedCount }}</span>
|
||||||
|
<span v-if="git.conflictCount" class="text-rose-400" :title="t('git.conflicts')">⚠{{ git.conflictCount }}</span>
|
||||||
|
<span v-if="isClean" class="text-zinc-600">{{ t('worktrees.clean') }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { GitBranch } from '@lucide/vue';
|
||||||
|
import type { WorktreeGitStatus } from '@arboretum/shared';
|
||||||
|
|
||||||
|
const props = defineProps<{ git: WorktreeGitStatus; branch: string | null }>();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const isClean = computed(
|
||||||
|
() => !props.git.dirtyCount && !props.git.ahead && !props.git.behind && !props.git.stagedCount && !props.git.unstagedCount,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
146
packages/web/src/components/workspace/MonacoEditor.vue
Normal file
146
packages/web/src/components/workspace/MonacoEditor.vue
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
|
<!-- barre d'outils de l'éditeur -->
|
||||||
|
<div class="flex items-center gap-2 border-b border-zinc-800 px-3 py-1 text-xs">
|
||||||
|
<span class="min-w-0 flex-1 truncate font-mono text-zinc-400" :title="file">{{ file }}</span>
|
||||||
|
<span v-if="dirty" class="text-amber-400" :title="t('editor.unsaved')">●</span>
|
||||||
|
<BaseButton size="sm" variant="ghost" :icon="Save" :loading="saving" :disabled="!dirty || !ready" @click="save">
|
||||||
|
{{ t('editor.save') }}
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- bannière de conflit (le fichier a changé sur le disque depuis le chargement) -->
|
||||||
|
<div v-if="conflict" class="flex flex-wrap items-center gap-2 border-b border-amber-700/40 bg-amber-950/30 px-3 py-1.5 text-xs text-amber-200">
|
||||||
|
<TriangleAlert :size="14" /> {{ t('editor.conflict') }}
|
||||||
|
<div class="ml-auto flex items-center gap-2">
|
||||||
|
<BaseButton size="sm" variant="ghost" @click="reload">{{ t('editor.reload') }}</BaseButton>
|
||||||
|
<BaseButton size="sm" variant="danger" :loading="saving" @click="overwrite">{{ t('editor.overwrite') }}</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="loadError" class="px-3 py-2 text-xs text-rose-400">{{ loadError }}</p>
|
||||||
|
<!-- conteneur Monaco -->
|
||||||
|
<div ref="host" class="min-h-0 flex-1" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { Save, TriangleAlert } from '@lucide/vue';
|
||||||
|
import type * as Monaco from 'monaco-editor';
|
||||||
|
import { gitApi } from '../../lib/git-api';
|
||||||
|
import { ApiError } from '../../lib/api';
|
||||||
|
import { loadMonaco } from '../../composables/useMonaco';
|
||||||
|
import BaseButton from '../ui/BaseButton.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ repoId: string; wt: string; file: string }>();
|
||||||
|
const emit = defineEmits<{ saved: [] }>();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const host = ref<HTMLElement | null>(null);
|
||||||
|
const ready = ref(false);
|
||||||
|
const dirty = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const conflict = ref(false);
|
||||||
|
const loadError = ref<string | null>(null);
|
||||||
|
|
||||||
|
let monaco: typeof Monaco | null = null;
|
||||||
|
const editor = shallowRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||||
|
let model: Monaco.editor.ITextModel | null = null;
|
||||||
|
let baseMtime: number | undefined;
|
||||||
|
let savedContent = '';
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
monaco = await loadMonaco();
|
||||||
|
if (disposed || !host.value) return;
|
||||||
|
editor.value = monaco.editor.create(host.value, {
|
||||||
|
value: '',
|
||||||
|
theme: 'vs-dark',
|
||||||
|
automaticLayout: true,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
wordWrap: 'on',
|
||||||
|
fontSize: 13,
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
tabSize: 2,
|
||||||
|
});
|
||||||
|
editor.value.onDidChangeModelContent(() => {
|
||||||
|
dirty.value = editor.value?.getValue() !== savedContent;
|
||||||
|
});
|
||||||
|
// Ctrl/Cmd+S : sauvegarde. Monaco n'intercepte que si l'éditeur a le focus → pas de capture globale.
|
||||||
|
editor.value.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => void save());
|
||||||
|
ready.value = true;
|
||||||
|
await loadFile();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadFile(): Promise<void> {
|
||||||
|
if (!monaco || !editor.value) return;
|
||||||
|
loadError.value = null;
|
||||||
|
conflict.value = false;
|
||||||
|
try {
|
||||||
|
const res = await gitApi.readFile(props.repoId, props.wt, props.file);
|
||||||
|
if (disposed) return;
|
||||||
|
baseMtime = res.mtime;
|
||||||
|
savedContent = res.content;
|
||||||
|
const next = monaco.editor.createModel(res.content, res.language);
|
||||||
|
editor.value.setModel(next);
|
||||||
|
model?.dispose();
|
||||||
|
model = next;
|
||||||
|
dirty.value = false;
|
||||||
|
} catch (err) {
|
||||||
|
loadError.value = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(): Promise<void> {
|
||||||
|
if (!editor.value || saving.value || !dirty.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
conflict.value = false;
|
||||||
|
try {
|
||||||
|
const content = editor.value.getValue();
|
||||||
|
const res = await gitApi.writeFile(props.repoId, props.wt, props.file, content, baseMtime);
|
||||||
|
baseMtime = res.mtime;
|
||||||
|
savedContent = content;
|
||||||
|
dirty.value = false;
|
||||||
|
emit('saved');
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.code === 'STALE_FILE') conflict.value = true;
|
||||||
|
else loadError.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Écrase malgré le conflit (PUT sans baseMtime → le serveur ne vérifie plus la fraîcheur).
|
||||||
|
async function overwrite(): Promise<void> {
|
||||||
|
if (!editor.value || saving.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const content = editor.value.getValue();
|
||||||
|
const res = await gitApi.writeFile(props.repoId, props.wt, props.file, content);
|
||||||
|
baseMtime = res.mtime;
|
||||||
|
savedContent = content;
|
||||||
|
dirty.value = false;
|
||||||
|
conflict.value = false;
|
||||||
|
emit('saved');
|
||||||
|
} catch (err) {
|
||||||
|
loadError.value = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recharge depuis le disque (abandonne les modifications locales).
|
||||||
|
async function reload(): Promise<void> {
|
||||||
|
await loadFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.repoId, props.wt, props.file], () => void loadFile());
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disposed = true;
|
||||||
|
model?.dispose();
|
||||||
|
editor.value?.dispose();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
10
packages/web/src/composables/useMonaco.ts
Normal file
10
packages/web/src/composables/useMonaco.ts
Normal file
@@ -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<typeof Monaco> | null = null;
|
||||||
|
|
||||||
|
export function loadMonaco(): Promise<typeof Monaco> {
|
||||||
|
monacoPromise ??= import('../lib/monaco-setup').then((m) => m.monaco);
|
||||||
|
return monacoPromise;
|
||||||
|
}
|
||||||
33
packages/web/src/composables/useWorkspaceLayout.ts
Normal file
33
packages/web/src/composables/useWorkspaceLayout.ts
Normal file
@@ -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<T>(key: string, initial: T): Ref<T> {
|
||||||
|
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<T>;
|
||||||
|
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<number>('arb.ws.leftWidth', 280);
|
||||||
|
const rightWidth = persistedRef<number>('arb.ws.rightWidth', 460);
|
||||||
|
// `string` (et non MobilePanel) pour rester compatible avec le v-model de SegmentedControl.
|
||||||
|
const mobilePanel = persistedRef<string>('arb.ws.mobilePanel', 'files');
|
||||||
|
return { leftWidth, rightWidth, mobilePanel };
|
||||||
|
}
|
||||||
@@ -202,6 +202,39 @@ export default {
|
|||||||
create: 'Create',
|
create: 'Create',
|
||||||
creating: 'Creating…',
|
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: {
|
terminal: {
|
||||||
observer: 'observer (read-only)',
|
observer: 'observer (read-only)',
|
||||||
sessionEnded: 'Session ended',
|
sessionEnded: 'Session ended',
|
||||||
|
|||||||
@@ -204,6 +204,39 @@ const fr: typeof en = {
|
|||||||
create: 'Créer',
|
create: 'Créer',
|
||||||
creating: 'Création…',
|
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: {
|
terminal: {
|
||||||
observer: 'observateur (lecture seule)',
|
observer: 'observateur (lecture seule)',
|
||||||
sessionEnded: 'Session terminée',
|
sessionEnded: 'Session terminée',
|
||||||
|
|||||||
71
packages/web/src/lib/diff-parse.ts
Normal file
71
packages/web/src/lib/diff-parse.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ export const gitApi = {
|
|||||||
readFile: (repoId: string, wt: string, path: string): Promise<FileContentResponse> =>
|
readFile: (repoId: string, wt: string, path: string): Promise<FileContentResponse> =>
|
||||||
api.get(`/api/v1/repos/${repoId}/files/content?wt=${enc(wt)}&path=${enc(path)}`),
|
api.get(`/api/v1/repos/${repoId}/files/content?wt=${enc(wt)}&path=${enc(path)}`),
|
||||||
|
|
||||||
writeFile: (repoId: string, wt: string, path: string, content: string): Promise<WriteFileResponse> =>
|
// baseMtime : mtime lu au chargement ; le serveur refuse en 409 STALE_FILE si le fichier a changé depuis.
|
||||||
api.put(`/api/v1/repos/${repoId}/files/content`, { wt, path, content }),
|
writeFile: (repoId: string, wt: string, path: string, content: string, baseMtime?: number): Promise<WriteFileResponse> =>
|
||||||
|
api.put(`/api/v1/repos/${repoId}/files/content`, { wt, path, content, ...(baseMtime != null ? { baseMtime } : {}) }),
|
||||||
};
|
};
|
||||||
|
|||||||
13
packages/web/src/lib/monaco-setup.ts
Normal file
13
packages/web/src/lib/monaco-setup.ts
Normal file
@@ -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 };
|
||||||
16
packages/web/src/lib/wt-key.ts
Normal file
16
packages/web/src/lib/wt-key.ts
Normal file
@@ -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)));
|
||||||
|
}
|
||||||
@@ -2,10 +2,11 @@ import { createRouter, createWebHistory } from 'vue-router';
|
|||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
|
||||||
// Type du layout choisi par App.vue : 'bare' (login, sans coquille),
|
// 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' {
|
declare module 'vue-router' {
|
||||||
interface RouteMeta {
|
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: '/', name: 'dashboard', component: () => import('../views/DashboardView.vue'), meta: { layout: 'shell' } },
|
||||||
{ path: '/sessions', name: 'sessions', component: () => import('../views/SessionsListView.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: '/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', name: 'groups', component: () => import('../views/GroupsListView.vue'), meta: { layout: 'shell' } },
|
||||||
{ path: '/groups/:id', name: 'group', component: () => import('../views/GroupView.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' } },
|
{ path: '/settings', name: 'settings', component: () => import('../views/SettingsView.vue'), meta: { layout: 'shell' } },
|
||||||
|
|||||||
212
packages/web/src/views/WorkspaceView.vue
Normal file
212
packages/web/src/views/WorkspaceView.vue
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-dvh min-h-0 flex-col bg-[#09090b] text-zinc-200">
|
||||||
|
<!-- en-tête IDE -->
|
||||||
|
<header class="flex items-center gap-3 border-b border-zinc-800 px-3 py-1.5">
|
||||||
|
<BaseButton size="sm" variant="ghost" :icon="ChevronLeft" :to="{ name: 'dashboard' }">{{ t('common.back') }}</BaseButton>
|
||||||
|
<span class="truncate text-sm font-medium text-zinc-200">{{ repoLabel }}</span>
|
||||||
|
<GitStatusBadge v-if="worktree" :git="worktree.git" :branch="worktree.branch" />
|
||||||
|
<span class="ml-auto" />
|
||||||
|
<!-- bascule de panneau sur mobile -->
|
||||||
|
<SegmentedControl v-model="layout.mobilePanel.value" :options="mobileOptions" class="md:hidden" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- corps : 3 colonnes (desktop) / panneau unique (mobile) -->
|
||||||
|
<div class="flex min-h-0 flex-1">
|
||||||
|
<!-- colonne gauche : arbre + changements -->
|
||||||
|
<aside
|
||||||
|
class="flex min-h-0 flex-col border-r border-zinc-800"
|
||||||
|
:class="mobileShow('files') ? 'flex w-full md:flex' : 'hidden md:flex'"
|
||||||
|
:style="desktopWidth(layout.leftWidth.value)"
|
||||||
|
>
|
||||||
|
<div class="min-h-0 flex-1 overflow-hidden">
|
||||||
|
<FileTree :wt="wtPath" :active="openFile" @open="onOpenFile" />
|
||||||
|
</div>
|
||||||
|
<div class="min-h-0 flex-1 overflow-hidden">
|
||||||
|
<ChangedFilesPanel :changes="changes" :active="openFile" :truncated="truncated" @select="onSelectChange" />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="hidden w-1 cursor-col-resize bg-transparent hover:bg-emerald-700/40 md:block" @mousedown.prevent="startResize('left', $event)" />
|
||||||
|
|
||||||
|
<!-- colonne centrale : éditeur / diff -->
|
||||||
|
<main
|
||||||
|
class="flex min-h-0 flex-1 flex-col"
|
||||||
|
:class="mobileShow('editor') || mobileShow('diff') ? 'flex' : 'hidden md:flex'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 border-b border-zinc-800 px-2 py-1">
|
||||||
|
<SegmentedControl v-if="openFile" v-model="centerTab" :options="centerOptions" />
|
||||||
|
<span v-else class="px-1 text-xs text-zinc-600">{{ t('workspace.noFileOpen') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="min-h-0 flex-1">
|
||||||
|
<MonacoEditor
|
||||||
|
v-if="openFile && showEditor"
|
||||||
|
:key="`ed:${openFile}`"
|
||||||
|
:repo-id="repoId"
|
||||||
|
:wt="wtPath"
|
||||||
|
:file="openFile"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
<DiffViewer
|
||||||
|
v-else-if="openFile && showDiff"
|
||||||
|
:key="`df:${openFile}`"
|
||||||
|
:repo-id="repoId"
|
||||||
|
:wt="wtPath"
|
||||||
|
:file="openFile"
|
||||||
|
:staged="diffStaged"
|
||||||
|
:version="changesVersion"
|
||||||
|
/>
|
||||||
|
<EmptyState v-else :icon="FileCode" :title="t('workspace.noFileOpen')" :hint="t('workspace.noFileHint')" class="m-4" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<div class="hidden w-1 cursor-col-resize bg-transparent hover:bg-emerald-700/40 md:block" @mousedown.prevent="startResize('right', $event)" />
|
||||||
|
|
||||||
|
<!-- colonne droite : terminal de la session corrélée -->
|
||||||
|
<section
|
||||||
|
class="flex min-h-0 flex-col border-l border-zinc-800"
|
||||||
|
:class="mobileShow('terminal') ? 'flex w-full md:flex' : 'hidden md:flex'"
|
||||||
|
:style="desktopWidth(layout.rightWidth.value)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1 border-b border-zinc-800 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">
|
||||||
|
<SquareTerminal :size="13" /> {{ t('workspace.terminal') }}
|
||||||
|
</div>
|
||||||
|
<div class="min-h-0 flex-1">
|
||||||
|
<TerminalView v-if="termSession" :key="termSession.id" :session-id="termSession.id" :mode="termSession.attachable ? 'interactive' : 'observer'" />
|
||||||
|
<EmptyState v-else :icon="SquareTerminal" :title="t('workspace.noSession')" :hint="t('workspace.noSessionHint')" class="m-4" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { ChevronLeft, FileCode, Files, GitCompare, SquareTerminal } from '@lucide/vue';
|
||||||
|
import type { FileChange } from '@arboretum/shared';
|
||||||
|
import { useWorktreesStore } from '../stores/worktrees';
|
||||||
|
import { useSessionsStore } from '../stores/sessions';
|
||||||
|
import { wsClient } from '../lib/ws-client';
|
||||||
|
import { gitApi } from '../lib/git-api';
|
||||||
|
import { decodeWtKey } from '../lib/wt-key';
|
||||||
|
import { useWorkspaceLayout, type MobilePanel } from '../composables/useWorkspaceLayout';
|
||||||
|
import GitStatusBadge from '../components/workspace/GitStatusBadge.vue';
|
||||||
|
import FileTree from '../components/workspace/FileTree.vue';
|
||||||
|
import ChangedFilesPanel from '../components/workspace/ChangedFilesPanel.vue';
|
||||||
|
import DiffViewer from '../components/workspace/DiffViewer.vue';
|
||||||
|
import MonacoEditor from '../components/workspace/MonacoEditor.vue';
|
||||||
|
import TerminalView from '../components/TerminalView.vue';
|
||||||
|
import SegmentedControl from '../components/ui/SegmentedControl.vue';
|
||||||
|
import BaseButton from '../components/ui/BaseButton.vue';
|
||||||
|
import EmptyState from '../components/ui/EmptyState.vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const worktrees = useWorktreesStore();
|
||||||
|
const sessions = useSessionsStore();
|
||||||
|
const layout = useWorkspaceLayout();
|
||||||
|
|
||||||
|
const repoId = route.params.repoId as string;
|
||||||
|
const wtPath = decodeWtKey(route.params.wt as string);
|
||||||
|
|
||||||
|
const openFile = ref<string | null>(null);
|
||||||
|
const centerTab = ref('editor'); // 'editor' | 'diff' (string pour le v-model de SegmentedControl)
|
||||||
|
const diffStaged = ref(false);
|
||||||
|
const changes = ref<FileChange[]>([]);
|
||||||
|
const truncated = ref(false);
|
||||||
|
const changesVersion = ref(0);
|
||||||
|
|
||||||
|
const showEditor = computed(() => centerTab.value === 'editor');
|
||||||
|
const showDiff = computed(() => centerTab.value === 'diff');
|
||||||
|
|
||||||
|
const worktree = computed(() => worktrees.worktrees.find((w) => w.repoId === repoId && w.path === wtPath));
|
||||||
|
const repoLabel = computed(() => worktrees.repos.find((r) => r.id === repoId)?.label ?? wtPath);
|
||||||
|
|
||||||
|
// session corrélée au worktree (par cwd) : on privilégie une session managée attachable.
|
||||||
|
const termSession = computed(() => {
|
||||||
|
const live = sessions.sessions.filter((s) => s.cwd === wtPath && s.live);
|
||||||
|
return live.find((s) => s.attachable) ?? live[0] ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mobileOptions = [
|
||||||
|
{ value: 'files', icon: Files },
|
||||||
|
{ value: 'editor', icon: FileCode },
|
||||||
|
{ value: 'diff', icon: GitCompare },
|
||||||
|
{ value: 'terminal', icon: SquareTerminal },
|
||||||
|
];
|
||||||
|
const centerOptions = computed(() => [
|
||||||
|
{ value: 'editor', label: t('workspace.editor') },
|
||||||
|
{ value: 'diff', label: t('workspace.diff') },
|
||||||
|
]);
|
||||||
|
const mobileShow = (p: MobilePanel): boolean => layout.mobilePanel.value === p;
|
||||||
|
const desktopWidth = (w: number): Record<string, string> => ({ width: `${w}px` });
|
||||||
|
|
||||||
|
function onOpenFile(rel: string): void {
|
||||||
|
openFile.value = rel;
|
||||||
|
centerTab.value = 'editor';
|
||||||
|
if (window.innerWidth < 768) layout.mobilePanel.value = 'editor';
|
||||||
|
}
|
||||||
|
function onSelectChange(sel: { file: string; staged: boolean }): void {
|
||||||
|
openFile.value = sel.file;
|
||||||
|
diffStaged.value = sel.staged;
|
||||||
|
centerTab.value = 'diff';
|
||||||
|
if (window.innerWidth < 768) layout.mobilePanel.value = 'diff';
|
||||||
|
}
|
||||||
|
function onSaved(): void {
|
||||||
|
void loadChanges();
|
||||||
|
changesVersion.value++; // force le DiffViewer à se rafraîchir
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChanges(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await gitApi.changes(repoId, wtPath);
|
||||||
|
changes.value = res.changes;
|
||||||
|
truncated.value = res.truncated;
|
||||||
|
} catch {
|
||||||
|
/* worktree transitoire / erreur réseau : on garde l'état précédent */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- redimensionnement des colonnes (desktop) ---
|
||||||
|
function startResize(which: 'left' | 'right', ev: MouseEvent): void {
|
||||||
|
const startX = ev.clientX;
|
||||||
|
const startW = which === 'left' ? layout.leftWidth.value : layout.rightWidth.value;
|
||||||
|
const onMove = (e: MouseEvent): void => {
|
||||||
|
const delta = which === 'left' ? e.clientX - startX : startX - e.clientX;
|
||||||
|
const next = Math.min(640, Math.max(160, startW + delta));
|
||||||
|
if (which === 'left') layout.leftWidth.value = next;
|
||||||
|
else layout.rightWidth.value = next;
|
||||||
|
};
|
||||||
|
const onUp = (): void => {
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
let unwatch: (() => void) | null = null;
|
||||||
|
onMounted(() => {
|
||||||
|
// l'AppShell n'est pas monté en layout 'ide' → on assure données + temps réel ici.
|
||||||
|
worktrees.startRealtime();
|
||||||
|
sessions.startRealtime();
|
||||||
|
if (worktrees.worktrees.length === 0) void worktrees.fetchAll();
|
||||||
|
else void worktrees.refreshRepoWorktrees(repoId);
|
||||||
|
if (sessions.sessions.length === 0) void sessions.fetchSessions();
|
||||||
|
void loadChanges();
|
||||||
|
// abonnement ciblé : un changement disque du worktree → rafraîchir changements + diff.
|
||||||
|
unwatch = wsClient.watchWorktree(repoId, wtPath, () => {
|
||||||
|
void loadChanges();
|
||||||
|
changesVersion.value++;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
unwatch?.();
|
||||||
|
// on laisse le temps réel des stores actif (modèle « abonné tant qu'authentifié », repris par AppShell).
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => route.params.wt, () => {
|
||||||
|
openFile.value = null;
|
||||||
|
void loadChanges();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
60
packages/web/test/diff-parse.test.ts
Normal file
60
packages/web/test/diff-parse.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
packages/web/test/wt-key.test.ts
Normal file
24
packages/web/test/wt-key.test.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,15 @@ const DAEMON_WS = 'ws://127.0.0.1:7317';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue(), tailwindcss()],
|
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: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
// L'Origin est réécrite vers celle du daemon : son check Origin strict
|
// L'Origin est réécrite vers celle du daemon : son check Origin strict
|
||||||
|
|||||||
Reference in New Issue
Block a user