import { state } from './state.js'; import { api } from './auth.js'; import { openFile, showWelcome } from './viewer.js'; import { refreshSidebarForContext, refreshTagsForContext } from './sidebar.js'; import { createAIToolbar } from './ai.js'; // --------------------------------------------------------------------------- // File extension → Lucide icon mapping // --------------------------------------------------------------------------- const EXT_ICONS = { // Text files ".md": "file-text", ".txt": "file-text", ".log": "file-text", ".readme": "file-text", ".rst": "file-text", ".adoc": "file-text", // Web development ".html": "file-code", ".htm": "file-code", ".css": "file-code", ".scss": "file-code", ".sass": "file-code", ".less": "file-code", ".js": "file-code", ".jsx": "file-code", ".ts": "file-code", ".tsx": "file-code", ".vue": "file-code", ".svelte": "file-code", // Programming languages ".py": "file-code", ".java": "file-code", ".c": "file-code", ".cpp": "file-code", ".cc": "file-code", ".cxx": "file-code", ".h": "file-code", ".hpp": "file-code", ".cs": "file-code", ".go": "file-code", ".rs": "file-code", ".rb": "file-code", ".php": "file-code", ".swift": "file-code", ".kt": "file-code", ".scala": "file-code", ".r": "file-code", ".m": "file-code", ".pl": "file-code", ".lua": "file-code", ".dart": "file-code", ".nim": "file-code", ".zig": "file-code", ".odin": "file-code", ".v": "file-code", ".cr": "file-code", ".ex": "file-code", ".exs": "file-code", ".elm": "file-code", ".purs": "file-code", ".hs": "file-code", ".ml": "file-code", ".ocaml": "file-code", ".fs": "file-code", ".fsx": "file-code", ".vb": "file-code", ".pas": "file-code", ".pp": "file-code", ".inc": "file-code", // Data formats ".json": "file-json", ".yaml": "file-cog", ".yml": "file-cog", ".toml": "file-cog", ".xml": "file-code", ".csv": "table", ".tsv": "table", ".excalidraw": "pen-tool", ".sql": "database", ".db": "database", ".sqlite": "database", ".sqlite3": "database", ".parquet": "database", ".avro": "database", // Configuration files ".ini": "file-cog", ".cfg": "file-cog", ".conf": "file-cog", ".env": "file-cog", ".dockerfile": "file-cog", ".gitignore": "file-cog", ".gitattributes": "file-cog", ".editorconfig": "file-cog", ".eslintrc": "file-cog", ".prettierrc": "file-cog", ".babelrc": "file-cog", ".tsconfig": "file-cog", "package.json": "file-cog", "package-lock.json": "file-cog", "yarn.lock": "file-cog", "composer.json": "file-cog", "requirements.txt": "file-cog", "pipfile": "file-cog", "gemfile": "file-cog", "cargo.toml": "file-cog", "go.mod": "file-cog", "go.sum": "file-cog", "pom.xml": "file-cog", "build.gradle": "file-cog", "cmakelists.txt": "file-cog", "makefile": "file-cog", // Shell scripts ".sh": "terminal", ".bash": "terminal", ".zsh": "terminal", ".fish": "terminal", ".bat": "terminal", ".cmd": "terminal", ".ps1": "terminal", ".psm1": "terminal", ".psd1": "terminal", // Document formats ".pdf": "file-text", ".doc": "file-text", ".docx": "file-text", ".rtf": "file-text", ".odt": "file-text", ".tex": "file-text", ".latex": "file-text", // Image files ".png": "file-image", ".jpg": "file-image", ".jpeg": "file-image", ".gif": "file-image", ".svg": "file-image", ".webp": "file-image", ".bmp": "file-image", ".ico": "file-image", ".tiff": "file-image", ".tif": "file-image", // Audio files ".mp3": "file-music", ".wav": "file-music", ".flac": "file-music", ".aac": "file-music", ".ogg": "file-music", ".m4a": "file-music", ".wma": "file-music", // Video files ".mp4": "play", ".avi": "play", ".mov": "play", ".wmv": "play", ".flv": "play", ".webm": "play", ".mkv": "play", ".m4v": "play", ".3gp": "play", // Archive files ".zip": "file-archive", ".rar": "file-archive", ".7z": "file-archive", ".tar": "file-archive", ".gz": "file-archive", ".tgz": "file-archive", ".bz2": "file-archive", ".xz": "file-archive", ".deb": "file-archive", ".rpm": "file-archive", ".dmg": "file-archive", ".pkg": "file-archive", ".msi": "file-archive", ".exe": "file-archive", // Font files ".ttf": "file-type", ".otf": "file-type", ".woff": "file-type", ".woff2": "file-type", ".eot": "file-type", // Other common files ".key": "file-cog", ".pem": "file-cog", ".crt": "file-cog", ".cert": "file-cog", ".p12": "file-cog", ".pfx": "file-cog", ".lock": "file-cog", ".tmp": "file", ".bak": "file", ".old": "file", ".orig": "file", ".save": "file", }; function getFileIcon(name) { const ext = "." + name.split(".").pop().toLowerCase(); return EXT_ICONS[ext] || "file"; } // --------------------------------------------------------------------------- // Safe CDN helpers // --------------------------------------------------------------------------- let _iconDebounceTimer = null; /** * Debounced icon creation — batches multiple rapid calls into one * DOM scan to avoid excessive reflows when building large trees. */ function safeCreateIcons() { if (typeof lucide === "undefined" || !lucide.createIcons) return; if (state._iconDebounceTimer) return; // already scheduled state._iconDebounceTimer = requestAnimationFrame(() => { state._iconDebounceTimer = null; try { lucide.createIcons(); } catch (e) { /* CDN not loaded */ } }); } /** Force-flush icon creation immediately (use sparingly). */ function flushIcons() { if (state._iconDebounceTimer) { cancelAnimationFrame(state._iconDebounceTimer); state._iconDebounceTimer = null; } if (typeof lucide !== "undefined" && lucide.createIcons) { try { lucide.createIcons(); } catch (e) { /* CDN not loaded */ } } } function safeHighlight(block) { // Skip mermaid blocks — they're rendered by Mermaid.js, not highlighted if (block.className && block.className.indexOf('language-mermaid') !== -1) return; if (typeof hljs !== "undefined" && hljs.highlightElement) { try { hljs.highlightElement(block); } catch (e) { /* CDN not loaded */ } } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function escapeHtml(str) { if (!str) return ""; return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } // --------------------------------------------------------------------------- // Editor (CodeMirror) // --------------------------------------------------------------------------- async function openEditor(vaultName, filePath) { state.editorVault = vaultName; state.editorPath = filePath; const modal = document.getElementById("editor-modal"); const titleInput = document.getElementById("editor-title-input"); const filenameInput = document.getElementById("editor-filename-input"); const statFile = document.getElementById("editor-stat-file"); const statWords = document.getElementById("editor-stat-words"); const bodyEl = document.getElementById("editor-body"); // Restore header/footer (may be hidden by Forge) var headerEl = modal.querySelector(".editor-header"); var footerEl = modal.querySelector(".editor-footer"); var container = modal.querySelector(".editor-container"); if (headerEl) headerEl.style.display = ""; if (footerEl) footerEl.style.display = ""; if (container) container.style.maxHeight = ""; filenameInput.value = filePath.split("/").pop(); statFile.textContent = vaultName + "/" + filePath; // Fetch raw content const rawUrl = `/api/file/${encodeURIComponent(vaultName)}/raw?path=${encodeURIComponent(filePath)}`; const rawData = await api(rawUrl); // Clear previous editor bodyEl.innerHTML = ""; if (state.editorView) { state.editorView.destroy(); state.editorView = null; } state.fallbackEditorEl = null; // Re-create AI toolbar container (was cleared by innerHTML above) const aiContainer = document.createElement('div'); aiContainer.id = 'ai-toolbar-container'; bodyEl.appendChild(aiContainer); try { await waitForCodeMirror(); const { EditorView, EditorState, basicSetup, markdown, python, javascript, html, css, json, xml, sql, php, cpp, java, rust, oneDark, keymap } = window.CodeMirror; const currentTheme = document.documentElement.getAttribute("data-theme"); const fileExt = filePath.split(".").pop().toLowerCase(); const extensions = [ basicSetup, keymap.of([ { key: "Mod-s", run: () => { saveFile(); return true; }, }, ]), EditorView.lineWrapping, // Auto-save: debounced save after 2s of inactivity EditorView.updateListener.of((update) => { if (update.docChanged) { clearTimeout(window._obsigateAutoSaveTimer); window._obsigateAutoSaveTimer = setTimeout(() => saveFile(true), 2000); // Mark dirty var sd = document.getElementById("editor-save-dot"); var sl = document.getElementById("editor-save-label"); if (sd) sd.className = "editor-save-dot dirty"; if (sl) sl.textContent = "Unsaved"; } }), ]; // Add language support based on file extension const langMap = { md: markdown, markdown: markdown, py: python, js: javascript, jsx: javascript, ts: javascript, tsx: javascript, mjs: javascript, cjs: javascript, html: html, htm: html, css: css, scss: css, less: css, json: json, xml: xml, svg: xml, sql: sql, php: php, cpp: cpp, cc: cpp, cxx: cpp, c: cpp, h: cpp, hpp: cpp, java: java, rs: rust, sh: javascript, // Using javascript for shell scripts as fallback bash: javascript, zsh: javascript, }; const langMode = langMap[fileExt]; if (langMode) { extensions.push(langMode()); } if (currentTheme === "dark") { extensions.push(oneDark); } const cmState = EditorState.create({ doc: rawData.raw, extensions: extensions, }); state.editorView = new EditorView({ state: cmState, parent: bodyEl, }); } catch (err) { console.error("CodeMirror init failed, falling back to textarea:", err); state.fallbackEditorEl = document.createElement("textarea"); state.fallbackEditorEl.className = "fallback-editor"; state.fallbackEditorEl.value = rawData.raw; bodyEl.appendChild(state.fallbackEditorEl); } // Set up AI toolbar (recreate each time editor opens) var container = document.getElementById('ai-toolbar-container'); if (container && !container.querySelector('.ai-toolbar') && !container.querySelector('div')) { createAIToolbar(container, function() { return state.editorView || null; }); } // Mermaid live preview panel (#26) var existingPreview = document.getElementById('mermaid-live-preview'); if (existingPreview) existingPreview.remove(); var previewPanel = document.createElement('div'); previewPanel.id = 'mermaid-live-preview'; previewPanel.className = 'mermaid-live-preview'; previewPanel.style.display = 'none'; bodyEl.appendChild(previewPanel); // Debounced live preview on content change var mermaidTimer = null; var updateMermaidPreview = function() { clearTimeout(mermaidTimer); mermaidTimer = setTimeout(function() { var content = state.editorView ? state.editorView.state.doc.toString() : (state.fallbackEditorEl ? state.fallbackEditorEl.value : ''); var match = content.match(/```mermaid\n([\s\S]*?)```/); if (match) { previewPanel.style.display = 'block'; previewPanel.innerHTML = ''; var code = match[1]; import('./mermaid-viewer.js').then(function(m) { // Create a temp container and render var tmp = document.createElement('div'); tmp.innerHTML = '
' + code.replace(/&/g,'&').replace(//g,'>') + '';
previewPanel.appendChild(tmp);
m.renderMermaidBlocks(previewPanel);
});
} else {
previewPanel.style.display = 'none';
}
}, 500);
};
if (state.editorView) {
// Attach to CodeMirror
var extUpdate = state.editorView.state.updateListener;
// Use the existing update listener approach - just add debounce on any change
}
modal.classList.add("active");
safeCreateIcons();
// Initial preview if content has mermaid
updateMermaidPreview();
}
async function waitForCodeMirror() {
let attempts = 0;
while (!window.CodeMirror && attempts < 50) {
await new Promise((resolve) => setTimeout(resolve, 100));
attempts++;
}
if (!window.CodeMirror) {
throw new Error("CodeMirror failed to load");
}
}
function closeEditor() {
const vault = state.editorVault;
const path = state.editorPath;
const modal = document.getElementById("editor-modal");
modal.classList.remove("active");
// Restore brand text
var brand = modal.querySelector('.editor-brand');
if (brand) brand.textContent = 'ObsiGate';
if (state.editorView) {
state.editorView.destroy();
state.editorView = null;
}
state.fallbackEditorEl = null;
// Remove forge iframe if present + refresh current file
var forgeFrame = document.getElementById("forge-iframe");
if (forgeFrame) {
forgeFrame.remove();
if (state.currentVault && state.currentPath) openFile(state.currentVault, state.currentPath);
}
state.editorVault = null;
state.editorPath = null;
// Refresh file content if we're viewing the edited file
if (vault && path && state.currentVault === vault && state.currentPath === path) {
openFile(vault, path);
}
}
async function saveFile(silent = false) {
// If Forge is open, delegate save to the iframe
var forgeFrame = document.getElementById("forge-iframe");
if (forgeFrame && forgeFrame.contentWindow) {
if (!state.editorVault || !state.editorPath) return;
var fSaveBtn = document.getElementById("editor-save");
var fSaveDot = document.getElementById("editor-save-dot");
var fSaveLabel = document.getElementById("editor-save-label");
if (fSaveDot) { fSaveDot.className = 'editor-save-dot saving'; }
if (fSaveLabel) fSaveLabel.textContent = 'Saving...';
if (fSaveBtn) { fSaveBtn.disabled = true; }
forgeFrame.contentWindow.postMessage({ type: 'parent-save' }, '*');
return;
}
if ((!state.editorView && !state.fallbackEditorEl) || !state.editorVault || !state.editorPath) return;
const content = state.editorView ? state.editorView.state.doc.toString() : state.fallbackEditorEl.value;
const saveBtn = document.getElementById("editor-save");
const saveDot = document.getElementById("editor-save-dot");
const saveLabel = document.getElementById("editor-save-label");
try {
// Update save dot to saving state
if (saveDot) { saveDot.className = 'editor-save-dot saving'; }
if (saveLabel) saveLabel.textContent = 'Saving...';
if (saveBtn && !silent) {
saveBtn.disabled = true;
saveBtn.innerHTML = '';
safeCreateIcons();
}
const backupParam = silent ? '&backup=false' : '';
const response = await fetch(`/api/file/${encodeURIComponent(state.editorVault)}/save?path=${encodeURIComponent(state.editorPath)}${backupParam}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || "Erreur de sauvegarde");
}
// Update save dot to saved state
if (saveDot) { saveDot.className = 'editor-save-dot ok'; }
if (saveLabel) saveLabel.textContent = 'Saved';
if (silent) {
// Auto-save: brief green flash on save button
if (saveBtn) {
saveBtn.style.background = '#22c55e';
saveBtn.style.color = '#fff';
saveBtn.style.borderColor = '#22c55e';
}
setTimeout(() => {
if (saveBtn) {
saveBtn.style.background = '';
saveBtn.style.color = '';
saveBtn.style.borderColor = '';
}
}, 1500);
} else {
closeEditor();
}
} catch (err) {
console.error("Save error:", err);
if (saveDot) { saveDot.className = 'editor-save-dot err'; }
if (saveLabel) saveLabel.textContent = 'Erreur';
// If offline, queue the save for later sync
if (!navigator.onLine) {
try {
const { queueOfflineSave } = await import('./offline.js');
await queueOfflineSave(state.editorVault, state.editorPath, content);
alert('Vous etes hors-ligne. La modification a ete enregistree localement.');
} catch (e) { alert('Erreur: ' + err.message); }
} else {
alert('Erreur: ' + err.message);
}
}
}
async function deleteFile() {
// If Forge is open, delegate delete to the iframe
var forgeFrame = document.getElementById("forge-iframe");
if (forgeFrame && forgeFrame.contentWindow) {
if (!state.editorVault || !state.editorPath) return;
forgeFrame.contentWindow.postMessage({ type: 'parent-delete' }, '*');
return;
}
if (!state.editorVault || !state.editorPath) return;
const deleteBtn = document.getElementById("editor-delete");
const originalHTML = deleteBtn.innerHTML;
let success = false;
try {
deleteBtn.disabled = true;
deleteBtn.innerHTML = '';
safeCreateIcons();
const response = await fetch(`/api/file/${encodeURIComponent(state.editorVault)}?path=${encodeURIComponent(state.editorPath)}`, { method: "DELETE" });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || "Erreur de suppression");
}
success = true;
closeEditor();
showWelcome();
await refreshSidebarForContext();
await refreshTagsForContext();
} catch (err) {
console.error("Delete error:", err);
alert(`Erreur: ${err.message}`);
} finally {
deleteBtn.innerHTML = originalHTML;
deleteBtn.disabled = false;
if (success) safeCreateIcons();
}
}
function initEditor() {
const cancelBtn = document.getElementById("editor-cancel");
const deleteBtn = document.getElementById("editor-delete");
const saveBtn = document.getElementById("editor-save");
const modal = document.getElementById("editor-modal");
cancelBtn.addEventListener("click", closeEditor);
deleteBtn.addEventListener("click", deleteFile);
saveBtn.addEventListener("click", () => saveFile());
// ESC to close
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.classList.contains("active")) {
closeEditor();
}
});
// Fix mouse wheel scrolling in editor
modal.addEventListener(
"wheel",
(e) => {
const editorBody = document.getElementById("editor-body");
if (editorBody && editorBody.contains(e.target)) {
// Let the editor handle the scroll
return;
}
// Prevent modal from scrolling if not in editor area
e.preventDefault();
},
{ passive: false },
);
}
/** Copy text to clipboard — works on both HTTP and HTTPS */
function copyToClipboard(text) {
// Try modern API first (HTTPS-only)
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).catch(() => {});
return true;
}
// Fallback for HTTP contexts
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.left = "-9999px";
ta.style.top = "-9999px";
document.body.appendChild(ta);
ta.focus();
ta.select();
try {
document.execCommand("copy");
return true;
} catch (e) {
return false;
} finally {
document.body.removeChild(ta);
}
}
export { getFileIcon, safeCreateIcons, flushIcons, safeHighlight, escapeHtml, EXT_ICONS, openEditor, closeEditor, saveFile, deleteFile, waitForCodeMirror, initEditor, copyToClipboard };