// 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">×</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);
}