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