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:
2026-06-27 13:47:10 +02:00
parent c8dd539571
commit 75efecf93f
26 changed files with 1142 additions and 6 deletions

View 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);
}

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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" />

View File

@@ -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';

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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;
}

View 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 };
}

View File

@@ -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',

View File

@@ -204,6 +204,39 @@ const fr: typeof en = {
create: 'Créer',
creating: 'Création…',
},
workspace: {
open: 'Ouvrir lIDE',
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 larbre 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',

View 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 };
}

View File

@@ -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 } : {}) }),
};

View 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 };

View 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)));
}

View File

@@ -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' } },

View 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>

View 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);
});
});

View 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 dURL 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
}
});
});

View File

@@ -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