// File operations module
// Requires: editor, files, currentFile, contextMenuFile, tabState, setStatus, escapeAttr, escapeHtml from global scope
// File icons by extension
const fileIcons = {
'go': '<span class="text-blue-400">Go</span>',
'js': '<span class="text-yellow-400">JS</span>',
'ts': '<span class="text-blue-500">TS</span>',
'py': '<span class="text-green-400">Py</span>',
'json': '<span class="text-yellow-300">{}</span>',
'html': '<span class="text-orange-400"><></span>',
'css': '<span class="text-purple-400">#</span>',
'md': '<span class="text-gray-400">MD</span>',
'yaml': '<span class="text-red-300">Y</span>',
'yml': '<span class="text-red-300">Y</span>',
'sh': '<span class="text-green-300">$</span>',
'sql': '<span class="text-cyan-400">DB</span>',
'default': '<span class="text-gray-500">F</span>'
};
// Track collapsed folders
const collapsedFolders = new Set();
function getFileIcon(path) {
const ext = path.split('.').pop().toLowerCase();
return fileIcons[ext] || fileIcons['default'];
}
async function loadFiles() {
try {
const res = await fetch('/api/files');
if (!res.ok) throw new Error('Failed to load files');
files = await res.json();
renderProjectsList();
} catch (err) {
setStatus('Failed to load files', false);
}
}
function buildFileTree(fileList) {
const root = { _children: {}, _files: [] };
fileList.forEach(f => {
const parts = f.path.split('/');
const fileName = parts[parts.length - 1];
// Handle .dir markers as empty directory indicators
if (fileName === '.dir') {
let current = root;
for (let i = 0; i < parts.length - 1; i++) {
const folder = parts[i];
if (!current._children[folder]) {
current._children[folder] = { _children: {}, _files: [], _path: parts.slice(0, i + 1).join('/') };
}
current = current._children[folder];
}
return;
}
let current = root;
// Navigate/create folder structure
for (let i = 0; i < parts.length - 1; i++) {
const folder = parts[i];
if (!current._children[folder]) {
current._children[folder] = { _children: {}, _files: [], _path: parts.slice(0, i + 1).join('/') };
}
current = current._children[folder];
}
// Add file to current folder
current._files.push(f);
});
return root;
}
function renderFileTree(node, depth = 0, parentPath = '') {
let html = '';
// Sort and render folders first
const folders = Object.keys(node._children).sort();
folders.forEach(folderName => {
const folder = node._children[folderName];
const folderPath = folder._path;
const isCollapsed = collapsedFolders.has(folderPath);
const indent = depth * 12;
html += `
<li>
<a onclick="toggleFolder('${escapeAttr(folderPath)}')" style="padding-left: ${indent}px" class="flex items-center gap-1">
<span class="text-xs">${isCollapsed ? '▶' : '▼'}</span>
<span class="text-yellow-500">📁</span>
<span>${escapeHtml(folderName)}</span>
</a>
</li>
`;
if (!isCollapsed) {
html += renderFileTree(folder, depth + 1, folderPath);
}
});
// Then render files
const sortedFiles = node._files.sort((a, b) => {
const aName = a.path.split('/').pop();
const bName = b.path.split('/').pop();
return aName.localeCompare(bName);
});
sortedFiles.forEach(f => {
const fileName = f.path.split('/').pop();
const indent = depth * 12;
const isActive = currentFile === f.path;
html += `
<li>
<a onclick="openFile('${escapeAttr(f.path)}')"
oncontextmenu="showContextMenu(event, '${escapeAttr(f.path)}')"
style="padding-left: ${indent}px"
class="flex items-center gap-1 ${isActive ? 'active' : ''}">
<span class="text-xs w-4">${getFileIcon(f.path)}</span>
<span>${escapeHtml(fileName)}</span>
</a>
</li>
`;
});
return html;
}
function toggleFolder(path) {
if (collapsedFolders.has(path)) {
collapsedFolders.delete(path);
} else {
collapsedFolders.add(path);
}
renderProjectsList();
}
function renderFileList() {
const list = document.getElementById('file-list');
if (files.length === 0) {
list.innerHTML = '<li class="text-xs text-base-content/50 p-2">No files yet</li>';
return;
}
const tree = buildFileTree(files);
list.innerHTML = renderFileTree(tree);
}
// Tab management
function renderTabs() {
const tabsDiv = document.getElementById('tabs');
tabsDiv.innerHTML = '';
for (const [path, state] of tabState) {
const tab = document.createElement('div');
const isActive = currentFile === path;
const isDirty = state.isDirty;
tab.className = `btn btn-xs ${isActive ? 'btn-primary' : 'btn-ghost'} gap-1`;
tab.innerHTML = `
<span onclick="switchToTab('${escapeAttr(path)}')" class="flex items-center gap-1">
${isDirty ? '<span class="w-2 h-2 bg-warning rounded-full"></span>' : ''}
${escapeHtml(path.split('/').pop())}
</span>
<span class="ml-1 hover:text-error" onclick="closeTab('${escapeAttr(path)}', event)">×</span>
`;
tabsDiv.appendChild(tab);
}
}
function saveCurrentViewState() {
if (currentFile && tabState.has(currentFile)) {
const state = tabState.get(currentFile);
state.viewState = editor.saveViewState();
}
}
function switchToTab(path) {
if (!tabState.has(path)) return;
saveCurrentViewState();
const state = tabState.get(path);
currentFile = path;
editor.setModel(state.model);
if (state.viewState) {
editor.restoreViewState(state.viewState);
}
editor.focus();
renderTabs();
renderProjectsList();
}
async function openFile(path) {
// If tab already open, just switch to it
if (tabState.has(path)) {
switchToTab(path);
setStatus('Switched to: ' + path);
return;
}
try {
let content = '';
let isNewFile = false;
const res = await fetch(`/api/files/open?path=${encodeURIComponent(path)}`);
if (res.ok) {
const data = await res.json();
content = data.content || '';
} else {
isNewFile = true;
}
saveCurrentViewState();
const lang = detectLanguage(path);
const uri = monaco.Uri.file(path);
let model = monaco.editor.getModel(uri);
if (model) {
model.dispose();
}
model = monaco.editor.createModel(content, lang, uri);
model.onDidChangeContent(() => {
const state = tabState.get(path);
if (state) {
state.isDirty = model.getValue() !== state.originalContent;
renderTabs();
}
});
tabState.set(path, {
model,
viewState: null,
isDirty: false,
originalContent: content
});
currentFile = path;
editor.setModel(model);
editor.focus();
renderTabs();
renderProjectsList();
setStatus(isNewFile ? 'New file: ' + path : 'Opened: ' + path);
} catch (err) {
setStatus('Failed to open file', false);
}
}
async function saveCurrentFile() {
if (!currentFile) {
setStatus('No file open', false);
return;
}
const content = editor.getValue();
try {
const res = await fetch('/api/files/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: currentFile, content })
});
if (!res.ok) throw new Error('Save failed');
if (tabState.has(currentFile)) {
const state = tabState.get(currentFile);
state.originalContent = content;
state.isDirty = false;
renderTabs();
}
setStatus('Saved: ' + currentFile);
loadFiles();
} catch (err) {
setStatus('Failed to save', false);
}
}
function closeTab(path, event) {
event.stopPropagation();
const state = tabState.get(path);
if (!state) return;
if (state.isDirty) {
if (!confirm(`${path.split('/').pop()} has unsaved changes. Close anyway?`)) {
return;
}
}
state.model.dispose();
tabState.delete(path);
if (currentFile === path) {
const remainingTabs = Array.from(tabState.keys());
if (remainingTabs.length > 0) {
switchToTab(remainingTabs[remainingTabs.length - 1]);
} else {
currentFile = null;
const placeholderModel = monaco.editor.createModel('// No file open\n// Create or open a file to start coding.', 'plaintext');
editor.setModel(placeholderModel);
}
}
renderTabs();
renderProjectsList();
}
// Context menu
function showContextMenu(e, path) {
e.preventDefault();
contextMenuFile = path;
const menu = document.getElementById('context-menu');
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
menu.classList.remove('hidden');
}
document.addEventListener('click', () => {
document.getElementById('context-menu').classList.add('hidden');
});
function renameContextFile() {
document.getElementById('rename-path').value = contextMenuFile;
document.getElementById('rename-modal').showModal();
}
async function confirmRename() {
const newPath = document.getElementById('rename-path').value.trim();
if (!newPath || newPath === contextMenuFile) {
document.getElementById('rename-modal').close();
return;
}
try {
const res = await fetch('/api/files/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oldPath: contextMenuFile, newPath })
});
if (!res.ok) throw new Error('Rename failed');
if (tabState.has(contextMenuFile)) {
const state = tabState.get(contextMenuFile);
tabState.delete(contextMenuFile);
const lang = detectLanguage(newPath);
const uri = monaco.Uri.file(newPath);
const content = state.model.getValue();
state.model.dispose();
const newModel = monaco.editor.createModel(content, lang, uri);
newModel.onDidChangeContent(() => {
const st = tabState.get(newPath);
if (st) {
st.isDirty = newModel.getValue() !== st.originalContent;
renderTabs();
}
});
tabState.set(newPath, {
model: newModel,
viewState: state.viewState,
isDirty: state.isDirty,
originalContent: state.originalContent
});
if (currentFile === contextMenuFile) {
currentFile = newPath;
editor.setModel(newModel);
}
}
setStatus('Renamed to: ' + newPath);
loadFiles();
renderTabs();
} catch (err) {
setStatus('Rename failed', false);
}
document.getElementById('rename-modal').close();
}
async function deleteContextFile() {
if (!confirm(`Delete ${contextMenuFile}?`)) return;
try {
const res = await fetch(`/api/files?path=${encodeURIComponent(contextMenuFile)}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Delete failed');
if (tabState.has(contextMenuFile)) {
const state = tabState.get(contextMenuFile);
state.model.dispose();
tabState.delete(contextMenuFile);
if (currentFile === contextMenuFile) {
const remainingTabs = Array.from(tabState.keys());
if (remainingTabs.length > 0) {
switchToTab(remainingTabs[remainingTabs.length - 1]);
} else {
currentFile = null;
const placeholderModel = monaco.editor.createModel('// No file open\n// Create or open a file to start coding.', 'plaintext');
editor.setModel(placeholderModel);
}
}
renderTabs();
}
setStatus('Deleted: ' + contextMenuFile);
loadFiles();
} catch (err) {
setStatus('Delete failed', false);
}
}
// New file
function showNewFileModal() {
document.getElementById('new-file-path').value = '';
document.getElementById('new-file-modal').showModal();
document.getElementById('new-file-path').focus();
}
async function createNewFile() {
const path = document.getElementById('new-file-path').value.trim();
if (!path) return;
document.getElementById('new-file-modal').close();
// Prepend current project path if not 'home'
const fullPath = currentProject && currentProject !== 'home'
? `${currentProject}/${path}`
: path;
await openFile(fullPath);
setStatus('Created: ' + fullPath);
}
// Language detection
function detectLanguage(path) {
const ext = path.split('.').pop().toLowerCase();
const langMap = {
'js': 'javascript',
'ts': 'typescript',
'jsx': 'javascript',
'tsx': 'typescript',
'py': 'python',
'go': 'go',
'rs': 'rust',
'rb': 'ruby',
'java': 'java',
'c': 'c',
'cpp': 'cpp',
'h': 'c',
'hpp': 'cpp',
'cs': 'csharp',
'php': 'php',
'html': 'html',
'css': 'css',
'scss': 'scss',
'json': 'json',
'xml': 'xml',
'yaml': 'yaml',
'yml': 'yaml',
'md': 'markdown',
'sql': 'sql',
'sh': 'shell',
'bash': 'shell',
'zsh': 'shell',
'dockerfile': 'dockerfile',
'makefile': 'makefile'
};
return langMap[ext] || 'plaintext';
}