/** * File management operations (create, rename, delete) * Plain functions, no module wrapper */ /** * Resolve a node in fileTree by filePath * @param {string} filePath - Path like 'subdir/file.md' or '' * @returns {Object|null} The node object or null if not found */ function resolveNode(filePath) { if (!filePath) return fileTree; const parts = filePath.split('/'); let node = fileTree; for (const part of parts) { if (!node.entries || !node.entries[part]) return null; node = node.entries[part]; } return node; } /** * Resolve the parent directory handle for a given file path * @param {string} filePath - Full path like 'subdir/file.md' * @returns {FileSystemDirectoryHandle|null} Parent directory handle or null */ function resolveParentDirHandle(filePath) { const parts = filePath.split('/'); if (parts.length === 1) return directoryHandle; let node = fileTree; for (let i = 0; i < parts.length - 1; i++) { node = node.entries[parts[i]]; if (!node) return null; } return node.handle; } /** * Create a new file * @param {string} parentDirPath - '' for root, or 'subdir', 'a/b/c' */ async function createNewFile(parentDirPath) { // Resolve parent directory handle first (no user activation needed for reads) let parentHandle; if (parentDirPath === '') { parentHandle = directoryHandle; } else { const node = resolveNode(parentDirPath); if (!node || !node.handle) { alert('Could not locate parent directory.'); return; } parentHandle = node.handle; } // Show in-page modal and wait for user to confirm or cancel. // Returns the filename string, or null if cancelled. const name = await new Promise((resolve) => { const modal = document.getElementById('new-file-modal'); const input = document.getElementById('new-file-input'); const confirmBtn = document.getElementById('new-file-confirm'); const cancelBtn = document.getElementById('new-file-cancel'); input.value = 'untitled.md'; modal.classList.remove('hidden'); input.focus(); input.select(); function cleanup() { modal.classList.add('hidden'); confirmBtn.removeEventListener('click', onConfirm); cancelBtn.removeEventListener('click', onCancel); input.removeEventListener('keydown', onKey); } function onConfirm() { const val = input.value.trim(); cleanup(); resolve(val || null); } function onCancel() { cleanup(); resolve(null); } function onKey(e) { if (e.key === 'Enter') onConfirm(); if (e.key === 'Escape') onCancel(); } confirmBtn.addEventListener('click', onConfirm); cancelBtn.addEventListener('click', onCancel); input.addEventListener('keydown', onKey); }); if (!name) { if (DEBUG) console.log('New file creation cancelled'); return; } // Validate name if (name.includes('/') || name.includes('\\')) { alert('Invalid filename: cannot contain / or \\.'); return; } // Check if file already exists try { await parentHandle.getFileHandle(name); const overwrite = window.confirm('A file named "' + name + '" already exists. Overwrite it?'); if (!overwrite) return; } catch (e) { if (e.name !== 'NotFoundError') throw e; } // Create the file — this must happen after the modal's button click // which is the user activation token. try { const newHandle = await parentHandle.getFileHandle(name, { create: true }); const writable = await newHandle.createWritable(); await writable.write(''); await writable.close(); if (DEBUG) console.log(`Created new file: ${parentDirPath ? parentDirPath + '/' : ''}${name}`); await refreshDirectory(); const newFilePath = parentDirPath ? parentDirPath + '/' + name : name; const element = document.querySelector('.file-item[data-path="' + CSS.escape(newFilePath) + '"]'); if (element) { handleFileClick(newHandle, newFilePath, element); } } catch (error) { console.error('Error creating new file:', error); alert('Error creating file: ' + error.message); } } /** * Rename a file or directory * @param {string} filePath - Full path like 'subdir/file.md' * @param {boolean} isDirectory - true if renaming a directory (not supported on Chrome) */ async function renameEntry(filePath, isDirectory) { const currentName = filePath.split('/').pop(); const newName = window.prompt('Rename to:', currentName); if (newName === null || newName === currentName) { if (DEBUG) console.log('Rename cancelled or unchanged'); return; } // Validate name if (newName.includes('/') || newName.includes('\\') || newName.trim() === '') { alert('Invalid filename: cannot contain / or \\ and must not be empty.'); return; } // Resolve parent directory handle const parentHandle = resolveParentDirHandle(filePath); if (!parentHandle) { alert('Could not locate parent directory.'); return; } // For files: rename via File System Access API if (!isDirectory) { try { // Check if new name already exists (file or directory) try { const existing = await parentHandle.getFileHandle(newName); // A file with that name exists const overwrite = window.confirm('A file named "' + newName + '" already exists. Overwrite?'); if (!overwrite) return; } catch (fileErr) { if (fileErr.name === 'TypeMismatchError') { // A directory with that name exists window.alert('A folder named "' + newName + '" already exists. Choose a different name.'); return; } if (fileErr.name !== 'NotFoundError') throw fileErr; // NotFoundError = safe to create } const oldHandle = resolveNode(filePath); if (!oldHandle || !oldHandle.handle) { alert('Could not find file to rename.'); return; } const file = await oldHandle.handle.getFile(); const content = await file.text(); const newHandle = await parentHandle.getFileHandle(newName, { create: true }); const writable = await newHandle.createWritable(); await writable.write(content); await writable.close(); const newFile = await newHandle.getFile(); await parentHandle.removeEntry(currentName); // Update editor instances if (editorInstances.has(filePath)) { const instance = editorInstances.get(filePath); const newFilePath = filePath.substring(0, filePath.length - currentName.length) + newName; // Remove old instance const data = editorInstances.get(filePath); if (data.fileViewContainer) { data.fileViewContainer.classList.add('hidden'); } editorInstances.delete(filePath); // Re-add with new path editorInstances.set(newFilePath, { ...data, fileHandle: newHandle, lastModified: newFile.lastModified }); // Update active state if (instance.fileViewContainer) { instance.fileViewContainer.classList.remove('hidden'); instance.fileViewContainer.dataset.path = newFilePath; } // Update fileTree entries const parts = filePath.split('/'); const fileName = parts.pop(); const dirPath = parts.join('/'); let targetEntries = fileTree.entries; if (dirPath) { const dirParts = dirPath.split('/'); let current = fileTree; for (const part of dirParts) { current = current.entries[part]; } targetEntries = current.entries; } if (targetEntries && targetEntries[currentName]) { delete targetEntries[currentName]; targetEntries[newName] = { name: newName, type: 'file', handle: newHandle }; } renderFileTree(); restoreActiveFile(newFilePath); } else { renderFileTree(); } } catch (error) { console.error('Error renaming file:', error); alert('Error renaming file: ' + error.message); } } else { // For directories: not supported by browser API alert('Directory rename is not supported by the browser File System API. Please rename the folder in your OS file manager and refresh.'); } } /** * Delete a file or directory * @param {string} filePath - Full path like 'subdir/file.md' or 'subdir' * @param {boolean} isDirectory - true if deleting a directory */ async function deleteEntry(filePath, isDirectory) { const name = filePath.split('/').pop(); const message = isDirectory ? 'Delete folder "' + name + '" and all its contents?' : 'Delete "' + name + '"?'; const ok = window.confirm(message); if (!ok) { if (DEBUG) console.log('Delete cancelled by user'); return; } // Resolve parent directory handle const parentHandle = resolveParentDirHandle(filePath); if (!parentHandle) { alert('Could not locate parent directory.'); return; } let deleted = false; try { await parentHandle.removeEntry(name, { recursive: isDirectory }); deleted = true; } catch (error) { if (error.name === 'NotFoundError') { // Already gone — treat as success for cleanup purposes deleted = true; } else { console.error('Error deleting entry:', error); alert('Error deleting entry: ' + error.message); } } if (deleted) { // Close editor if open if (!isDirectory && editorInstances.has(filePath)) { closeEditorInstance(filePath); } else if (isDirectory) { // Close any editors under this directory const dirsToClose = []; editorInstances.forEach(function(instance, key) { if (key === filePath || key.startsWith(filePath + '/')) { dirsToClose.push(key); } }); dirsToClose.forEach(function(key) { closeEditorInstance(key); }); } // Remove from fileTree entries const parts = filePath.split('/'); const entryName = parts.pop(); const dirPath = parts.join('/'); let targetEntries = fileTree.entries; if (dirPath) { const dirParts = dirPath.split('/'); let current = fileTree; for (const part of dirParts) { current = current.entries[part]; } targetEntries = current.entries; } if (targetEntries && targetEntries[entryName]) { delete targetEntries[entryName]; } renderFileTree(); updateStatusCountsFromTree(); } } /** * Close an editor instance and show welcome screen if no files open * @param {string} filePath - Path of file to close */ function closeEditorInstance(filePath) { const instance = editorInstances.get(filePath); if (!instance) return; if (instance.fileViewContainer) { instance.fileViewContainer.classList.add('hidden'); } editorInstances.delete(filePath); // Check if any visible file-view-container children remain const contentContainer = document.getElementById('content-container'); if (contentContainer) { const visibleChildren = Array.from(contentContainer.querySelectorAll('.file-view-container')) .filter(function(el) { return !el.classList.contains('hidden'); }); if (visibleChildren.length === 0) { document.getElementById('welcome-screen').classList.remove('hidden'); contentContainer.classList.add('hidden'); } } } /** * Restore active file state after rename * @param {string} newFilePath - New path of the file */ function restoreActiveFile(newFilePath) { const element = document.querySelector('.file-item[data-path="' + CSS.escape(newFilePath) + '"]'); if (element) { element.classList.add('active-file'); element.style.backgroundColor = ''; element.style.color = ''; } } /** * Update status counts from fileTree */ function updateStatusCountsFromTree() { let folderCount = 0; let fileCount = 0; function countEntries(entries) { if (!entries) return; for (const [name, item] of Object.entries(entries)) { if (item.type === 'directory') { folderCount++; countEntries(item.entries); } else if (item.type === 'file') { fileCount++; } } } countEntries(fileTree.entries); updateStatusCounts(folderCount, fileCount); }