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:
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)));
|
||||
}
|
||||
Reference in New Issue
Block a user