/* ELYSIA CODE COMPANION v1.2.2 - Main Application Entry point and UI orchestration */ import Utils from "./utils.js"; import DB from "./db.js"; import FileSystem from "./filesystem.js"; import Chat from "./chat.js"; class App { constructor() { this.state = { apiKey: null, model: "x-ai/grok-3-fast", maxFiles: 100, autoPreview: true, syntaxHighlighting: true, maxResponseTokens: 4000, maxHistoryMessages: 20, theme: "dark", currentFile: null }; this.elements = { btnOpenFolder: document.getElementById("btn-open-folder"), btnCloseFolder: document.getElementById("btn-close-folder"), btnSettings: document.getElementById("btn-settings"), btnHistory: document.getElementById("btn-history"), btnSaveSettings: document.getElementById("btn-save-settings"), btnClearHistory: document.getElementById("btn-clear-history"), fileTree: document.getElementById("file-tree"), filePreview: document.getElementById("file-preview"), folderInfo: document.getElementById("folder-info"), folderName: document.getElementById("folder-name"), folderStats: document.getElementById("folder-stats"), searchFiles: document.getElementById("search-files"), apiKeyInput: document.getElementById("api-key"), modelSelect: document.getElementById("model-select"), maxFilesInput: document.getElementById("max-files"), autoPreviewCheckbox: document.getElementById("auto-preview"), syntaxHighlightingCheckbox: document.getElementById("syntax-highlighting"), maxResponseTokensInput: document.getElementById("max-response-tokens"), maxHistoryMessagesInput: document.getElementById("max-history-messages"), themeSelect: document.getElementById("theme-select") }; } async init() { // Load settings this.loadSettings(); // Ensure all modals are closed on startup (safety check) document.querySelectorAll(".modal").forEach(m => m.classList.remove("active")); // Initialize chat Chat.init(); // Setup event listeners this.setupEventListeners(); // Check File System Access API support if (!FileSystem.isSupported()) { Utils.toast.warning("File System Access API not supported. Please use Chrome or Edge browser."); } // Setup keyboard shortcuts this.setupKeyboardShortcuts(); // Setup drag & drop for folder this.setupDragAndDrop(); // Apply theme this.applyTheme(this.state.theme); // App initialized (production: silent mode) // console.log("💎 Elysia Code Companion initialized"); } setupDragAndDrop() { const dropZone = document.body; dropZone.addEventListener("dragover", e => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add("drag-over"); }); dropZone.addEventListener("dragleave", e => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove("drag-over"); }); dropZone.addEventListener("drop", async e => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove("drag-over"); const items = e.dataTransfer.items; // Check if folder was dropped if (items && items.length > 0) { const item = items[0]; if (item.kind === "file") { const entry = item.webkitGetAsEntry?.(); if (entry && entry.isDirectory) { Utils.toast.info( "Drag & drop folder detected. Please use 'Open Folder' button for full access." ); } else { Utils.toast.warning("Please drag a folder, not individual files."); } } } }); } setupSidebarToggles() { const btnToggleLeft = document.getElementById("btn-toggle-left"); const btnToggleRight = document.getElementById("btn-toggle-right"); const sidebarLeft = document.getElementById("sidebar-left"); const sidebarRight = document.getElementById("sidebar-right"); const overlay = document.getElementById("sidebar-overlay"); const isMobile = () => window.innerWidth <= 900; // Toggle left sidebar btnToggleLeft.addEventListener("click", () => { if (isMobile()) { sidebarLeft.classList.toggle("visible"); sidebarRight.classList.remove("visible"); overlay.classList.toggle("active", sidebarLeft.classList.contains("visible")); } else { sidebarLeft.classList.toggle("hidden"); } btnToggleLeft.classList.toggle( "active", isMobile() ? sidebarLeft.classList.contains("visible") : !sidebarLeft.classList.contains("hidden") ); btnToggleRight.classList.remove("active"); }); // Toggle right sidebar btnToggleRight.addEventListener("click", () => { if (isMobile()) { sidebarRight.classList.toggle("visible"); sidebarLeft.classList.remove("visible"); overlay.classList.toggle("active", sidebarRight.classList.contains("visible")); } else { sidebarRight.classList.toggle("hidden"); } btnToggleRight.classList.toggle( "active", isMobile() ? sidebarRight.classList.contains("visible") : !sidebarRight.classList.contains("hidden") ); btnToggleLeft.classList.remove("active"); }); // Close sidebars when clicking overlay overlay.addEventListener("click", () => { sidebarLeft.classList.remove("visible"); sidebarRight.classList.remove("visible"); overlay.classList.remove("active"); btnToggleLeft.classList.remove("active"); btnToggleRight.classList.remove("active"); }); // Set initial active state for desktop (sidebars visible by default) if (!isMobile()) { btnToggleLeft.classList.add("active"); btnToggleRight.classList.add("active"); } // Handle resize window.addEventListener("resize", () => { if (!isMobile()) { sidebarLeft.classList.remove("visible"); sidebarRight.classList.remove("visible"); overlay.classList.remove("active"); } }); } applyTheme(theme) { document.body.setAttribute("data-theme", theme); this.state.theme = theme; Utils.storage.set("theme", theme); } setupKeyboardShortcuts() { document.addEventListener("keydown", e => { // Ctrl+O: Open folder if (e.ctrlKey && e.key === "o") { e.preventDefault(); this.openFolder(); } // Ctrl+K: Focus search if (e.ctrlKey && e.key === "k") { e.preventDefault(); this.elements.searchFiles.focus(); } // Escape: Close modals if (e.key === "Escape") { document.querySelectorAll(".modal.active").forEach(modal => { Utils.modal.close(modal.id); }); } // Ctrl+Enter: Send message (when chat input focused) if (e.ctrlKey && e.key === "Enter" && document.activeElement === document.getElementById("chat-input")) { e.preventDefault(); Chat.sendMessage(); } // Arrow Up/Down: Navigate file tree (when file tree focused) if ((e.key === "ArrowUp" || e.key === "ArrowDown") && document.activeElement.closest(".file-tree")) { e.preventDefault(); this.navigateFileTree(e.key === "ArrowUp" ? -1 : 1); } // Enter: Open selected file in tree if (e.key === "Enter" && document.activeElement.classList.contains("tree-item")) { e.preventDefault(); const path = document.activeElement.dataset.path; if (path) this.previewFile(path); } }); } setupEventListeners() { // Folder actions this.elements.btnOpenFolder.addEventListener("click", () => this.openFolder()); this.elements.btnCloseFolder.addEventListener("click", () => this.closeFolder()); // Settings this.elements.btnSettings.addEventListener("click", () => { this.populateSettings(); Utils.modal.open("modal-settings"); }); this.elements.btnSaveSettings.addEventListener("click", () => { this.saveSettings(); Utils.modal.close("modal-settings"); }); // History this.elements.btnHistory.addEventListener("click", () => { this.loadHistory(); Utils.modal.open("modal-history"); }); this.elements.btnClearHistory.addEventListener("click", async () => { if (confirm("Are you sure you want to clear all chat history?")) { await DB.clearChats(); this.loadHistory(); } }); // Sidebar toggles this.setupSidebarToggles(); // File search this.elements.searchFiles.addEventListener( "input", Utils.debounce(e => this.searchFiles(e.target.value), 300) ); // API Key visibility toggle const btnToggleApiKey = document.getElementById("btn-toggle-api-key"); if (btnToggleApiKey) { btnToggleApiKey.addEventListener("click", () => { const apiKeyInput = this.elements.apiKeyInput; const isPassword = apiKeyInput.type === "password"; apiKeyInput.type = isPassword ? "text" : "password"; btnToggleApiKey.textContent = isPassword ? "🙈" : "👁️"; }); } // About modal const btnAbout = document.getElementById("btn-about"); if (btnAbout) { btnAbout.addEventListener("click", e => { e.preventDefault(); Utils.modal.open("modal-about"); }); } } loadSettings() { this.state.apiKey = Utils.storage.get("apiKey"); this.state.model = Utils.storage.get("model", "x-ai/grok-4.1-fast"); this.state.maxFiles = Utils.storage.get("maxFiles", 100); this.state.autoPreview = Utils.storage.get("autoPreview", true); this.state.syntaxHighlighting = Utils.storage.get("syntaxHighlighting", true); this.state.maxResponseTokens = Utils.storage.get("maxResponseTokens", 4000); this.state.maxHistoryMessages = Utils.storage.get("maxHistoryMessages", 20); this.state.theme = Utils.storage.get("theme", "dark"); } populateSettings() { this.elements.apiKeyInput.value = this.state.apiKey || ""; this.elements.modelSelect.value = this.state.model; this.elements.maxFilesInput.value = this.state.maxFiles; this.elements.autoPreviewCheckbox.checked = this.state.autoPreview; this.elements.syntaxHighlightingCheckbox.checked = this.state.syntaxHighlighting; this.elements.maxResponseTokensInput.value = this.state.maxResponseTokens; this.elements.maxHistoryMessagesInput.value = this.state.maxHistoryMessages; this.elements.themeSelect.value = this.state.theme; } saveSettings() { this.state.apiKey = this.elements.apiKeyInput.value; this.state.model = this.elements.modelSelect.value; this.state.maxFiles = parseInt(this.elements.maxFilesInput.value); this.state.autoPreview = this.elements.autoPreviewCheckbox.checked; this.state.syntaxHighlighting = this.elements.syntaxHighlightingCheckbox.checked; this.state.maxResponseTokens = parseInt(this.elements.maxResponseTokensInput.value); this.state.maxHistoryMessages = parseInt(this.elements.maxHistoryMessagesInput.value); this.state.theme = this.elements.themeSelect.value; Utils.storage.set("apiKey", this.state.apiKey); Utils.storage.set("model", this.state.model); Utils.storage.set("maxFiles", this.state.maxFiles); Utils.storage.set("autoPreview", this.state.autoPreview); Utils.storage.set("syntaxHighlighting", this.state.syntaxHighlighting); Utils.storage.set("maxResponseTokens", this.state.maxResponseTokens); Utils.storage.set("maxHistoryMessages", this.state.maxHistoryMessages); // Update Chat module with new history limit if (window.Chat) { window.Chat.maxHistoryMessages = this.state.maxHistoryMessages; } // Apply theme this.applyTheme(this.state.theme); Utils.toast.success("Settings saved!"); } async openFolder() { Utils.loading.show("Opening folder picker..."); try { const result = await FileSystem.openFolder(); if (!result) { Utils.loading.hide(); return; } Utils.loading.update("Loading files..."); // Update UI this.elements.folderInfo.style.display = "block"; this.elements.folderName.textContent = result.name; this.elements.folderStats.textContent = `${result.files.length} files`; this.elements.btnOpenFolder.style.display = "none"; this.elements.btnCloseFolder.style.display = "block"; // Update chat context Chat.updateContextInfo(); // Render file tree Utils.loading.update("Rendering file tree..."); this.renderFileTree(); Utils.loading.hide(); } catch (err) { Utils.loading.hide(); console.error("Failed to open folder:", err); Utils.toast.error("Failed to open folder: " + err.message); } } closeFolder() { // Confirm if there's chat history in current session if (Chat && Chat.messageContainer) { const messageCount = Chat.messageContainer.querySelectorAll(".message.user").length; if (messageCount > 0) { if (!confirm(`You have ${messageCount} message(s) in this session. Close folder and lose context?`)) { return; } } } FileSystem.close(); // Update UI this.elements.folderInfo.style.display = "none"; this.elements.btnOpenFolder.style.display = "block"; this.elements.btnCloseFolder.style.display = "none"; this.elements.fileTree.innerHTML = `

📂 No folder opened

Click "Open Folder" to start

`; this.elements.filePreview.innerHTML = `

📄 No file selected

Click a file in the tree to preview

`; // Update chat context Chat.updateContextInfo(); Utils.toast.info("Folder closed"); } renderFileTree() { const tree = FileSystem.buildTree(); this.elements.fileTree.innerHTML = ""; // Performance optimization: Collapse large directories by default const shouldCollapseByDefault = FileSystem.files.length > 100; const renderNode = (node, parentEl, level = 0) => { if (node.type === "directory") { const folderEl = document.createElement("div"); folderEl.className = "tree-folder"; const isCollapsed = shouldCollapseByDefault && level > 0; const folderHeader = document.createElement("div"); folderHeader.className = "tree-item tree-folder-header"; folderHeader.style.paddingLeft = `${level * 1.5}rem`; folderHeader.innerHTML = ` ${isCollapsed ? "📁" : "📂"} ${node.name} `; // Toggle collapse on click folderHeader.addEventListener("click", () => { const isCurrentlyCollapsed = folderHeader.querySelector(".icon").textContent === "📁"; folderHeader.querySelector(".icon").textContent = isCurrentlyCollapsed ? "📂" : "📁"; childrenContainer.style.display = isCurrentlyCollapsed ? "block" : "none"; }); folderEl.appendChild(folderHeader); const childrenContainer = document.createElement("div"); childrenContainer.className = "tree-children"; childrenContainer.style.display = isCollapsed ? "none" : "block"; if (node.children) { node.children.forEach(child => renderNode(child, childrenContainer, level + 1)); } folderEl.appendChild(childrenContainer); parentEl.appendChild(folderEl); } else { const fileEl = document.createElement("div"); fileEl.className = "tree-item"; fileEl.style.paddingLeft = `${level * 1.5}rem`; fileEl.dataset.path = node.path; // Add data attribute for identification fileEl.innerHTML = ` 📄 ${node.name} `; fileEl.addEventListener("click", e => { // Highlight active file properly document.querySelectorAll(".tree-item").forEach(el => el.classList.remove("active")); fileEl.classList.add("active"); this.previewFile(node.path); // Mobile UX: Close sidebar automatically after file selection if (window.innerWidth <= 900) { const sidebarLeft = document.getElementById("sidebar-left"); const overlay = document.getElementById("sidebar-overlay"); sidebarLeft.classList.remove("visible"); overlay.classList.remove("active"); document.getElementById("btn-toggle-left").classList.remove("active"); } }); parentEl.appendChild(fileEl); } }; if (tree.children) { tree.children.forEach(child => renderNode(child, this.elements.fileTree)); } } async previewFile(path) { try { const fileEntry = FileSystem.getFileByPath(path); if (!fileEntry) return; // Highlight active file IMMEDIATELY (before loading) for instant feedback document.querySelectorAll(".tree-item").forEach(el => el.classList.remove("active")); // Find and highlight the clicked item by path (not name - multiple files can have same name!) const activeItem = document.querySelector(`.tree-item[data-path="${CSS.escape(fileEntry.path)}"]`); if (activeItem) activeItem.classList.add("active"); this.state.currentFile = fileEntry; // Show loading indicator for large files (>100KB) if (fileEntry.size > 100 * 1024) { this.elements.filePreview.innerHTML = '

📄 Loading file...

'; } // Read file content const content = await FileSystem.readFile(fileEntry); // Render preview const language = fileEntry.language; const escapedContent = Utils.escapeHtml(content); const escapedPath = Utils.escapeHtml(fileEntry.path); const escapedName = Utils.escapeHtml(fileEntry.name); // Check if file can be shown as artifact const canShowArtifact = ["html", "htm"].includes(fileEntry.extension); this.elements.filePreview.innerHTML = `
${escapedPath}
${canShowArtifact ? '' : ""}
${escapedContent}
`; // Apply syntax highlighting if (this.state.syntaxHighlighting && window.Prism) { const codeBlock = this.elements.filePreview.querySelector("code"); Prism.highlightElement(codeBlock); } } catch (err) { console.error("Failed to preview file:", err); Utils.toast.error("Failed to preview file"); } } async copyFileContent() { if (!this.state.currentFile) return; try { const content = await FileSystem.readFile(this.state.currentFile); await Utils.copyToClipboard(content); } catch (err) { console.error("Failed to copy:", err); Utils.toast.error("Failed to copy file content"); } } searchFiles(query) { if (!query) { this.renderFileTree(); return; } const results = FileSystem.searchFiles(query); this.elements.fileTree.innerHTML = ""; if (results.length === 0) { this.elements.fileTree.innerHTML = `

No files found

`; return; } results.forEach(file => { const fileEl = document.createElement("div"); fileEl.className = "tree-item"; fileEl.dataset.path = file.path; fileEl.innerHTML = ` 📄 ${file.path} `; fileEl.addEventListener("click", e => { document.querySelectorAll(".tree-item").forEach(el => el.classList.remove("active")); fileEl.classList.add("active"); this.previewFile(file.path); // Mobile UX: Close sidebar automatically after file selection if (window.innerWidth <= 900) { const sidebarLeft = document.getElementById("sidebar-left"); const overlay = document.getElementById("sidebar-overlay"); sidebarLeft.classList.remove("visible"); overlay.classList.remove("active"); document.getElementById("btn-toggle-left").classList.remove("active"); } }); this.elements.fileTree.appendChild(fileEl); }); } async loadHistory() { const chats = await DB.getChats(20); const historyList = document.getElementById("history-list"); if (chats.length === 0) { historyList.innerHTML = `

No chat history yet

`; return; } // Add read-only notice at the top let html = `

📖 Read-only history — Start a new chat to continue coding!

`; html += chats .map(chat => { const date = new Date(chat.timestamp); // Escape ALL user-controlled content to prevent XSS const escapedModel = Utils.escapeHtml(chat.model || "unknown"); const escapedUserMsg = Utils.escapeHtml(Utils.truncate(chat.userMessage, 100)); const escapedAssistantMsg = Utils.escapeHtml(Utils.truncate(chat.assistantMessage, 150)); const escapedDate = Utils.escapeHtml(date.toLocaleString()); const escapedFolderName = Utils.escapeHtml(chat.folderName || "Unknown"); return `
${escapedDate} • ${escapedModel} • ${escapedFolderName}
You: ${escapedUserMsg}
Assistant: ${escapedAssistantMsg}
`; }) .join(""); historyList.innerHTML = html; } // Navigate file tree with keyboard navigateFileTree(direction) { const items = Array.from(document.querySelectorAll(".tree-item[data-path]")); if (items.length === 0) return; const currentIndex = items.findIndex(el => el.classList.contains("active")); let newIndex = currentIndex + direction; // Wrap around if (newIndex < 0) newIndex = items.length - 1; if (newIndex >= items.length) newIndex = 0; // Update active state items.forEach(el => el.classList.remove("active")); items[newIndex].classList.add("active"); items[newIndex].focus(); items[newIndex].scrollIntoView({ block: "nearest" }); } // Fullscreen Code Viewer async viewFileFullscreen() { if (!this.state.currentFile) return; try { const content = await FileSystem.readFile(this.state.currentFile); const language = this.state.currentFile.language; const escapedContent = Utils.escapeHtml(content); // Update modal content document.getElementById("viewer-filename").textContent = `📄 ${this.state.currentFile.name}`; document.getElementById("viewer-file-info").textContent = `${this.state.currentFile.extension} • ${Utils.formatFileSize(this.state.currentFile.size)}`; const viewerContent = document.getElementById("viewer-content"); viewerContent.innerHTML = `
${escapedContent}
`; // Apply syntax highlighting if (this.state.syntaxHighlighting && window.Prism) { const codeBlock = viewerContent.querySelector("code"); Prism.highlightElement(codeBlock); } // Show/hide artifact button const artifactBtn = document.getElementById("btn-viewer-artifact"); const canShowArtifact = ["html", "htm"].includes(this.state.currentFile.extension); artifactBtn.style.display = canShowArtifact ? "block" : "none"; // Setup buttons document.getElementById("btn-viewer-copy").onclick = () => this.copyFileContent(); document.getElementById("btn-viewer-artifact").onclick = () => { Utils.modal.close("modal-code-viewer"); this.viewAsArtifact(); }; // Open modal Utils.modal.open("modal-code-viewer"); } catch (err) { console.error("Failed to view file:", err); Utils.toast.error("Failed to open file viewer"); } } // Artifact Preview (HTML Live Preview) async viewAsArtifact() { if (!this.state.currentFile) return; try { const content = await FileSystem.readFile(this.state.currentFile); // Update modal document.getElementById("artifact-filename").textContent = this.state.currentFile.name; // Resolve dependencies (CSS/JS in same folder) const processedContent = await this.resolveArtifactDependencies(content); // Load into iframe const iframe = document.getElementById("artifact-iframe"); // Security: sandbox restricts capabilities // allow-scripts: needed for JS in artifacts // allow-forms: needed for form submission preview // NOTE: allow-same-origin removed for security (prevents parent access) iframe.setAttribute("sandbox", "allow-scripts allow-forms"); iframe.srcdoc = processedContent; // Setup buttons document.getElementById("btn-artifact-refresh").onclick = () => { iframe.srcdoc = processedContent; Utils.toast.success("Artifact refreshed"); }; document.getElementById("btn-artifact-code").onclick = () => { Utils.modal.close("modal-artifact"); this.viewFileFullscreen(); }; // Open modal Utils.modal.open("modal-artifact"); } catch (err) { console.error("Failed to view artifact:", err); Utils.toast.error("Failed to preview artifact"); } } // Resolve CSS/JS dependencies in HTML async resolveArtifactDependencies(htmlContent) { // Get current file's directory const currentPath = this.state.currentFile.path; const dirPath = currentPath.substring(0, currentPath.lastIndexOf("/")); // Find all local references (href/src with relative paths) let processed = htmlContent; // Match CSS links const cssRegex = /]+href=["'](?!http|https|\/\/)([^"']+)["'][^>]*>/gi; const cssMatches = [...htmlContent.matchAll(cssRegex)]; for (const match of cssMatches) { const relativePath = match[1]; const fullPath = dirPath ? `${dirPath}/${relativePath}` : relativePath; const cssFile = FileSystem.getFileByPath(fullPath); if (cssFile) { try { const cssContent = await FileSystem.readFile(cssFile); // Replace link tag with inline style processed = processed.replace(match[0], ``); } catch (err) { console.warn(`Could not load CSS: ${fullPath}`); } } } // Match JS scripts const jsRegex = /]+src=["'](?!http|https|\/\/)([^"']+)["'][^>]*><\/script>/gi; const jsMatches = [...htmlContent.matchAll(jsRegex)]; for (const match of jsMatches) { const relativePath = match[1]; const fullPath = dirPath ? `${dirPath}/${relativePath}` : relativePath; const jsFile = FileSystem.getFileByPath(fullPath); if (jsFile) { try { const jsContent = await FileSystem.readFile(jsFile); // Replace script tag with inline script processed = processed.replace(match[0], ``); } catch (err) { console.warn(`Could not load JS: ${fullPath}`); } } } return processed; } } // Create global app instance const app = new App(); // Initialize on DOM ready document.addEventListener("DOMContentLoaded", () => { app.init(); }); // Export for window access (for inline onclick handlers) window.app = app; export default app;