- 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)
97 lines
5.3 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
}
|