Connor McCutcheon
/ SkyCode
files.js
js
// 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">&lt;&gt;</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)">&times;</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';
}
No comments yet.