- 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>
1022 lines
38 KiB
HTML
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>
|