feat: live Repo/CI panel, env.local overrides (0.3.0)
- `devdesk attach --repo NAME` / `devdesk repo NAME`: git state of the session's folder (branch, ahead/behind, uncommitted changes) plus the Gitea commit status of HEAD and open PR count. Uses the anonymous API for public repos, or /etc/devdesk/gitea-token when present. Runs over the ttyd channel, so no sidecar is needed. The panel links to the repo, the CI run and PRs, and a notification fires when HEAD's CI finishes while the tab is in the background. - /etc/devdesk/env.local is read after the packaged env and never shipped, so upgrades no longer stop at a conffile prompt. - Hooks and status line only record for panes on DevDesk's own tmux server; the Claude wrapper re-reads env before every launch (so the Remote Control toggle applies on the next Claude restart). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -89,8 +89,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="lt-frame dd-widget" id="widget-repo" data-state="stub">
|
||||
<div class="lt-section-header">Repo / CI <span class="lt-badge lt-badge-sm dd-soon">v1.1</span></div>
|
||||
<section class="lt-frame dd-widget" id="widget-repo" data-state="empty">
|
||||
<div class="lt-section-header">Repo / CI</div>
|
||||
<div class="lt-section-body">
|
||||
<div class="lt-kv-grid">
|
||||
<span class="lt-kv-key">Repo</span><span class="lt-kv-val" data-field="repo">—</span>
|
||||
@@ -99,7 +99,7 @@
|
||||
<span class="lt-kv-key">CI · HEAD</span><span class="lt-kv-val"><span class="lt-status lt-status-pending" data-field="ci">no data</span></span>
|
||||
<span class="lt-kv-key">Open PRs</span><span class="lt-kv-val" data-field="prs">—</span>
|
||||
</div>
|
||||
<p class="dd-muted dd-stub-note">Gitea status for this session's repo arrives with devdesk-api.</p>
|
||||
<p class="dd-muted dd-stub-note dd-repo-note"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -124,6 +124,11 @@ export async function fetchSessionList() {
|
||||
return parseListOutput(await runCommand([LIST_ARG]));
|
||||
}
|
||||
|
||||
// Git + Gitea CI status for a session's folder (`devdesk attach --repo NAME`).
|
||||
export async function fetchRepo(session) {
|
||||
return parseListOutput(await runCommand(['--repo', session]));
|
||||
}
|
||||
|
||||
// `devdesk attach --new|--rename|--end …`: resolves on {"ok":true}, otherwise
|
||||
// rejects with devdesk's own error message.
|
||||
export async function sessionAction(action, ...args) {
|
||||
|
||||
+35
-2
@@ -1,9 +1,10 @@
|
||||
/* global __DEVDESK_VERSION__ */
|
||||
import { createTerminal, loadFonts } from './term.js';
|
||||
import { TtydConnection, fetchToken, AuthExpiredError, UnauthorizedError } from './connection.js';
|
||||
import { TtydConnection, fetchRepo, fetchToken, AuthExpiredError, UnauthorizedError } from './connection.js';
|
||||
import { parseTitle, sessionFromSearch } from './protocol.js';
|
||||
import { SessionsPanel, rememberSession } from './sessions.js';
|
||||
import { renderContext } from './context.js';
|
||||
import { renderRepo } from './repo.js';
|
||||
import { bindAskClaude } from './widgets.js';
|
||||
import * as notify from './notify.js';
|
||||
|
||||
@@ -96,6 +97,34 @@ function showContext() {
|
||||
renderContext($('widget-context'), state.session, current?.context ?? null);
|
||||
}
|
||||
|
||||
// Repo / CI panel for the current session; notifies when HEAD's CI finishes.
|
||||
let lastCi = { key: '', state: '' };
|
||||
let repoBusy = false;
|
||||
async function refreshRepo() {
|
||||
const session = state.session;
|
||||
if (!session) {
|
||||
renderRepo($('widget-repo'), null);
|
||||
return;
|
||||
}
|
||||
if (repoBusy) return;
|
||||
repoBusy = true;
|
||||
try {
|
||||
const repo = await fetchRepo(session);
|
||||
if (session !== state.session) return;
|
||||
renderRepo($('widget-repo'), repo);
|
||||
const key = `${session}@${repo.sha}`;
|
||||
const ci = repo.ci?.total ? repo.ci.state : '';
|
||||
if (key === lastCi.key && lastCi.state === 'pending' && ['success', 'failure', 'error'].includes(ci)) {
|
||||
notify.ring(session, `CI ${ci === 'success' ? 'passed' : 'failed'} on ${repo.branch} @ ${repo.sha}`);
|
||||
}
|
||||
lastCi = { key, state: ci };
|
||||
} catch {
|
||||
// Keep the last rendering; the next refresh retries.
|
||||
} finally {
|
||||
repoBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
let refreshTimer = 0;
|
||||
function refreshSoon(delay = 1500) {
|
||||
clearTimeout(refreshTimer);
|
||||
@@ -133,6 +162,7 @@ function connect(session) {
|
||||
setWsState('connected', 'live');
|
||||
setSso('ok');
|
||||
if (session) rememberSession(session);
|
||||
refreshRepo();
|
||||
fit.fit();
|
||||
term.focus();
|
||||
},
|
||||
@@ -394,7 +424,10 @@ async function boot() {
|
||||
ui.sessionChip.textContent = to;
|
||||
sessionsPanel.setCurrent(to);
|
||||
},
|
||||
onData: () => showContext(),
|
||||
onData: () => {
|
||||
showContext();
|
||||
refreshRepo();
|
||||
},
|
||||
},
|
||||
});
|
||||
bindAskClaude($('sidebar'), (text) => {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Repo / CI panel: git state of the current session's folder plus the Gitea CI
|
||||
// status of its HEAD commit (`devdesk attach --repo <session>`).
|
||||
|
||||
const CI = {
|
||||
success: ['passing', 'lt-status-completed'],
|
||||
failure: ['failed', 'lt-status-failed'],
|
||||
error: ['error', 'lt-status-failed'],
|
||||
pending: ['running', 'lt-status-running'],
|
||||
warning: ['warning', 'lt-status-pending'],
|
||||
};
|
||||
|
||||
function link(href, text) {
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
a.textContent = text;
|
||||
return a;
|
||||
}
|
||||
|
||||
export function renderRepo(root, repo) {
|
||||
const field = (name) => root.querySelector(`[data-field="${name}"]`);
|
||||
const set = (name, value) => field(name).replaceChildren(value);
|
||||
const note = root.querySelector('.dd-repo-note');
|
||||
|
||||
if (!repo || !repo.git) {
|
||||
root.dataset.state = 'empty';
|
||||
for (const f of ['repo', 'branch', 'dirty', 'prs']) set(f, '—');
|
||||
const ci = field('ci');
|
||||
ci.className = 'lt-status lt-status-pending';
|
||||
ci.textContent = 'no data';
|
||||
note.textContent = repo
|
||||
? `${repo.dir} is not a git repository.`
|
||||
: 'Open a session to see its repository.';
|
||||
return;
|
||||
}
|
||||
|
||||
root.dataset.state = 'live';
|
||||
set('repo', repo.url ? link(repo.url, repo.repo) : (repo.remote || 'no remote'));
|
||||
const sync = repo.upstream
|
||||
? [repo.ahead ? `↑${repo.ahead}` : '', repo.behind ? `↓${repo.behind}` : ''].filter(Boolean).join(' ') || 'in sync'
|
||||
: 'no upstream';
|
||||
set('branch', `${repo.branch} @ ${repo.sha || '—'} · ${sync}`);
|
||||
set('dirty', repo.dirty ? `${repo.dirty} changed` : 'clean');
|
||||
|
||||
const ci = field('ci');
|
||||
const [label, cls] = repo.ci?.total ? (CI[repo.ci.state] || [repo.ci.state, 'lt-status-pending'])
|
||||
: [repo.ahead ? 'not pushed' : 'no CI', 'lt-status-pending'];
|
||||
ci.className = `lt-status ${cls}`;
|
||||
ci.replaceChildren(repo.ci?.url ? link(repo.ci.url, label) : label);
|
||||
|
||||
set('prs', repo.url && repo.prs != null
|
||||
? link(`${repo.url}/pulls`, `${repo.prs} open`)
|
||||
: '—');
|
||||
note.textContent = repo.repo ? '' : 'Not a Gitea repository; CI status unavailable.';
|
||||
}
|
||||
+5
-1
@@ -288,7 +288,11 @@ body.dd-sidebar-open .dd-sidebar {
|
||||
.dd-context-grid { margin-top: var(--space-sm); }
|
||||
|
||||
[data-state="stub"] .lt-section-body,
|
||||
#widget-context[data-state="empty"] .lt-kv-grid { opacity: 0.6; }
|
||||
#widget-context[data-state="empty"] .lt-kv-grid,
|
||||
#widget-repo[data-state="empty"] .lt-kv-grid { opacity: 0.6; }
|
||||
|
||||
.lt-status a { color: inherit; text-shadow: none; }
|
||||
.dd-widget .lt-kv-val { overflow-wrap: anywhere; }
|
||||
|
||||
/* ---- overlay ----------------------------------------------------------------- */
|
||||
.dd-overlay {
|
||||
|
||||
Reference in New Issue
Block a user