Files
arboretum/packages/server/src/routes/files.ts
Johan LEROY 75efecf93f 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)
2026-06-27 13:47:10 +02:00

97 lines
5.3 KiB
TypeScript

// API fichiers (P7) pour l'éditeur Monaco : lecture/écriture du contenu d'un fichier, strictement
// bornées à un worktree ENREGISTRÉ (WorktreeManager.assertPathInWorktree : anti `..`, anti symlink
// sortant). Authentifiée par le hook preValidation global. Un terminal web est déjà du RCE par
// conception, mais on borne quand même (défense en profondeur + moindre étonnement).
import type { FastifyInstance } from 'fastify';
import { readFile, writeFile, stat, mkdir } from 'node:fs/promises';
import { dirname, extname } from 'node:path';
import type { FileContentResponse, WriteFileRequest, WriteFileResponse } from '@arboretum/shared';
import type { WorktreeManager } from '../core/worktree-manager.js';
import type { Db } from '../db/index.js';
import { recordAudit } from '../core/audit-log.js';
import { sendManagerError } from './repos.js';
const FILE_MAX_BYTES = 2 * 1024 * 1024;
/** Langage Monaco déduit de l'extension (sous-ensemble courant ; Monaco a un fallback 'plaintext'). */
const LANG_BY_EXT: Record<string, string> = {
'.ts': 'typescript', '.tsx': 'typescript', '.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
'.json': 'json', '.vue': 'vue', '.css': 'css', '.scss': 'scss', '.less': 'less', '.html': 'html', '.md': 'markdown',
'.py': 'python', '.rb': 'ruby', '.go': 'go', '.rs': 'rust', '.java': 'java', '.c': 'c', '.h': 'c', '.cpp': 'cpp', '.cc': 'cpp',
'.cs': 'csharp', '.php': 'php', '.sh': 'shell', '.bash': 'shell', '.zsh': 'shell', '.yml': 'yaml', '.yaml': 'yaml',
'.toml': 'toml', '.xml': 'xml', '.sql': 'sql', '.swift': 'swift', '.kt': 'kotlin', '.dart': 'dart', '.lua': 'lua',
};
function languageFor(path: string): string | undefined {
return LANG_BY_EXT[extname(path).toLowerCase()];
}
/** Détecte un fichier binaire par présence d'un octet NUL dans le buffer. */
function looksBinary(buf: Buffer): boolean {
const n = Math.min(buf.length, 8192);
for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
return false;
}
export function registerFileRoutes(app: FastifyInstance, wt: WorktreeManager, db: Db): void {
// Lecture du contenu d'un fichier (UTF-8) d'un worktree.
app.get('/api/v1/repos/:id/files/content', async (req, reply) => {
const { id } = req.params as { id: string };
const q = req.query as { wt?: string; path?: string };
if (typeof q.wt !== 'string' || q.wt === '' || typeof q.path !== 'string' || q.path === '') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'wt and path are required' } });
}
try {
const abs = await wt.assertPathInWorktree(id, q.wt, q.path);
const st = await stat(abs).catch(() => null);
if (!st || !st.isFile()) return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No such file' } });
if (st.size > FILE_MAX_BYTES) return reply.status(413).send({ error: { code: 'FILE_TOO_LARGE', message: `File exceeds ${FILE_MAX_BYTES} bytes` } });
const buf = await readFile(abs);
if (looksBinary(buf)) return reply.status(415).send({ error: { code: 'BINARY_FILE', message: 'Binary files are not editable' } });
const lang = languageFor(q.path);
const res: FileContentResponse = {
path: q.path,
content: buf.toString('utf-8'),
encoding: 'utf-8',
size: st.size,
mtime: st.mtimeMs,
...(lang ? { language: lang } : {}),
};
return reply.send(res);
} catch (err) {
return sendManagerError(reply, err);
}
});
// Écriture (création/modification) du contenu d'un fichier d'un worktree.
app.put('/api/v1/repos/:id/files/content', async (req, reply) => {
const { id } = req.params as { id: string };
const body = req.body as Partial<WriteFileRequest> | null;
if (!body || typeof body.wt !== 'string' || typeof body.path !== 'string' || typeof body.content !== 'string') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'wt, path and content are required' } });
}
if (Buffer.byteLength(body.content, 'utf-8') > FILE_MAX_BYTES) {
return reply.status(413).send({ error: { code: 'FILE_TOO_LARGE', message: `Content exceeds ${FILE_MAX_BYTES} bytes` } });
}
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'), mtime: after.mtimeMs };
return reply.send(res);
} catch (err) {
return sendManagerError(reply, err);
}
});
}