Files
clawd/dashboard/workspace.html
Echo e92284645c feat: Workspace page enhancements - git ops, Gitea auto-create, compact stories
- Add workspace.html with project cards, Ralph status, git info
- Backend: git diff/commit/push endpoints, project delete with confirmation
- Push auto-creates Gitea repo (romfast org) when no remote configured
- GITEA_TOKEN read from dashboard/.env file
- Compact collapsible user stories (emoji row + expand on click)
- Action buttons: Diff (with count badge), Commit, Push, README, Delete
- Fix openPrd/openReadme to use hash navigation for files.html
- Add .gitignore template to ralph.sh for new projects
- Unify branches: merge main into master, delete main

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 12:21:13 +00:00

1022 lines
38 KiB
HTML

<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/echo/favicon.svg">
<title>Echo · Workspace</title>
<link rel="stylesheet" href="/echo/common.css">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<script src="/echo/swipe-nav.js"></script>
<style>
.main {
max-width: 1200px;
margin: 0 auto;
padding: var(--space-5);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-5);
}
.page-title {
font-size: var(--text-xl);
font-weight: 600;
color: var(--text-primary);
}
.page-subtitle {
font-size: var(--text-sm);
color: var(--text-muted);
margin-top: var(--space-1);
}
.projects-grid {
display: grid;
gap: var(--space-4);
}
/* Project Card */
.project-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-5);
transition: border-color var(--transition-base);
}
.project-card:hover {
border-color: var(--border-focus);
}
.project-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-4);
}
.project-name {
font-size: var(--text-lg);
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--space-2);
}
.project-name .indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
}
.project-name .indicator.running { background: var(--success); }
.project-name .indicator.ralph-running { background: var(--warning); }
.project-actions {
display: flex;
gap: var(--space-2);
}
.project-actions .btn {
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
}
/* Ralph Progress */
.ralph-section {
margin-bottom: var(--space-4);
}
.ralph-header {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.progress-bar {
width: 100%;
height: 6px;
background: var(--bg-surface-active);
border-radius: var(--radius-full);
overflow: hidden;
margin-bottom: var(--space-3);
}
.progress-fill {
height: 100%;
background: var(--success);
border-radius: var(--radius-full);
transition: width var(--transition-base);
}
.stories-compact {
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
font-size: var(--text-xs);
color: var(--text-secondary);
padding: var(--space-1) 0;
user-select: none;
}
.stories-compact .emoji-row {
display: flex;
gap: 2px;
font-size: 11px;
}
.stories-compact .chevron {
transition: transform var(--transition-fast);
color: var(--text-muted);
}
.stories-compact .chevron.open {
transform: rotate(180deg);
}
.stories-expanded {
display: none;
flex-direction: column;
gap: var(--space-1);
margin-top: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--bg-elevated);
border-radius: var(--radius-sm);
font-size: var(--text-xs);
}
.stories-expanded.open {
display: flex;
}
.story-line {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--text-secondary);
}
.story-line.pass { color: var(--success); }
.story-line.fail { color: var(--text-muted); }
/* Git info */
.git-info {
display: flex;
align-items: center;
gap: var(--space-3);
font-size: var(--text-xs);
color: var(--text-muted);
margin-bottom: var(--space-4);
padding: var(--space-2) var(--space-3);
background: var(--bg-elevated);
border-radius: var(--radius-sm);
}
.git-info span {
display: flex;
align-items: center;
gap: var(--space-1);
}
/* Command box */
.cmd-box {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-4);
padding: var(--space-2) var(--space-3);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.cmd-box code {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cmd-box .btn {
flex-shrink: 0;
padding: var(--space-1) var(--space-2);
font-size: var(--text-xs);
}
/* Server link */
.server-link {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: var(--radius-md);
color: var(--success);
font-size: var(--text-sm);
font-weight: 500;
text-decoration: none;
margin-bottom: var(--space-4);
transition: all var(--transition-fast);
}
.server-link:hover {
background: rgba(34, 197, 94, 0.25);
}
/* Action buttons row */
.action-row {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.action-row .btn {
font-size: var(--text-xs);
padding: var(--space-2) var(--space-3);
}
/* Log Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 200;
backdrop-filter: blur(4px);
}
.modal-overlay.active { display: flex; align-items: center; justify-content: center; }
.modal {
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
width: 90%;
max-width: 800px;
max-height: 85vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-4);
border-bottom: 1px solid var(--border);
}
.modal-title {
font-size: var(--text-base);
font-weight: 600;
color: var(--text-primary);
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
}
.log-content {
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: 1.6;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
}
/* Test output */
.test-output {
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: 1.6;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
padding: var(--space-4);
background: var(--bg-surface);
border-radius: var(--radius-md);
border: 1px solid var(--border);
max-height: 300px;
overflow-y: auto;
margin-top: var(--space-3);
}
/* Loading spinner */
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--text-muted);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Diff display */
.diff-line { font-family: var(--font-mono); font-size: var(--text-xs); white-space: pre-wrap; word-break: break-all; }
.diff-add { color: #22c55e; }
.diff-del { color: #ef4444; }
.diff-hunk { color: #60a5fa; font-weight: 600; }
.diff-header { color: var(--text-muted); }
/* Badge */
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: var(--radius-full);
background: var(--warning);
color: #000;
font-size: 10px;
font-weight: 700;
line-height: 1;
}
/* Delete button */
.btn-danger {
border-color: var(--error);
color: var(--error);
}
.btn-danger:hover {
background: var(--error);
color: white;
}
/* Modal input */
.modal-input {
width: 100%;
padding: var(--space-2) var(--space-3);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: var(--text-sm);
font-family: inherit;
outline: none;
box-sizing: border-box;
}
.modal-input:focus {
border-color: var(--accent);
}
.modal-actions {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
margin-top: var(--space-4);
}
.warning-text {
color: var(--error);
font-size: var(--text-sm);
margin-bottom: var(--space-3);
}
/* No projects */
.empty-state {
text-align: center;
padding: var(--space-10);
color: var(--text-muted);
}
/* Responsive */
@media (max-width: 768px) {
.project-header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
}
.git-info {
flex-wrap: wrap;
}
.action-row {
flex-wrap: wrap;
}
}
</style>
</head>
<body>
<header class="header">
<a href="/echo/index.html" class="logo">
<i data-lucide="circle-dot"></i>
Echo
</a>
<nav class="nav">
<a href="/echo/index.html" class="nav-item">
<i data-lucide="layout-dashboard"></i>
<span>Dashboard</span>
</a>
<a href="/echo/workspace.html" class="nav-item active">
<i data-lucide="code"></i>
<span>Workspace</span>
</a>
<a href="/echo/notes.html" class="nav-item">
<i data-lucide="file-text"></i>
<span>KB</span>
</a>
<a href="/echo/files.html" class="nav-item">
<i data-lucide="folder"></i>
<span>Files</span>
</a>
<button class="theme-toggle" onclick="toggleTheme()" title="Schimba tema">
<i data-lucide="sun" id="themeIcon"></i>
</button>
</nav>
</header>
<main class="main">
<div class="page-header">
<div>
<h1 class="page-title">Workspace Projects</h1>
<div class="page-subtitle" id="projectCount"></div>
</div>
<button class="btn btn-secondary" onclick="loadProjects()">
<i data-lucide="refresh-cw"></i>
Refresh
</button>
</div>
<div class="projects-grid" id="projectsGrid">
<div class="empty-state">Loading...</div>
</div>
</main>
<!-- Log Modal -->
<div class="modal-overlay" id="logModal">
<div class="modal">
<div class="modal-header">
<span class="modal-title" id="logModalTitle">Logs</span>
<button class="btn btn-ghost" onclick="closeLogModal()">
<i data-lucide="x"></i>
</button>
</div>
<div class="modal-body">
<div class="log-content" id="logContent">Loading...</div>
</div>
</div>
</div>
<script>
let projects = [];
let logRefreshInterval = null;
// Theme
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
}
(function() {
const saved = localStorage.getItem('theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
})();
async function loadProjects() {
try {
const res = await fetch('/echo/api/workspace');
const data = await res.json();
projects = data.projects || [];
renderProjects();
} catch (e) {
document.getElementById('projectsGrid').innerHTML =
'<div class="empty-state">Error loading projects: ' + e.message + '</div>';
}
}
function renderProjects() {
const grid = document.getElementById('projectsGrid');
const count = document.getElementById('projectCount');
if (projects.length === 0) {
grid.innerHTML = '<div class="empty-state">No projects found in ~/workspace/</div>';
count.textContent = '';
return;
}
count.textContent = projects.length + ' project' + (projects.length !== 1 ? 's' : '');
grid.innerHTML = projects.map(proj => {
const isRunning = proj.process && proj.process.running;
const isRalphRunning = proj.ralph && proj.ralph.running;
const indicatorClass = isRalphRunning ? 'ralph-running' : (isRunning ? 'running' : '');
let ralphHtml = '';
if (proj.ralph) {
const r = proj.ralph;
const pct = r.storiesTotal > 0 ? Math.round((r.storiesComplete / r.storiesTotal) * 100) : 0;
const remaining = r.storiesTotal - r.storiesComplete;
ralphHtml = `
<div class="ralph-section">
<div class="ralph-header">
<span>Ralph: ${r.storiesComplete}/${r.storiesTotal} stories (${pct}%)</span>
${r.running ? '<span class="spinner"></span>' : ''}
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: ${pct}%"></div>
</div>
<div class="stories-compact" onclick="toggleStories('${proj.name}')">
<span class="emoji-row">${r.stories.map(s => s.passes ? '\u2705' : '\u2B1C').join('')}</span>
<span>${r.storiesComplete} passing${remaining > 0 ? ', ' + remaining + ' remaining' : ''}</span>
<i data-lucide="chevron-down" class="chevron" id="chevron-${proj.name}" style="width:14px;height:14px;"></i>
</div>
<div class="stories-expanded" id="stories-${proj.name}">
${r.stories.map(s => `
<div class="story-line ${s.passes ? 'pass' : 'fail'}">
<span>${s.passes ? '\u2705' : '\u2B1C'}</span>
<span>${s.id}: ${s.title}</span>
</div>
`).join('')}
</div>
</div>
`;
}
let gitHtml = '';
if (proj.git) {
const g = proj.git;
gitHtml = `
<div class="git-info">
<span><i data-lucide="git-branch"></i> ${g.branch}</span>
<span>${g.uncommitted} uncommitted</span>
<span>${g.lastCommit || 'No commits'}</span>
</div>
`;
}
// Determine start command and port
const startCmd = (proj.techStack?.commands?.start || 'python main.py').replace(/\bpython\b(?!3)/g, 'python3');
const port = proj.techStack?.port;
const fullCmd = `cd ~/workspace/${proj.name} && ${startCmd}`;
// Server running link
let serverHtml = '';
if (isRunning && port) {
serverHtml = `
<a href="http://moltbot:${port}" target="_blank" class="server-link">
<i data-lucide="external-link"></i>
http://moltbot:${port}
</a>
`;
}
// Command box for CLI (show when not running)
let cmdHtml = '';
if (!isRunning && proj.hasMain) {
cmdHtml = `
<div class="cmd-box">
<code>${fullCmd}</code>
<button class="btn btn-ghost" onclick="copyCmd('${fullCmd}')" title="Copy">
<i data-lucide="copy"></i>
</button>
<a href="http://moltbot:7681" target="_blank" class="btn btn-ghost" title="Open Terminal">
<i data-lucide="terminal"></i>
</a>
</div>
`;
}
return `
<div class="project-card" id="project-${proj.name}">
<div class="project-header">
<div class="project-name">
<span class="indicator ${indicatorClass}"></span>
${proj.name}
${proj.hasVenv ? '<span class="tag">venv</span>' : ''}
</div>
<div class="project-actions">
${isRunning
? `<button class="btn btn-secondary" onclick="stopProject('${proj.name}', 'main')" style="border-color: var(--error); color: var(--error);">
<i data-lucide="square"></i> Stop
</button>`
: `<button class="btn btn-primary" onclick="runProject('${proj.name}', 'main')" ${!proj.hasMain ? 'disabled' : ''}>
<i data-lucide="play"></i> Run
</button>`
}
</div>
</div>
${serverHtml}
${cmdHtml}
${ralphHtml}
${gitHtml}
<div class="action-row">
<button class="btn btn-secondary" onclick="runTest('${proj.name}')">
<i data-lucide="flask-conical"></i> Test
</button>
<button class="btn btn-secondary" onclick="showLogs('${proj.name}', 'ralph')">
<i data-lucide="scroll-text"></i> Logs
</button>
${proj.git && proj.git.uncommitted > 0
? `<button class="btn btn-secondary" onclick="showGitDiff('${proj.name}')">
<i data-lucide="file-diff"></i> Diff <span class="badge">${proj.git.uncommitted}</span>
</button>
<button class="btn btn-secondary" onclick="gitCommit('${proj.name}')">
<i data-lucide="git-commit-horizontal"></i> Commit
</button>`
: ''
}
${proj.git
? `<button class="btn btn-secondary" onclick="gitPush('${proj.name}')">
<i data-lucide="upload"></i> Push
</button>`
: ''
}
${proj.hasPrd
? `<button class="btn btn-secondary" onclick="openPrd('${proj.name}')">
<i data-lucide="file-text"></i> PRD
</button>`
: ''
}
${proj.hasReadme
? `<button class="btn btn-secondary" onclick="openReadme('${proj.name}')">
<i data-lucide="book-open"></i> README
</button>`
: ''
}
${proj.hasRalph
? (isRalphRunning
? `<button class="btn btn-secondary" onclick="stopProject('${proj.name}', 'ralph')" style="border-color: var(--warning); color: var(--warning);">
<i data-lucide="square"></i> Ralph
</button>`
: `<button class="btn btn-secondary" onclick="runProject('${proj.name}', 'ralph')">
<i data-lucide="bot"></i> Ralph
</button>`)
: ''
}
<a href="http://moltbot:7681" target="_blank" class="btn btn-secondary">
<i data-lucide="terminal"></i> Terminal
</a>
<button class="btn btn-secondary btn-danger" onclick="deleteProject('${proj.name}')" title="Delete project">
<i data-lucide="trash-2"></i>
</button>
</div>
<div id="test-output-${proj.name}"></div>
</div>
`;
}).join('');
lucide.createIcons();
}
async function runProject(name, command) {
try {
const res = await fetch('/echo/api/workspace/run', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project: name, command: command})
});
const data = await res.json();
if (data.success) {
setTimeout(loadProjects, 1000);
} else {
alert('Error: ' + data.error);
}
} catch (e) {
alert('Failed: ' + e.message);
}
}
async function stopProject(name, target) {
try {
const res = await fetch('/echo/api/workspace/stop', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project: name, target: target})
});
const data = await res.json();
if (data.success) {
setTimeout(loadProjects, 1000);
} else {
alert('Error: ' + data.error);
}
} catch (e) {
alert('Failed: ' + e.message);
}
}
async function runTest(name) {
const outputEl = document.getElementById('test-output-' + name);
outputEl.innerHTML = '<div class="test-output"><span class="spinner"></span> Running pytest...</div>';
try {
const res = await fetch('/echo/api/workspace/run', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project: name, command: 'test'})
});
const data = await res.json();
const color = data.success ? 'var(--success)' : 'var(--error)';
outputEl.innerHTML = `
<div class="test-output" style="border-color: ${color}">
<div style="margin-bottom: 8px; color: ${color}; font-weight: 600;">
${data.success ? 'PASSED' : 'FAILED'}
</div>
${data.output || 'No output'}
</div>
`;
} catch (e) {
outputEl.innerHTML = `<div class="test-output" style="border-color: var(--error)">Error: ${e.message}</div>`;
}
}
async function showLogs(name, type) {
const modal = document.getElementById('logModal');
const title = document.getElementById('logModalTitle');
const content = document.getElementById('logContent');
title.textContent = `${name} - ${type} logs`;
content.textContent = 'Loading...';
modal.classList.add('active');
// Clear existing interval
if (logRefreshInterval) clearInterval(logRefreshInterval);
async function fetchLogs() {
try {
const res = await fetch(`/echo/api/workspace/logs?project=${name}&type=${type}&lines=100`);
const data = await res.json();
content.textContent = data.lines.join('\n') || '(empty)';
content.scrollTop = content.scrollHeight;
} catch (e) {
content.textContent = 'Error: ' + e.message;
}
}
await fetchLogs();
// Auto-refresh while modal is open
const proj = projects.find(p => p.name === name);
if (proj && ((proj.ralph && proj.ralph.running) || (proj.process && proj.process.running))) {
logRefreshInterval = setInterval(fetchLogs, 3000);
}
}
function closeLogModal() {
document.getElementById('logModal').classList.remove('active');
if (logRefreshInterval) {
clearInterval(logRefreshInterval);
logRefreshInterval = null;
}
}
function copyCmd(cmd) {
navigator.clipboard.writeText(cmd).then(() => {
// Brief visual feedback - would need a toast, just use title change
}).catch(() => {
// Fallback for non-HTTPS
const el = document.createElement('textarea');
el.value = cmd;
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
});
}
function openPrd(name) {
window.location.href = `/echo/files.html#${name}/tasks/prd-${name}.md`;
}
function openReadme(name) {
window.location.href = `/echo/files.html#${name}/README.md`;
}
function toggleStories(name) {
const el = document.getElementById('stories-' + name);
const chevron = document.getElementById('chevron-' + name);
if (el) {
el.classList.toggle('open');
if (chevron) chevron.classList.toggle('open');
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDiff(status, diff) {
let html = '';
if (status) {
html += '<div style="margin-bottom:12px;"><strong>Status:</strong></div>';
html += status.split('\n').map(l => `<div class="diff-line diff-header">${escapeHtml(l)}</div>`).join('');
html += '<hr style="border-color:var(--border);margin:12px 0;">';
}
if (diff) {
html += diff.split('\n').map(l => {
const esc = escapeHtml(l);
if (l.startsWith('+') && !l.startsWith('+++')) return `<div class="diff-line diff-add">${esc}</div>`;
if (l.startsWith('-') && !l.startsWith('---')) return `<div class="diff-line diff-del">${esc}</div>`;
if (l.startsWith('@@')) return `<div class="diff-line diff-hunk">${esc}</div>`;
if (l.startsWith('===')) return `<div class="diff-line diff-hunk">${esc}</div>`;
return `<div class="diff-line">${esc}</div>`;
}).join('');
}
if (!status && !diff) {
html = '<div style="color:var(--text-muted);">No changes</div>';
}
return html;
}
async function showGitDiff(name) {
const modal = document.getElementById('logModal');
const title = document.getElementById('logModalTitle');
const content = document.getElementById('logContent');
title.textContent = `${name} - Git Diff`;
content.innerHTML = '<span class="spinner"></span> Loading diff...';
modal.classList.add('active');
if (logRefreshInterval) clearInterval(logRefreshInterval);
try {
const res = await fetch(`/echo/api/workspace/git/diff?project=${name}`);
const data = await res.json();
if (data.error) {
content.innerHTML = `<div style="color:var(--error);">${escapeHtml(data.error)}</div>`;
} else {
content.innerHTML = formatDiff(data.status, data.diff);
}
} catch (e) {
content.innerHTML = `<div style="color:var(--error);">Error: ${escapeHtml(e.message)}</div>`;
}
}
function gitCommit(name) {
const modal = document.getElementById('logModal');
const title = document.getElementById('logModalTitle');
const content = document.getElementById('logContent');
title.textContent = `${name} - Commit`;
content.innerHTML = `
<p style="color:var(--text-secondary);margin-bottom:var(--space-3);">Commit message (leave empty for auto-generated):</p>
<input type="text" class="modal-input" id="commitMsg" placeholder="Update: YYYY-MM-DD HH:MM (N files)">
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeLogModal()">Cancel</button>
<button class="btn btn-primary" onclick="doCommit('${name}')">
<i data-lucide="git-commit-horizontal"></i> Commit
</button>
</div>
`;
modal.classList.add('active');
lucide.createIcons();
setTimeout(() => document.getElementById('commitMsg')?.focus(), 100);
}
async function doCommit(name) {
const content = document.getElementById('logContent');
const msg = document.getElementById('commitMsg')?.value || '';
content.innerHTML = '<span class="spinner"></span> Committing...';
try {
const res = await fetch('/echo/api/workspace/git/commit', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project: name, message: msg})
});
const data = await res.json();
if (data.success) {
content.innerHTML = `
<div style="color:var(--success);font-weight:600;margin-bottom:8px;">Committed (${data.filesChanged} files)</div>
<div style="color:var(--text-secondary);margin-bottom:8px;">${escapeHtml(data.message)}</div>
<div class="diff-line diff-header">${escapeHtml(data.output || '')}</div>
`;
loadProjects();
} else {
content.innerHTML = `<div style="color:var(--error);">${escapeHtml(data.error)}</div>`;
}
} catch (e) {
content.innerHTML = `<div style="color:var(--error);">Error: ${escapeHtml(e.message)}</div>`;
}
}
async function gitPush(name) {
const modal = document.getElementById('logModal');
const title = document.getElementById('logModalTitle');
const content = document.getElementById('logContent');
title.textContent = `${name} - Push`;
content.innerHTML = '<span class="spinner"></span> Pushing...';
modal.classList.add('active');
try {
const res = await fetch('/echo/api/workspace/git/push', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project: name})
});
const data = await res.json();
if (data.success) {
content.innerHTML = `
<div style="color:var(--success);font-weight:600;margin-bottom:8px;">Push successful</div>
<div class="diff-line diff-header">${escapeHtml(data.output || '')}</div>
`;
} else {
content.innerHTML = `<div style="color:var(--error);">${escapeHtml(data.error)}</div>`;
}
} catch (e) {
content.innerHTML = `<div style="color:var(--error);">Error: ${escapeHtml(e.message)}</div>`;
}
}
function deleteProject(name) {
const modal = document.getElementById('logModal');
const title = document.getElementById('logModalTitle');
const content = document.getElementById('logContent');
title.textContent = `Delete ${name}`;
content.innerHTML = `
<div class="warning-text">This will permanently delete ~/workspace/${escapeHtml(name)} and all its contents. This cannot be undone.</div>
<p style="color:var(--text-secondary);margin-bottom:var(--space-3);">Type <strong>${escapeHtml(name)}</strong> to confirm:</p>
<input type="text" class="modal-input" id="deleteConfirm" placeholder="${escapeHtml(name)}" oninput="checkDeleteConfirm('${escapeHtml(name)}')">
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeLogModal()">Cancel</button>
<button class="btn btn-danger" id="deleteBtn" onclick="doDelete('${name}')" disabled>
<i data-lucide="trash-2"></i> Delete
</button>
</div>
`;
modal.classList.add('active');
lucide.createIcons();
setTimeout(() => document.getElementById('deleteConfirm')?.focus(), 100);
}
function checkDeleteConfirm(name) {
const input = document.getElementById('deleteConfirm');
const btn = document.getElementById('deleteBtn');
if (input && btn) {
btn.disabled = input.value !== name;
}
}
async function doDelete(name) {
const content = document.getElementById('logContent');
content.innerHTML = '<span class="spinner"></span> Deleting...';
try {
const res = await fetch('/echo/api/workspace/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project: name, confirm: name})
});
const data = await res.json();
if (data.success) {
content.innerHTML = `<div style="color:var(--success);font-weight:600;">${escapeHtml(data.message)}</div>`;
setTimeout(() => { closeLogModal(); loadProjects(); }, 1000);
} else {
content.innerHTML = `<div style="color:var(--error);">${escapeHtml(data.error)}</div>`;
}
} catch (e) {
content.innerHTML = `<div style="color:var(--error);">Error: ${escapeHtml(e.message)}</div>`;
}
}
// Close modal on backdrop click
document.getElementById('logModal').addEventListener('click', function(e) {
if (e.target === this) closeLogModal();
});
// Close modal on Escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeLogModal();
});
// Auto-refresh status every 10 seconds
let autoRefresh = setInterval(loadProjects, 10000);
// Initial load
loadProjects();
lucide.createIcons();
</script>
</body>
</html>