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:
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'),
|
||||
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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div class="flex h-dvh flex-col">
|
||||
<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 />
|
||||
</AppShell>
|
||||
<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>
|
||||
|
||||
<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
|
||||
v-for="s in visibleSessions"
|
||||
:key="s.id"
|
||||
@@ -118,6 +121,7 @@ import type { SessionSummary, WorktreeSummary } from '@arboretum/shared';
|
||||
import { useWorktreesStore } from '../stores/worktrees';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import { ApiError } from '../lib/api';
|
||||
import { encodeWtKey } from '../lib/wt-key';
|
||||
import SessionStateBadge from './SessionStateBadge.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',
|
||||
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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
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> =>
|
||||
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> =>
|
||||
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<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';
|
||||
|
||||
// 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' } },
|
||||
|
||||
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({
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user