Connor McCutcheon
/ SkyCode
git.js
js
// Git and Project management module
// Requires: files, currentProject, projects, setStatus, escapeAttr, escapeHtml, buildFileTree, getFileIcon from global scope
// Git state
let gitStatus = { initialized: false, branch: '', files: [] };
let gitPanelOpen = false;
let branches = [];
// Project state
let currentProject = null;
let projects = [];
const collapsedProjects = new Set();
function toggleGitPanel() {
  gitPanelOpen = !gitPanelOpen;
  const content = document.getElementById('git-content');
  const chevron = document.getElementById('git-panel-chevron');
  content.classList.toggle('hidden', !gitPanelOpen);
  chevron.style.transform = gitPanelOpen ? 'rotate(180deg)' : '';
  if (gitPanelOpen) {
    loadGitStatus();
  }
}
async function loadGitStatus() {
  const content = document.getElementById('git-content');
  content.innerHTML = '<div class="text-xs text-base-content/50">Loading...</div>';
  try {
    const params = currentProject ? `?project=${encodeURIComponent(currentProject)}` : '';
    const res = await fetch('/api/git/status' + params);
    if (!res.ok) {
      const err = await res.json();
      const errorDiv = document.createElement('div');
      errorDiv.className = 'text-xs text-error';
      errorDiv.textContent = err.error || 'Unknown error';
      content.innerHTML = '';
      content.appendChild(errorDiv);
      return;
    }
    gitStatus = await res.json();
    renderGitPanel();
    loadBranches();
  } catch (err) {
    content.innerHTML = '<div class="text-xs text-error">Failed to load git status</div>';
  }
}
function renderGitPanel() {
  const content = document.getElementById('git-content');
  // Build project options for dropdown
  const projectOptions = projects.map(p =>
    `<option value="${escapeAttr(p.path)}" ${currentProject === p.path ? 'selected' : ''}>${escapeHtml(p.name)}</option>`
  ).join('');
  if (!gitStatus.initialized) {
    content.innerHTML = `
      <div class="pb-4">
        <div class="flex items-center gap-2 mb-3">
          <span class="text-xs text-base-content/50">Project:</span>
          <select class="select select-xs select-bordered flex-1" onchange="switchProject(this.value)">
            ${projectOptions}
          </select>
        </div>
        <div class="text-center py-4">
          <p class="text-xs text-base-content/50 mb-2">No git repository</p>
          <button class="btn btn-primary btn-xs" onclick="showCloneModal()">Clone Repository</button>
        </div>
      </div>
    `;
    return;
  }
  const staged = gitStatus.files.filter(f => f.staged);
  const unstaged = gitStatus.files.filter(f => !f.staged);
  let html = `
    <div class="mb-3">
      <div class="flex items-center gap-2 mb-2">
        <span class="text-xs text-base-content/50">Project:</span>
        <select class="select select-xs select-bordered flex-1" onchange="switchProject(this.value)">
          ${projectOptions}
        </select>
      </div>
      <div class="flex items-center gap-2 mb-2">
        <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 text-base-content/50">
          <path stroke-linecap="round" stroke-linejoin="round" d="M6 6.75v10.5M18 6.75v10.5M6 6.75A2.25 2.25 0 0 1 8.25 4.5h7.5A2.25 2.25 0 0 1 18 6.75M6 6.75a2.25 2.25 0 0 0-2.25 2.25v6a2.25 2.25 0 0 0 2.25 2.25h12a2.25 2.25 0 0 0 2.25-2.25v-6a2.25 2.25 0 0 0-2.25-2.25" />
        </svg>
        <select id="branch-selector" class="select select-xs select-ghost flex-1" onchange="switchBranch(this.value)">
          <option value="${escapeAttr(gitStatus.branch || 'main')}">${escapeHtml(gitStatus.branch || 'main')}</option>
        </select>
        <button class="btn btn-ghost btn-xs" onclick="showNewBranchModal()" title="New Branch">
          <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3 h-3">
            <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
          </svg>
        </button>
      </div>
      <div class="flex gap-1">
        <button class="btn btn-xs btn-outline flex-1" onclick="gitPull()" title="Pull from remote">
          <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3 h-3">
            <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 13.5 12 21m0 0-7.5-7.5M12 21V3" />
          </svg>
          Pull
        </button>
        <button class="btn btn-xs btn-outline flex-1" onclick="gitPush()" title="Push to remote">
          <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-3 h-3">
            <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18" />
          </svg>
          Push
        </button>
      </div>
    </div>
  `;
  // Staged Changes
  html += `
    <div class="mb-3">
      <div class="flex items-center justify-between mb-1">
        <span class="text-xs font-semibold">Staged Changes (${staged.length})</span>
        ${staged.length > 0 ? '<button class="btn btn-ghost btn-xs" onclick="unstageAll()">−</button>' : ''}
      </div>
      <ul class="menu menu-xs bg-base-200 rounded">
  `;
  if (staged.length === 0) {
    html += '<li class="text-xs text-base-content/50 p-2">No staged changes</li>';
  } else {
    staged.forEach(f => {
      html += `
        <li>
          <a onclick="viewDiff('${escapeAttr(f.path)}', true)" class="flex items-center gap-1">
            <span class="w-4 text-success">${escapeHtml(f.status)}</span>
            <span class="truncate">${escapeHtml(f.path)}</span>
            <span class="flex-1"></span>
            <button class="btn btn-ghost btn-xs" onclick="event.stopPropagation(); unstageFile('${escapeAttr(f.path)}')">−</button>
          </a>
        </li>
      `;
    });
  }
  html += '</ul></div>';
  // Unstaged Changes
  html += `
    <div class="mb-3">
      <div class="flex items-center justify-between mb-1">
        <span class="text-xs font-semibold">Changes (${unstaged.length})</span>
        ${unstaged.length > 0 ? '<button class="btn btn-ghost btn-xs" onclick="stageAll()">+</button>' : ''}
      </div>
      <ul class="menu menu-xs bg-base-200 rounded">
  `;
  if (unstaged.length === 0) {
    html += '<li class="text-xs text-base-content/50 p-2">No changes</li>';
  } else {
    unstaged.forEach(f => {
      html += `
        <li>
          <a onclick="viewDiff('${escapeAttr(f.path)}', false)" class="flex items-center gap-1">
            <span class="w-4 ${f.status === '?' ? 'text-info' : 'text-warning'}">${escapeHtml(f.status)}</span>
            <span class="truncate">${escapeHtml(f.path)}</span>
            <span class="flex-1"></span>
            <button class="btn btn-ghost btn-xs" onclick="event.stopPropagation(); stageFile('${escapeAttr(f.path)}')">+</button>
          </a>
        </li>
      `;
    });
  }
  html += '</ul></div>';
  // Commit section
  if (staged.length > 0) {
    html += `
      <div class="mt-3">
        <textarea id="commit-message" class="textarea textarea-bordered textarea-xs w-full h-16"
                  placeholder="Commit message"></textarea>
        <button class="btn btn-primary btn-xs w-full mt-2" onclick="commitChanges()">Commit</button>
      </div>
    `;
  }
  // Add padding at the bottom for scroll room
  html += '<div class="pb-4"></div>';
  content.innerHTML = html;
}
function showCloneModal() {
  document.getElementById('clone-url').value = '';
  document.getElementById('clone-modal').showModal();
}
async function cloneRepository() {
  const url = document.getElementById('clone-url').value.trim();
  if (!url) {
    setStatus('Please enter a repository URL', false);
    return;
  }
  document.getElementById('clone-modal').close();
  setStatus('Cloning repository...');
  try {
    const res = await fetch('/api/workspace/clone', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ url })
    });
    const data = await res.json();
    if (!res.ok) {
      throw new Error(data.error || 'Clone failed');
    }
    setStatus('Repository cloned successfully');
    if (data.path) {
      currentProject = data.path;
    }
    await loadFiles();
    await loadProjects();
    loadGitStatus();
  } catch (err) {
    setStatus('Failed to clone: ' + err.message, false);
  }
}
async function stageFile(path) {
  try {
    await fetch('/api/git/stage', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ paths: [path] })
    });
    loadGitStatus();
  } catch (err) {
    setStatus('Failed to stage file', false);
  }
}
async function stageAll() {
  try {
    await fetch('/api/git/stage', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ paths: [] })
    });
    loadGitStatus();
  } catch (err) {
    setStatus('Failed to stage files', false);
  }
}
async function unstageFile(path) {
  try {
    await fetch('/api/git/unstage', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ paths: [path] })
    });
    loadGitStatus();
  } catch (err) {
    setStatus('Failed to unstage file', false);
  }
}
async function unstageAll() {
  try {
    await fetch('/api/git/unstage', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ paths: [] })
    });
    loadGitStatus();
  } catch (err) {
    setStatus('Failed to unstage files', false);
  }
}
async function commitChanges() {
  const message = document.getElementById('commit-message').value.trim();
  if (!message) {
    setStatus('Commit message required', false);
    return;
  }
  try {
    const res = await fetch('/api/git/commit', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message })
    });
    if (!res.ok) {
      const err = await res.json();
      throw new Error(err.error);
    }
    setStatus('Changes committed');
    document.getElementById('commit-message').value = '';
    loadGitStatus();
  } catch (err) {
    setStatus('Commit failed: ' + err.message, false);
  }
}
async function viewDiff(path, staged) {
  try {
    const params = new URLSearchParams({ path });
    if (staged) params.append('staged', 'true');
    const res = await fetch(`/api/git/diff?${params}`);
    const data = await res.json();
    if (data.diff) {
      await openFile(path);
      setStatus('Viewing: ' + path);
    } else {
      await openFile(path);
    }
  } catch (err) {
    setStatus('Failed to load diff', false);
  }
}
// Project management
async function loadProjects() {
  try {
    const res = await fetch('/api/projects');
    if (!res.ok) return;
    projects = await res.json();
    if (!currentProject && projects.length > 0) {
      const defaultProject = projects.find(p => p.isDefault) || projects[0];
      currentProject = defaultProject.path;
    }
    renderProjectsList();
  } catch (err) {
    console.error('Failed to load projects:', err);
  }
}
function renderProjectsList() {
  const list = document.getElementById('projects-list');
  if (projects.length === 0) {
    list.innerHTML = '<div class="p-4 text-xs text-base-content/50">No projects yet. Use "Open in SkyCode" from theskyscape.com to clone a repo.</div>';
    return;
  }
  let html = '<ul class="menu menu-xs w-full">';
  const sortedProjects = [...projects].sort((a, b) => {
    if (a.path === 'home') return -1;
    if (b.path === 'home') return 1;
    return a.name.localeCompare(b.name);
  });
  for (const project of sortedProjects) {
    const isCollapsed = collapsedProjects.has(project.path);
    const isHome = project.path === 'home';
    const projectFiles = getProjectFiles(project.path);
    const icon = isHome ? '~' : '📁';
    const deleteBtn = isHome ? '' : `<span class="opacity-0 group-hover:opacity-100 hover:text-error transition-opacity" onclick="event.stopPropagation(); deleteProject('${escapeAttr(project.id)}')" title="Delete project">&times;</span>`;
    html += `
      <li class="w-full">
        <a onclick="selectProject('${escapeAttr(project.path)}')" class="group flex items-center gap-1 font-semibold w-full ${currentProject === project.path ? 'active' : ''}">
          <span class="text-xs cursor-pointer hover:text-primary" onclick="event.stopPropagation(); toggleProject('${escapeAttr(project.path)}')">${isCollapsed ? '▶' : '▼'}</span>
          <span>${icon}</span>
          <span class="flex-1 truncate">${escapeHtml(project.name)}</span>
          ${deleteBtn}
        </a>
      </li>`;
    if (!isCollapsed && projectFiles.length > 0) {
      const tree = buildFileTree(projectFiles.map(f => ({
        ...f,
        path: f.path.startsWith(project.path + '/') ? f.path.slice(project.path.length + 1) : f.path
      })));
      html += renderProjectFileTree(tree, project.path, 1);
    } else if (!isCollapsed && projectFiles.length === 0) {
      html += '<li class="w-full"><span class="text-xs text-base-content/40 pl-8">Empty</span></li>';
    }
  }
  html += '</ul>';
  list.innerHTML = html;
}
function getProjectFiles(projectPath) {
  if (projectPath === 'home') {
    const otherPaths = projects.filter(p => p.path !== 'home').map(p => p.path + '/');
    return files.filter(f => !otherPaths.some(prefix => f.path.startsWith(prefix)) && !f.path.includes('/'));
  }
  return files.filter(f => f.path.startsWith(projectPath + '/') || f.path === projectPath);
}
function renderProjectFileTree(node, projectPath, depth = 0) {
  let html = '';
  const indent = depth * 12;
  const folders = Object.keys(node._children).sort();
  folders.forEach(folderName => {
    const folder = node._children[folderName];
    const folderPath = folder._path;
    const fullFolderPath = projectPath + '/' + folderPath;
    const isCollapsed = collapsedFolders.has(fullFolderPath);
    html += `
      <li class="w-full">
        <a onclick="toggleFolder('${escapeAttr(fullFolderPath)}')" style="padding-left: ${indent}px" class="flex items-center gap-1 w-full">
          <span class="text-xs">${isCollapsed ? '▶' : '▼'}</span>
          <span class="text-yellow-500">📁</span>
          <span class="truncate">${escapeHtml(folderName)}</span>
        </a>
      </li>`;
    if (!isCollapsed) {
      html += renderProjectFileTree(folder, projectPath, depth + 1);
    }
  });
  const sortedFiles = node._files.sort((a, b) => a.path.localeCompare(b.path));
  sortedFiles.forEach(f => {
    const fileName = f.path.split('/').pop();
    const fullPath = projectPath === 'home' ? fileName : projectPath + '/' + f.path;
    html += `
      <li class="w-full">
        <a onclick="openFile('${escapeAttr(fullPath)}')" style="padding-left: ${indent}px"
           class="w-full ${currentFile === fullPath ? 'active' : ''}"
           oncontextmenu="showContextMenu(event, '${escapeAttr(fullPath)}')">
          ${getFileIcon(fileName)}
          <span class="truncate">${escapeHtml(fileName)}</span>
        </a>
      </li>`;
  });
  return html;
}
function toggleProject(path) {
  // Only toggle collapse/expand, don't change currentProject
  if (collapsedProjects.has(path)) {
    collapsedProjects.delete(path);
  } else {
    collapsedProjects.add(path);
  }
  renderProjectsList();
}
function selectProject(path) {
  // Select project without toggling collapse
  currentProject = path;
  if (gitPanelOpen) {
    loadGitStatus();
  }
  renderProjectsList();
}
function switchProject(path) {
  // Called from git panel dropdown
  currentProject = path;
  loadGitStatus();
  renderProjectsList();
}
function showNewProjectModal() {
  document.getElementById('new-project-modal').showModal();
  document.getElementById('new-project-name').value = '';
  document.getElementById('new-project-url').value = '';
}
async function createProject() {
  const name = document.getElementById('new-project-name').value.trim();
  const remoteUrl = document.getElementById('new-project-url').value.trim();
  if (!name) {
    setStatus('Project name required', false);
    return;
  }
  try {
    setStatus('Creating project...');
    const res = await fetch('/api/projects', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, remoteUrl })
    });
    if (!res.ok) {
      const err = await res.json();
      throw new Error(err.error);
    }
    const data = await res.json();
    document.getElementById('new-project-modal').close();
    setStatus('Project created: ' + data.project.name);
    await loadProjects();
    switchProject(data.project.path);
  } catch (err) {
    setStatus('Failed: ' + err.message, false);
  }
}
async function deleteProject(projectId) {
  if (!confirm('Delete this project? Files will be removed.')) {
    return;
  }
  try {
    setStatus('Deleting project...');
    const res = await fetch(`/api/projects/${projectId}`, {
      method: 'DELETE'
    });
    if (!res.ok) {
      const err = await res.json();
      throw new Error(err.error);
    }
    setStatus('Project deleted');
    const deletedProject = projects.find(p => p.id === projectId);
    if (deletedProject && currentProject === deletedProject.path) {
      currentProject = 'home';
    }
    await loadProjects();
    await loadFiles();
  } catch (err) {
    setStatus('Failed: ' + err.message, false);
  }
}
// Branch management
async function loadBranches() {
  if (!gitStatus.initialized) return;
  try {
    const params = currentProject ? `?project=${encodeURIComponent(currentProject)}` : '';
    const res = await fetch('/api/git/branches' + params);
    if (!res.ok) return;
    const data = await res.json();
    branches = data.branches || [];
    const selector = document.getElementById('branch-selector');
    if (selector) {
      selector.innerHTML = '';
      branches.filter(b => !b.isRemote).forEach(b => {
        const option = document.createElement('option');
        option.value = b.name;
        option.textContent = b.name;
        if (b.current) option.selected = true;
        selector.appendChild(option);
      });
    }
  } catch (err) {
    console.error('Failed to load branches:', err);
  }
}
async function switchBranch(branch) {
  try {
    setStatus('Switching branch...');
    const params = currentProject ? `?project=${encodeURIComponent(currentProject)}` : '';
    const res = await fetch('/api/git/checkout' + params, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ branch })
    });
    if (!res.ok) {
      const err = await res.json();
      throw new Error(err.error);
    }
    setStatus('Switched to branch: ' + branch);
    loadGitStatus();
    loadFiles();
  } catch (err) {
    setStatus('Failed: ' + err.message, false);
    loadBranches();
  }
}
function showNewBranchModal() {
  document.getElementById('new-branch-modal').showModal();
  document.getElementById('new-branch-name').value = '';
}
async function createBranch() {
  const branch = document.getElementById('new-branch-name').value.trim();
  if (!branch) {
    setStatus('Branch name required', false);
    return;
  }
  try {
    setStatus('Creating branch...');
    const params = currentProject ? `?project=${encodeURIComponent(currentProject)}` : '';
    const res = await fetch('/api/git/checkout' + params, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ branch, create: true })
    });
    if (!res.ok) {
      const err = await res.json();
      throw new Error(err.error);
    }
    document.getElementById('new-branch-modal').close();
    setStatus('Created and switched to branch: ' + branch);
    loadGitStatus();
  } catch (err) {
    setStatus('Failed: ' + err.message, false);
  }
}
async function gitPull() {
  try {
    setStatus('Pulling from remote...');
    const params = currentProject ? `?project=${encodeURIComponent(currentProject)}` : '';
    const res = await fetch('/api/git/pull' + params, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({})
    });
    if (!res.ok) {
      const err = await res.json();
      throw new Error(err.error);
    }
    setStatus('Pull successful');
    loadGitStatus();
    loadFiles();
    // Refresh all open tabs with updated content from pulled files
    await refreshOpenTabs();
  } catch (err) {
    setStatus('Pull failed: ' + err.message, false);
  }
}
// Refresh all open tabs with latest content from database
async function refreshOpenTabs() {
  for (const [path, state] of tabState.entries()) {
    try {
      const res = await fetch('/api/files/open?path=' + encodeURIComponent(path));
      if (res.ok) {
        const data = await res.json();
        // Update model content if changed (and not dirty with unsaved user edits)
        if (state.model && !state.isDirty) {
          const currentContent = state.model.getValue();
          if (currentContent !== data.content) {
            state.model.setValue(data.content);
            state.originalContent = data.content;
          }
        }
      }
    } catch (err) {
      console.error('Failed to refresh tab:', path, err);
    }
  }
}
let pushInProgress = false;
async function gitPush(username = null, password = null) {
  // Prevent double-push
  if (pushInProgress) return;
  pushInProgress = true;
  try {
    setStatus('Pushing to remote...');
    const params = currentProject ? `?project=${encodeURIComponent(currentProject)}` : '';
    const body = { setUpstream: true };
    if (username && password) {
      body.username = username;
      body.password = password;
    }
    const res = await fetch('/api/git/push' + params, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body)
    });
    const data = await res.json();
    if (!res.ok) {
      // Check if authentication is needed (only prompt if no credentials were provided)
      if (data.needsAuth && !username) {
        showGitCredentialsModal();
        return;
      }
      throw new Error(data.error);
    }
    setStatus('Push successful');
    loadGitStatus();
  } catch (err) {
    setStatus('Push failed: ' + err.message, false);
  } finally {
    pushInProgress = false;
  }
}
// Show git credentials modal for authentication
function showGitCredentialsModal() {
  // Don't show if already open
  const modal = document.getElementById('git-credentials-modal');
  if (modal.open) return;
  document.getElementById('git-username').value = '';
  document.getElementById('git-password').value = '';
  modal.showModal();
}
// Retry push with credentials from modal
async function gitPushWithCredentials() {
  const username = document.getElementById('git-username').value.trim();
  const password = document.getElementById('git-password').value;
  if (!username || !password) {
    setStatus('Username and password/token required', false);
    return;
  }
  document.getElementById('git-credentials-modal').close();
  await gitPush(username, password);
}
No comments yet.