/** * CodeMirror 6 Editor for Ansible Playbooks * * Features: * - YAML syntax highlighting * - Line numbers * - Current line highlighting * - Lint gutter markers * - Dark theme (One Dark) */ import { EditorState, StateField, StateEffect, RangeSet } from '@codemirror/state'; import { EditorView, lineNumbers, highlightActiveLine, highlightActiveLineGutter, keymap, Decoration, gutter, GutterMarker } from '@codemirror/view'; import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; import { yaml } from '@codemirror/lang-yaml'; import { bracketMatching, indentOnInput, foldGutter, syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'; import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; import { oneDark } from '@codemirror/theme-one-dark'; // Effect to update lint markers const setLintMarkers = StateEffect.define(); // Lint gutter marker class class LintGutterMarker extends GutterMarker { constructor(issue, index) { super(); this.issue = issue; this.index = index; } toDOM() { const marker = document.createElement('div'); marker.className = `lint-gutter-marker lint-${this.issue.severity}`; marker.title = `#${this.index + 1}: ${this.issue.rule_id} - ${this.issue.message}`; const badge = document.createElement('span'); badge.className = 'lint-badge'; badge.textContent = String(this.index + 1); if (this.issue.severity === 'error') { badge.classList.add('error'); } else if (this.issue.severity === 'warning') { badge.classList.add('warning'); } else { badge.classList.add('info'); } marker.appendChild(badge); return marker; } } // State field for lint markers const lintMarkersField = StateField.define({ create() { return []; }, update(markers, tr) { for (let e of tr.effects) { if (e.is(setLintMarkers)) { return e.value; } } return markers; } }); // Lint gutter extension const lintGutter = gutter({ class: 'cm-lint-gutter', markers: view => { const markers = view.state.field(lintMarkersField); const result = []; for (let i = 0; i < markers.length; i++) { const issue = markers[i]; try { const lineNum = Math.min(issue.line, view.state.doc.lines); const line = view.state.doc.line(lineNum); result.push(new LintGutterMarker(issue, i).range(line.from)); } catch (e) { console.warn('Invalid line number:', issue.line); } } return RangeSet.of(result, true); }, initialSpacer: () => { const spacer = document.createElement('div'); spacer.className = 'lint-gutter-spacer'; spacer.textContent = '99'; return spacer; } }); // Custom theme additions const editorTheme = EditorView.theme({ '&': { height: '100%', fontSize: '14px', backgroundColor: '#1e1e1e' }, '.cm-scroller': { fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace", lineHeight: '1.6' }, '.cm-gutters': { backgroundColor: '#252526', borderRight: '1px solid #3c3c3c', color: '#858585' }, '.cm-activeLineGutter': { backgroundColor: '#2a2d2e' }, '.cm-activeLine': { backgroundColor: 'rgba(139, 92, 246, 0.1)' }, '.cm-lint-gutter': { width: '28px', backgroundColor: '#252526' }, '.lint-gutter-marker': { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }, '.lint-badge': { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: '18px', height: '18px', borderRadius: '50%', fontSize: '10px', fontWeight: 'bold', color: 'white' }, '.lint-badge.error': { backgroundColor: '#ef4444' }, '.lint-badge.warning': { backgroundColor: '#f59e0b' }, '.lint-badge.info': { backgroundColor: '#3b82f6' }, '.lint-gutter-spacer': { visibility: 'hidden', width: '28px' }, '.cm-selectionBackground': { backgroundColor: 'rgba(139, 92, 246, 0.3) !important' }, '&.cm-focused .cm-selectionBackground': { backgroundColor: 'rgba(139, 92, 246, 0.4) !important' }, '.cm-cursor': { borderLeftColor: '#a78bfa' } }); // PlaybookEditor class class PlaybookEditor { constructor() { this.view = null; this.state = { filename: '', originalContent: '', isDirty: false, isNew: false, initialized: false, lintStatus: 'idle', lintResults: null, qualityScore: null }; } init(containerId, content, filename, isNew = false) { console.log('[CMEditor] Initializing for:', filename); this.state.filename = filename; this.state.originalContent = content; this.state.isNew = isNew; this.state.isDirty = false; this.state.lintStatus = 'idle'; this.state.lintResults = null; this.state.qualityScore = null; const container = document.getElementById(containerId); if (!container) { console.error('[CMEditor] Container not found:', containerId); return; } // Clear container container.innerHTML = ''; // Create editor const extensions = [ lineNumbers(), highlightActiveLine(), highlightActiveLineGutter(), history(), bracketMatching(), indentOnInput(), foldGutter(), highlightSelectionMatches(), keymap.of([ ...defaultKeymap, ...historyKeymap, ...searchKeymap, indentWithTab ]), yaml(), oneDark, editorTheme, lintMarkersField, lintGutter, EditorView.updateListener.of(update => { if (update.docChanged) { this.onContentChange(); } }), EditorView.lineWrapping ]; const startState = EditorState.create({ doc: content, extensions }); this.view = new EditorView({ state: startState, parent: container }); this.state.initialized = true; console.log('[CMEditor] Initialized successfully'); } getContent() { if (this.view) { return this.view.state.doc.toString(); } return ''; } setContent(content) { if (this.view) { this.view.dispatch({ changes: { from: 0, to: this.view.state.doc.length, insert: content } }); } } onContentChange() { const content = this.getContent(); this.state.isDirty = content !== this.state.originalContent; this.updateTitle(); this.validateYaml(content); } validateYaml(content) { const statusEl = document.getElementById('yaml-validation-status'); if (!statusEl) return; const errors = []; const lines = content.split('\n'); lines.forEach((line, index) => { if (line.includes('\t')) { errors.push({ line: index + 1, message: 'Utilisez des espaces au lieu des tabs' }); } const leadingSpaces = (line.match(/^(\s*)/) || ['', ''])[1].length; if (leadingSpaces % 2 !== 0 && line.trim()) { errors.push({ line: index + 1, message: 'Indentation impaire détectée' }); } }); if (errors.length > 0) { statusEl.innerHTML = ` Ligne ${errors[0].line}: ${errors[0].message} `; } else { statusEl.innerHTML = ` YAML valide `; } } updateTitle() { const titleEl = document.querySelector('.editor-title'); if (titleEl) { const dirtyIndicator = this.state.isDirty ? ' •' : ''; titleEl.textContent = this.state.filename + dirtyIndicator; } } goToLine(lineNumber) { if (!this.view) return; try { const lineNum = Math.min(lineNumber, this.view.state.doc.lines); const line = this.view.state.doc.line(lineNum); this.view.dispatch({ selection: { anchor: line.from, head: line.to }, scrollIntoView: true }); this.view.focus(); // Switch to editor tab if (window.dashboard?.switchEditorTab) { window.dashboard.switchEditorTab('editor'); } } catch (e) { console.error('[CMEditor] goToLine error:', e); } } setLintMarkers(issues) { if (!this.view) return; this.view.dispatch({ effects: setLintMarkers.of(issues || []) }); } async runLint() { const content = this.getContent(); if (!content.trim()) { console.warn('[CMEditor] Cannot lint empty content'); return; } console.log('[CMEditor] Running lint for:', this.state.filename); this.setLintStatus('running'); try { const token = localStorage.getItem('accessToken'); const headers = { 'Content-Type': 'application/json' }; if (token) { headers['Authorization'] = `Bearer ${token}`; } const response = await fetch(`/api/playbooks/${encodeURIComponent(this.state.filename)}/lint`, { method: 'POST', headers, body: JSON.stringify({ content }) }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const result = await response.json(); console.log('[CMEditor] Lint result:', result); this.state.lintResults = result; this.state.qualityScore = result.quality_score; // Persist last lint result (for playbooks list UI) if (window.dashboard?.storePlaybookLintResult) { window.dashboard.storePlaybookLintResult(this.state.filename, result); } // Update lint markers in gutter this.setLintMarkers(result.issues); // Determine status if (result.summary.errors > 0) { this.setLintStatus('errors'); } else if (result.summary.warnings > 0) { this.setLintStatus('warnings'); } else { this.setLintStatus('success'); } // Update UI this.updateQualityBadge(); this.updateProblemsPanel(); this.updateProblemsCount(); // Refresh playbooks list so score appears immediately if (window.dashboard?.renderPlaybooks) { window.dashboard.renderPlaybooks(); } } catch (error) { console.error('[CMEditor] Lint error:', error); this.setLintStatus('idle'); if (error.message.includes('503')) { window.dashboard?.showNotification('ansible-lint non disponible sur le serveur', 'warning'); } } } setLintStatus(status) { this.state.lintStatus = status; const btn = document.getElementById('lint-button'); if (!btn) return; const icons = { idle: '', running: '', success: '', warnings: '', errors: '' }; const labels = { idle: 'Lint', running: 'Analyse...', success: 'OK', warnings: '', errors: '' }; const count = this.state.lintResults?.summary?.total || 0; const label = labels[status] || ''; const countBadge = (status === 'warnings' || status === 'errors') && count > 0 ? `${count}` : ''; btn.innerHTML = `${icons[status]}${label ? ' ' + label : ''}${countBadge}`; btn.disabled = status === 'running'; btn.classList.remove('success', 'warnings', 'errors'); if (status === 'success' || status === 'warnings' || status === 'errors') { btn.classList.add(status); } } updateQualityBadge() { const badge = document.getElementById('quality-badge'); if (!badge || this.state.qualityScore === null) return; const score = this.state.qualityScore; let colorClass = 'bg-green-600'; if (score < 50) colorClass = 'bg-red-600'; else if (score < 70) colorClass = 'bg-yellow-600'; else if (score < 90) colorClass = 'bg-blue-600'; badge.innerHTML = ` Quality: ${score}/100 `; badge.style.display = 'block'; } updateProblemsCount() { const countEl = document.getElementById('problems-count'); if (countEl) { const count = this.state.lintResults?.summary?.total || 0; countEl.textContent = count; } } updateProblemsPanel() { const panel = document.getElementById('problems-panel'); if (!panel) return; const results = this.state.lintResults; if (!results || results.issues.length === 0) { panel.innerHTML = `
Aucun problème détecté
Temps: ${results?.execution_time_ms || 0}ms