diff --git a/src/frontend/app.js b/src/frontend/app.js index 84b3e42..0f3b8dc 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -1246,47 +1246,97 @@ function renderQACard(s, idx) { } function renderProjects(container, sessions) { - var byGit = {}; // key → { name, list } + var byGit = {}; // key → { name, list, path, source, manualId } sessions.forEach(function(s) { var info = getRepoInfo(s.project, s.git_root); - if (!byGit[info.key]) byGit[info.key] = { name: info.name, list: [] }; + if (!byGit[info.key]) byGit[info.key] = { name: info.name, list: [], path: info.key !== 'unknown' ? info.key : '', source: 'session', manualId: '' }; byGit[info.key].list.push(s); }); + // Merge manually-registered projects (local-only or cloned from GitHub) so + // they appear as project cards even before any session exists for them. + (window.manualProjects || []).forEach(function(p) { + if (!byGit[p.path]) { + byGit[p.path] = { name: p.name, list: [], path: p.path, source: p.source || 'manual', manualId: p.id, _git: p.git, _lastAdded: p.addedAt }; + } else { + // Existing key from sessions — annotate with manual id so the launcher + // buttons can still show source and offer "remove from registry". + byGit[p.path].manualId = p.id; + if (!byGit[p.path].source || byGit[p.path].source === 'session') byGit[p.path].source = p.source || 'manual'; + } + }); + var sorted = Object.entries(byGit).sort(function(a, b) { - return b[1].list[0].last_ts - a[1].list[0].last_ts; + var aTs = a[1].list[0] ? a[1].list[0].last_ts : (a[1]._lastAdded ? Date.parse(a[1]._lastAdded) : 0); + var bTs = b[1].list[0] ? b[1].list[0].last_ts : (b[1]._lastAdded ? Date.parse(b[1]._lastAdded) : 0); + return bTs - aTs; }); + var html = '
'; + html += ''; + html += ''; + html += ''; + html += '
'; + if (sorted.length === 0) { - container.innerHTML = '
No projects found.
'; + container.innerHTML = html + '
No projects yet. Click + Add Project to register a local folder or clone one from GitHub.
'; return; } var globalIdx = 0; - var html = '
'; - html += ''; - html += ''; - html += '
'; html += '
'; sorted.forEach(function(entry) { var projKey = entry[0]; - var projName = entry[1].name; - var list = entry[1].list.slice().sort(function(a, b) { return b.last_ts - a.last_ts; }); + var projInfo = entry[1]; + var projName = projInfo.name; + var projPath = projInfo.path; + var list = projInfo.list.slice().sort(function(a, b) { return b.last_ts - a.last_ts; }); var color = getProjectColor(projName); var totalMsgs = list.reduce(function(s, e) { return s + (e.messages || 0); }, 0); var totalCost = list.reduce(function(s, e) { return s + getEstimatedSessionCost(e); }, 0); var costLabel = totalCost > 0 ? ' · ~$' + totalCost.toFixed(2) : ''; + var lastSession = list[0]; // most recent + + var sourceTag = ''; + if (projInfo.source === 'github-clone') sourceTag = 'github'; + else if (projInfo.source === 'manual') sourceTag = 'added'; - html += '
'; + var statsLine = list.length === 0 + ? 'no sessions yet' + : (list.length + ' sessions · ' + totalMsgs + ' msgs' + costLabel); + + html += '
'; html += '
'; html += ''; - html += '' + escHtml(projName) + ''; - html += '' + list.length + ' sessions · ' + totalMsgs + ' msgs' + escHtml(costLabel) + ''; + html += '' + escHtml(projName) + sourceTag + ''; + html += '' + escHtml(statsLine) + ''; + + // Launch buttons — fresh + resume last. Only render if we know the local path. + if (projPath) { + // Prefer the tool the user actually worked with in this project; default + // to claude for brand-new entries with no sessions yet. + var preferredTool = lastSession && lastSession.tool ? lastSession.tool : 'claude'; + html += ''; + if (lastSession) { + var lastId = lastSession.id || ''; + var lastTool = lastSession.tool || 'claude'; + html += ''; + } + } + + if (projInfo.manualId) { + html += ''; + } + html += ''; html += ''; html += '
'; html += '
'; - list.forEach(function(s) { html += renderQACard(s, globalIdx++); }); + if (list.length === 0) { + html += '
No sessions yet. Click ▶ New above to start one.
'; + } else { + list.forEach(function(s) { html += renderQACard(s, globalIdx++); }); + } html += '
'; html += '
'; }); @@ -2106,12 +2156,438 @@ function dismissUpdate() { if (banner) banner.style.display = 'none'; } +// ── Project launcher: manual registry, New/Last session, Add Project modal ── + +window.manualProjects = window.manualProjects || []; +var _githubReposCache = { owned: null, contributing: null }; + +async function loadManualProjects() { + try { + var resp = await fetch('/api/projects/manual'); + var data = await resp.json(); + window.manualProjects = Array.isArray(data) ? data : []; + if (currentView === 'projects') render(); + } catch (e) { + console.warn('[codbash] loadManualProjects failed:', e && e.message); + } +} + +function _currentTerminalId() { + return localStorage.getItem('codedash-terminal') || ''; +} + +async function launchNewProjectSession(projectPath, tool) { + if (!projectPath) { showToast('No project path'); return; } + var t = tool || 'claude'; + try { + var resp = await fetch('/api/launch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mode: 'fresh', + tool: t, + flags: [], + project: projectPath, + terminal: _currentTerminalId(), + }), + }); + var data = await resp.json(); + if (data.ok) showToast('Starting new ' + t + ' session in ' + projectPath.split('/').pop()); + else showToast('Launch failed: ' + (data.error || 'unknown')); + } catch (e) { + showToast('Launch failed: ' + e.message); + } +} + +async function resumeLastProjectSession(sessionId, tool, projectPath) { + if (!sessionId) { showToast('No previous session to resume'); return; } + try { + var resp = await fetch('/api/launch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: sessionId, + tool: tool || 'claude', + flags: [], + project: projectPath || '', + terminal: _currentTerminalId(), + }), + }); + var data = await resp.json(); + if (data.ok) showToast('Resuming ' + sessionId.slice(0, 8) + '…'); + else showToast('Resume failed: ' + (data.error || 'unknown')); + } catch (e) { + showToast('Resume failed: ' + e.message); + } +} + +async function unregisterProject(id, name) { + if (!id) return; + if (!confirm('Remove “' + name + '” from project registry? Files on disk are untouched.')) return; + try { + var resp = await fetch('/api/projects/manual/' + encodeURIComponent(id), { method: 'DELETE' }); + var data = await resp.json(); + if (data.ok) { + showToast('Removed ' + name); + await loadManualProjects(); + } else { + showToast('Remove failed'); + } + } catch (e) { + showToast('Remove failed: ' + e.message); + } +} + +// ── Add Project modal ───────────────────────────────────────── + +function openAddProject() { + var overlay = document.getElementById('addProjectOverlay'); + if (!overlay) return; + overlay.classList.add('open'); + addProjectSwitchTab('local'); + var input = document.getElementById('apLocalPath'); + if (input) { input.value = ''; setTimeout(function() { input.focus(); }, 50); } + var err = document.getElementById('apLocalError'); if (err) err.textContent = ''; +} + +function closeAddProject() { + var overlay = document.getElementById('addProjectOverlay'); + if (overlay) overlay.classList.remove('open'); + // Stop any in-flight device-code polling when the user dismisses the modal. + _stopRepoScopePolling(); + _repoScopeDeviceCode = ''; +} + +function addProjectSwitchTab(tab) { + ['local', 'owned', 'contributing'].forEach(function(t) { + var btn = document.querySelector('.ap-tab[data-tab="' + t + '"]'); + if (btn) btn.classList.toggle('active', t === tab); + }); + document.getElementById('apPaneLocal').style.display = tab === 'local' ? '' : 'none'; + document.getElementById('apPaneOwned').style.display = tab === 'owned' ? '' : 'none'; + document.getElementById('apPaneContrib').style.display = tab === 'contributing' ? '' : 'none'; + var addBtn = document.getElementById('apLocalAddBtn'); + if (addBtn) addBtn.style.display = tab === 'local' ? '' : 'none'; + + if (tab === 'owned' || tab === 'contributing') { + var apiType = tab === 'owned' ? 'owned' : 'contributing'; + if (!_githubReposCache[apiType]) loadGithubRepos(apiType); + else renderRepoList(apiType); + } +} + +async function submitAddLocalProject() { + var input = document.getElementById('apLocalPath'); + var err = document.getElementById('apLocalError'); + if (err) err.textContent = ''; + var p = input ? input.value.trim() : ''; + if (!p) { if (err) err.textContent = 'Path required'; return; } + try { + var resp = await fetch('/api/projects/manual', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: p, source: 'manual' }), + }); + var data = await resp.json(); + if (data.ok) { + showToast('Added ' + (data.project.name || p)); + closeAddProject(); + await loadManualProjects(); + } else { + if (err) err.textContent = data.error || 'Failed to add'; + } + } catch (e) { + if (err) err.textContent = e.message; + } +} + +async function loadGithubRepos(apiType) { + var containerId = apiType === 'owned' ? 'apOwnedRepos' : 'apContribRepos'; + var container = document.getElementById(containerId); + if (!container) return; + // Re-entering this view cancels any stale polling that was running for a + // previous device-code flow. + _stopRepoScopePolling(); + container.innerHTML = '
Loading repos…
'; + try { + var resp = await fetch('/api/github/repos?type=' + apiType); + if (resp.status === 401) { + var data401 = await resp.json().catch(function() { return {}; }); + if (data401.needsRepoScope) { + renderRepoScopeConnectPanel(containerId, apiType); + } else { + container.innerHTML = '
GitHub not connected. Open Cloud view → connect GitHub, then return here.
'; + } + return; + } + if (!resp.ok) { + var errBody = await resp.json().catch(function() { return {}; }); + container.innerHTML = '
' + escHtml(errBody.error || ('HTTP ' + resp.status)) + '
'; + return; + } + var data = await resp.json(); + if (!Array.isArray(data)) { + container.innerHTML = '
' + escHtml(data.error || 'Failed to load') + '
'; + return; + } + _githubReposCache[apiType] = data; + renderRepoList(apiType); + } catch (e) { + container.innerHTML = '
Failed to load: ' + escHtml(e.message) + '
'; + } +} + +// ── Repo-scope connect panel + device-code polling ────────────── + +var _repoScopePolling = null; +var _repoScopeInterval = 0; // current poll interval in ms (mutable on slow_down) +var _repoScopeDeadline = 0; +var _repoScopeDeviceCode = ''; +var _repoScopeApiType = ''; +var _repoScopeErrorStreak = 0; + +function _stopRepoScopePolling() { + if (_repoScopePolling) { clearInterval(_repoScopePolling); _repoScopePolling = null; } +} + +function renderRepoScopeConnectPanel(containerId, apiType) { + // A user re-entering the connect panel cancels any prior in-flight polling. + _stopRepoScopePolling(); + var container = document.getElementById(containerId); + if (!container) return; + container.innerHTML = '' + + '
' + + '
Repo access not granted yet
' + + '
' + + ' To list your repositories the dashboard needs a separate GitHub authorization. The new token is stored locally and is never sent to the leaderboard — it is used only here in the project launcher.' + + '
' + + ' ' + + '
' + + ' ' + + '
' + + '
' + + '
'; +} + +async function startRepoScopeConnect(apiType, btn) { + if (btn) btn.disabled = true; // prevent double-clicks racing two device flows + var status = document.getElementById('apConnectStatus'); + if (status) status.textContent = 'Requesting device code…'; + var publicOnly = !!(document.getElementById('apPublicOnlyToggle') && document.getElementById('apPublicOnlyToggle').checked); + try { + var resp = await fetch('/api/github/repo-scope/device-code', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ publicOnly: publicOnly }), + }); + var data = await resp.json(); + if (data.error) { + if (status) status.textContent = 'Failed: ' + data.error; + if (btn) btn.disabled = false; + return; + } + showDeviceCodePanel(data, apiType); + } catch (e) { + if (status) status.textContent = 'Failed: ' + e.message; + showToast('Connect failed: ' + e.message); + if (btn) btn.disabled = false; + } +} + +function showDeviceCodePanel(deviceData, apiType) { + var status = document.getElementById('apConnectStatus'); + if (!status) return; + // user_code goes into innerHTML via escHtml AND into a data-attribute on the + // Copy button — the click handler reads from `dataset.code` instead of the + // attribute being interpolated into an inline JS string, so any quote + // characters in a future user_code value cannot break out of the handler. + status.innerHTML = '' + + '
' + + '
1. Open ' + escHtml(deviceData.verification_uri) + '
' + + '
2. Enter code: ' + escHtml(deviceData.user_code) + ' ' + + '
' + + '
Waiting for authorization…
' + + '
'; + _stopRepoScopePolling(); + _repoScopeDeadline = Date.now() + (deviceData.expires_in || 900) * 1000; + _repoScopeInterval = (deviceData.interval || 5) * 1000; + _repoScopeDeviceCode = deviceData.device_code; + _repoScopeApiType = apiType; + _repoScopeErrorStreak = 0; + _repoScopePolling = setInterval(_repoScopeTick, _repoScopeInterval); +} + +function _repoScopeTick() { + if (Date.now() > _repoScopeDeadline) { + _stopRepoScopePolling(); + var st = document.getElementById('apPollStatus'); + if (st) st.textContent = 'Code expired. Click Connect to try again.'; + return; + } + pollRepoScopeOnce(); +} + +async function pollRepoScopeOnce() { + var deviceCode = _repoScopeDeviceCode; + var apiType = _repoScopeApiType; + try { + var resp = await fetch('/api/github/repo-scope/poll-token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ device_code: deviceCode }), + }); + var data = await resp.json(); + // Bail out if a different flow took over while this poll was in flight. + if (deviceCode !== _repoScopeDeviceCode) return; + if (data.error) { + var st = document.getElementById('apPollStatus'); + if (st) st.textContent = 'Error: ' + data.error; + _stopRepoScopePolling(); + return; + } + if (data.status === 'pending') { + _repoScopeErrorStreak = 0; + return; + } + if (data.status === 'slow_down') { + // RFC 8628 §3.5: add at least 5s on slow_down and wait the new interval. + _stopRepoScopePolling(); + _repoScopeInterval = _repoScopeInterval + 5000; + _repoScopePolling = setInterval(_repoScopeTick, _repoScopeInterval); + return; + } + if (data.status === 'expired') { + var st2 = document.getElementById('apPollStatus'); + if (st2) st2.textContent = 'Code expired. Click Connect to try again.'; + _stopRepoScopePolling(); + return; + } + if (data.status === 'ok') { + _stopRepoScopePolling(); + showToast('GitHub repo access granted'); + _githubReposCache.owned = null; + _githubReposCache.contributing = null; + loadGithubRepos(apiType); + } + } catch (e) { + // Transient network failures shouldn't kill the flow. Persistent failures + // (3 in a row) surface to the user instead of polling silently forever. + _repoScopeErrorStreak += 1; + if (_repoScopeErrorStreak >= 3) { + _stopRepoScopePolling(); + var st3 = document.getElementById('apPollStatus'); + if (st3) st3.textContent = 'Connection error. Please retry.'; + } + } +} + +async function disconnectRepoScope() { + if (!confirm('Disconnect repo-scope GitHub access locally? You can re-connect any time. To fully revoke the OAuth authorization you must also visit github.com → Settings → Applications.')) return; + // Cancel any in-flight polling first — otherwise a poll could race the + // disconnect and silently restore the token if GitHub returns ok at the + // same moment. + _stopRepoScopePolling(); + _repoScopeDeviceCode = ''; + try { + var resp = await fetch('/api/github/repo-scope/disconnect', { method: 'POST' }); + var data = await resp.json().catch(function() { return {}; }); + _githubReposCache.owned = null; + _githubReposCache.contributing = null; + showToast('Repo access disconnected locally'); + if (data.revokeUrl) { + // Nudge the user toward fully revoking on github.com, since clearing + // locally does not invalidate the token at GitHub. + var go = confirm('Also fully revoke the GitHub OAuth authorization?\n\nThis opens github.com so you can disconnect the app there too.'); + if (go) window.open(data.revokeUrl, '_blank', 'noopener'); + } + var tab = document.querySelector('.ap-tab.active'); + if (tab) addProjectSwitchTab(tab.getAttribute('data-tab')); + } catch (e) { + showToast('Disconnect failed: ' + e.message); + } +} + +function renderRepoList(apiType) { + var containerId = apiType === 'owned' ? 'apOwnedRepos' : 'apContribRepos'; + var filterId = apiType === 'owned' ? 'apOwnedFilter' : 'apContribFilter'; + var container = document.getElementById(containerId); + var filter = document.getElementById(filterId); + if (!container) return; + var repos = _githubReposCache[apiType] || []; + var q = filter ? filter.value.trim().toLowerCase() : ''; + var filtered = q + ? repos.filter(function(r) { + return (r.fullName || '').toLowerCase().indexOf(q) >= 0 + || (r.description || '').toLowerCase().indexOf(q) >= 0; + }) + : repos; + + if (filtered.length === 0) { + container.innerHTML = '
' + (q ? 'No repos match "' + escHtml(q) + '"' : 'No repos found') + '
'; + return; + } + var registeredPaths = (window.manualProjects || []).map(function(p) { return p.remoteUrl; }).filter(Boolean); + var html = '' + + '
' + + 'Showing ' + filtered.length + ' repos' + + '' + + '
'; + // No client-side truncation: the backend already caps at 300 repos and the + // filter has narrowed the list. Truncating here would hide matching repos + // when the user has many. + filtered.forEach(function(r) { + var already = registeredPaths.indexOf(r.cloneUrl) >= 0; + var meta = (r.private ? 'private · ' : '') + (r.description ? r.description : (r.htmlUrl || '')); + html += '
'; + html += '
'; + html += '
' + escHtml(r.fullName) + '
'; + html += '
' + escHtml(meta) + '
'; + html += '
'; + html += ''; + html += '
'; + }); + container.innerHTML = html; +} + +async function cloneRepoAndAdd(btn) { + var fullName = btn.getAttribute('data-full-name'); + var cloneUrl = btn.getAttribute('data-clone-url'); + var sshUrl = btn.getAttribute('data-ssh-url'); + var defaultBranch = btn.getAttribute('data-default-branch'); + btn.disabled = true; + btn.textContent = 'Cloning…'; + try { + var resp = await fetch('/api/projects/clone', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fullName: fullName, cloneUrl: cloneUrl, sshUrl: sshUrl, defaultBranch: defaultBranch }), + }); + var data = await resp.json(); + if (data.ok) { + showToast(data.alreadyExisted ? 'Linked existing clone of ' + fullName : 'Cloned ' + fullName); + btn.textContent = 'Added'; + await loadManualProjects(); + } else { + btn.disabled = false; + btn.textContent = 'Retry'; + showToast('Clone failed: ' + (data.error || 'unknown')); + } + } catch (e) { + btn.disabled = false; + btn.textContent = 'Retry'; + showToast('Clone failed: ' + e.message); + } +} + // ── Initialization ───────────────────────────────────────────── (function init() { // Load data loadSessions(); loadTerminals(); + loadManualProjects(); checkForUpdates(); setInterval(checkForUpdates, 10000); // check every 10s setInterval(loadSessions, 60000); // refresh sessions + invalidate analytics cache every 60s diff --git a/src/frontend/index.html b/src/frontend/index.html index 8748325..8a64ddf 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -191,6 +191,38 @@

Delete Session?

+ +
+
+

Add project

+
+ + + +
+
+
+ + +
Must be an existing directory. ~ is expanded.
+
+
+ + +
+
+ + +
+
+
+