feat: live Repo/CI panel, env.local overrides (0.3.0)
build-deb / Lint, test, package (push) Successful in 1m46s
build-deb / Notify on failure (push) Skipped

- `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:
2026-10-07 13:09:32 -04:00
co-authored by Claude Opus 5.5
parent ae8b5d843f
commit 9026238024
15 changed files with 256 additions and 20 deletions
+3 -3
View File
@@ -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>
+5
View File
@@ -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
View File
@@ -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) => {
+56
View File
@@ -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
View File
@@ -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 {