Files
Codeman/packages/desktop/assets/overview.html
T
Codeman maintainer 4cedeec529 feat(desktop): server tabs, several Codeman servers open in one window
The Mac app showed one server at a time, and moving to another machine
meant Switch Server and a fresh connect. Now every server gets a tab in
the title bar, and every tab stays live.

- Tab strip: a small bundled page (assets/tabs.html) in its own web view
  across the top of the window, with the traffic lights inset into it.
  Each tab has a colour and a status badge: a red count when sessions
  need you, an amber dot when they are waiting, a blue ring while they
  work, a dashed circle while the server does not answer.
- The status comes from the app, not from the page: one thread per tab
  reads GET /api/sessions every 2 seconds (macOS stalls hidden web
  views), backs off for 60 s on a 429 and stops on 401/403, so a wrong
  password is never retried in a loop.
- + or ⌘T opens the connect screen in a new tab. A server that is
  already open just brings its tab forward instead of opening twice,
  also when two connects race (checked again when the probe ends).
- Double-click or the right-click menu renames a tab; the same menu
  sets the colour, reloads, reconnects, closes, or closes the others.
  Tabs can be dragged. A new Server menu carries ⌘T, ⌘W, ⇧⌘T (reopen
  closed tab, last 10), ⌘0, ⇧⌘[ / ⇧⌘] and ⌘1 to ⌘8 (⌘9 is the last
  tab once there are nine). Close Window moves to ⇧⌘W. Ctrl+Tab and
  Alt+1..9 stay with Codeman's own session switching.
- ⌘0 shows every server at once (assets/overview.html): the ones that
  need you first, up to four sessions each with what they ask, and a
  click on a session opens its tab and selects it.
- Tabs are saved in desktop.json (tabs, activeTab) and come back at
  launch, connecting by themselves when auto-connect is on.
- Logins: each tab keeps its own Basic credentials, and a challenge is
  answered only with the login of the tab whose web view asked
  (macos.rs binds each WKWebView to its tab).
- Cookies: on macOS 14 and later every server gets its own WebKit data
  store (WKWebsiteDataStore dataStoreForIdentifier, with a stable id
  hashed from the server URL), so a server on port 3000 and a beta on
  5000 of the same host cannot log each other out. Before macOS 14 a
  second tab on a host that is already open is refused with a message.
  The server used last before this change keeps the default store, so
  its per-device settings (font size, skin) survive; every other
  server starts those fresh once.
- The lid logo blink watches all open servers, and Keep This Mac Awake
  follows the tab that runs on this Mac.

New pure model in src/tabs.rs (names, colours, order, shortcuts, strip
and overview state) with unit tests; agents.rs reads the per-session
detail for the badges and the overview; policy.rs parses the strip and
overview IPC and refuses it from any other frame. Browser tests drive
both bundled pages (test/desktop-tabs-pages.browser.test.ts) and the
connect page's restored-tab path.

Not yet run in a real window: the Mac mini has no screen session. The
owner checks the traffic-light placement, clicks under the transparent
title bar, window drag and zoom from the strip, keyboard focus after
strip clicks, and two logged-in servers side by side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 18:36:08 +01:00

374 lines
15 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>All servers</title>
<style>
/* All servers (Cmd+0): one card per server tab, what needs you first. The
native side sends the whole list, already ordered, on every change. */
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html { min-height: 100%; background: #0b0c0f; }
body {
margin: 0; min-height: 100%; overflow-x: hidden; background: #0b0c0f; color: #e8eaed;
font: 13px/1.4 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
-webkit-user-select: none; user-select: none; cursor: default;
}
main { padding: 22px 28px 28px; }
header { display: flex; align-items: baseline; flex-wrap: wrap; column-gap: 14px; row-gap: 4px; margin-bottom: 18px; }
h1 { margin: 0; font-size: 20px; font-weight: 650; letter-spacing: -0.01em; }
#summary { font-size: 13px; color: #8b9099; }
#summary .needs { color: #fca5a5; }
#summary .waiting { color: #fcd34d; }
#grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); gap: 18px; align-items: stretch; }
button { font: inherit; color: inherit; margin: 0; cursor: pointer; -webkit-user-select: none; user-select: none; }
button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
.card {
--c: #94a3b8;
display: flex; flex-direction: column; min-width: 0; min-height: 236px; padding-top: 3px;
border: 1px solid #23252b; border-radius: 12px; background: #121316;
box-shadow: inset 0 3px 0 var(--c);
}
.card.urgent { border-color: rgba(248,113,113,0.45); }
.card.offline { border-style: dashed; border-color: #30333b; background: #0f1013; box-shadow: inset 0 3px 0 #30333b; }
.card.slim { min-height: 0; align-self: start; }
.head {
display: flex; align-items: flex-start; gap: 10px; width: 100%; padding: 11px 16px 10px;
border: 0; border-radius: 9px 9px 0 0; background: transparent; text-align: left;
}
.head:hover .name { text-decoration: underline; text-decoration-color: #3a3d45; text-underline-offset: 3px; }
.head:focus-visible { outline-offset: -2px; }
.title { flex: 1; min-width: 0; }
.name { display: block; font-size: 15px; font-weight: 650; line-height: 1.35; color: #e8eaed; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card.offline .name { color: #8b9099; }
.url { display: block; margin-top: 2px; font-size: 12px; color: #8b9099; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.key { flex: none; padding-top: 2px; font-size: 12px; color: #6b7079; }
.body { flex: 1; min-height: 0; padding: 0 10px; }
.row {
display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 8px; margin: 0 0 3px;
border: 0; border-radius: 8px; background: transparent; font-size: 12.5px; line-height: 18px; text-align: left;
}
.row:hover { background: #1a1c21; }
.row.needs { background: rgba(248,113,113,0.10); }
.row.needs:hover { background: rgba(248,113,113,0.16); }
.row.waiting { background: rgba(251,191,36,0.07); }
.row.waiting:hover { background: rgba(251,191,36,0.12); }
.mark { flex: none; display: flex; align-items: center; justify-content: center; width: 18px; height: 18px; }
.mark::before { content: ""; display: block; }
.row.needs .mark { border-radius: 9px; background: #f87171; color: #2a0707; font-weight: 700; font-size: 11px; line-height: 18px; }
.row.needs .mark::before { display: none; }
.row.waiting .mark::before { width: 9px; height: 9px; border-radius: 50%; background: #fbbf24; }
.row.working .mark::before {
width: 11px; height: 11px; border-radius: 50%; border: 2px solid #60a5fa; border-right-color: transparent;
animation: spin 1s linear infinite;
}
.row.idle .mark::before { width: 8px; height: 8px; border-radius: 50%; background: #3a3d45; }
.sname { flex: 0 1 auto; min-width: 0; max-width: 55%; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stext { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #8b9099; }
.row.needs .stext { color: #fca5a5; }
.row.waiting .stext { color: #fcd34d; }
.row.idle .stext { color: #6b7079; }
.more { padding: 3px 8px 0; font-size: 12px; color: #6b7079; }
.note { display: flex; align-items: center; gap: 9px; padding: 4px 6px; font-size: 12.5px; color: #8b9099; }
.note.warn { color: #fcd34d; }
.foot { display: flex; align-items: center; gap: 10px; margin-top: 10px; padding: 10px 16px 14px; border-top: 1px solid #1d1f24; }
.meta { flex: 1; min-width: 0; font-size: 12px; color: #6b7079; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.action {
flex: none; height: 28px; padding: 0 12px; border: 1px solid #30333b; border-radius: 7px;
background: #16181d; color: #e8eaed; font-weight: 600; font-size: 12px;
}
.action:hover { background: #1d2026; border-color: #3a3d45; }
.action.urgent { border-color: #ef4444; background: #ef4444; color: #ffffff; }
.action.urgent:hover { border-color: #dc2626; background: #dc2626; }
.add {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px;
min-height: 236px; padding: 0; border: 1.5px dashed #30333b; border-radius: 12px;
background: transparent; color: #8b9099;
}
.add:hover { border-color: #3a3d45; color: #c9ccd2; }
.add-ico { width: 40px; height: 40px; border-radius: 12px; border: 1.5px dashed #3a3d45; display: flex; align-items: center; justify-content: center; }
.add-label { font-size: 13px; font-weight: 600; }
.add-key { font-size: 12px; color: #6b7079; }
.spin {
flex: none; width: 14px; height: 14px; border-radius: 50%;
border: 2px solid #30333b; border-top-color: #60a5fa; animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.row.working .mark::before, .spin { animation: none; }
}
</style>
</head>
<body>
<main>
<header>
<h1>All servers</h1>
<span id="summary"></span>
</header>
<div id="grid"></div>
</main>
<script>
(function () {
'use strict';
var FALLBACK_COLOR = '#94a3b8';
var COLOR_RE = /^#[0-9a-fA-F]{6}$/;
var MAX_ROWS = 4;
var STATES = ['needs', 'waiting', 'working', 'idle'];
var SVG_NS = 'http://www.w3.org/2000/svg';
var grid = document.getElementById('grid');
var summary = document.getElementById('summary');
function post(msg) { try { window.ipc.postMessage(JSON.stringify(msg)); } catch (e) { /* not in the app */ } }
// Names, URLs, versions and session texts come from servers: they are only
// ever set as text, and a colour only after it passed the #rrggbb check.
function str(v) { return typeof v === 'string' ? v : ''; }
function color(v) { return typeof v === 'string' && COLOR_RE.test(v) ? v : FALLBACK_COLOR; }
function count(v) {
var n = Math.floor(Number(v));
return isFinite(n) && n > 0 ? n : 0;
}
function shortcut(v) {
var n = Number(v);
return n >= 1 && n <= 9 && Math.floor(n) === n ? n : null;
}
function plural(n, one, many) { return n + ' ' + (n === 1 ? one : many); }
function normServer(s) {
if (!s || typeof s !== 'object') return null;
var tab = Number(s.tab);
if (!isFinite(tab)) return null;
var c = s.counts && typeof s.counts === 'object' ? s.counts : {};
var sessions = [];
(Array.isArray(s.sessions) ? s.sessions : []).forEach(function (x) {
if (!x || typeof x !== 'object') return;
sessions.push({
id: str(x.id),
name: str(x.name),
state: STATES.indexOf(x.state) >= 0 ? x.state : 'idle',
text: str(x.text)
});
});
return {
tab: tab,
name: str(s.name),
color: color(s.color),
url: str(s.url),
local: s.local === true,
key: shortcut(s.key),
version: str(s.version),
kind: s.kind === 'connect' || s.kind === 'loading' ? s.kind : 'server',
offline: s.offline === true,
refused: s.refused === true,
sessions: sessions,
counts: {
needsYou: count(c.needsYou),
waiting: count(c.waiting),
working: count(c.working),
sessions: count(c.sessions)
}
};
}
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined) e.textContent = text;
return e;
}
function button(cls, focusKey, text) {
var b = el('button', cls, text);
b.type = 'button';
b.dataset.focus = focusKey;
return b;
}
function plusIcon(size) {
var s = document.createElementNS(SVG_NS, 'svg');
s.setAttribute('width', String(size));
s.setAttribute('height', String(size));
s.setAttribute('viewBox', '0 0 16 16');
s.setAttribute('fill', 'none');
s.setAttribute('stroke', 'currentColor');
s.setAttribute('stroke-width', '1.7');
s.setAttribute('aria-hidden', 'true');
var p = document.createElementNS(SVG_NS, 'path');
p.setAttribute('d', 'M8 2.5v11M2.5 8h11');
s.appendChild(p);
return s;
}
function renderSummary(servers) {
var needs = 0, waiting = 0, working = 0, offline = 0;
servers.forEach(function (s) {
if (s.offline) { offline++; return; }
needs += s.counts.needsYou;
waiting += s.counts.waiting;
working += s.counts.working;
});
var parts = [];
if (needs) parts.push(el('span', 'needs', needs + (needs === 1 ? ' needs you' : ' need you')));
if (waiting) parts.push(el('span', 'waiting', waiting + ' waiting'));
if (working) parts.push(el('span', 'working', working + ' working'));
if (offline) parts.push(el('span', 'offline', offline + ' offline'));
if (!parts.length) parts.push(el('span', 'calm', 'Nothing needs you'));
var out = [];
parts.forEach(function (p, i) {
if (i) out.push(document.createTextNode(' · '));
out.push(p);
});
summary.replaceChildren.apply(summary, out);
}
function sessionRow(s, x) {
var row = button('row ' + x.state, 'row:' + s.tab + ':' + x.id);
var mark = el('span', 'mark', x.state === 'needs' ? '!' : undefined);
mark.setAttribute('aria-hidden', 'true');
row.appendChild(mark);
row.appendChild(el('span', 'sname', x.name || 'Untitled'));
row.appendChild(el('span', 'stext', x.text));
var said = { needs: 'needs you', waiting: 'waiting', working: 'working', idle: 'idle' }[x.state];
row.setAttribute('aria-label', (x.name || 'Untitled') + ', ' + said + (x.text ? ': ' + x.text : ''));
if (x.id) row.addEventListener('click', function () { post({ cmd: 'openSession', tab: s.tab, session: x.id }); });
else row.disabled = true;
return row;
}
function card(s) {
var c = el('section', 'card');
c.style.setProperty('--c', s.color);
var name = s.name || (s.kind === 'connect' ? 'New server tab' : 'Untitled');
c.setAttribute('aria-label', name);
var head = button('head', 'head:' + s.tab);
var title = el('span', 'title');
title.appendChild(el('span', 'name', name));
if (s.url) title.appendChild(el('span', 'url', s.url));
head.appendChild(title);
if (s.key) head.appendChild(el('span', 'key', '⌘' + s.key));
head.addEventListener('click', function () { post({ cmd: 'open', tab: s.tab }); });
c.appendChild(head);
var body = el('div', 'body');
var meta = '';
var action = button('action', 'act:' + s.tab, 'Open');
var onAction = function () { post({ cmd: 'open', tab: s.tab }); };
if (s.kind === 'connect') {
c.classList.add('slim');
body.appendChild(el('div', 'note', 'Not connected yet'));
} else if (s.kind === 'loading') {
var note = el('div', 'note');
note.appendChild(el('span', 'spin'));
note.appendChild(document.createTextNode('Connecting…'));
body.appendChild(note);
} else if (s.offline) {
c.classList.add('offline');
body.appendChild(el('div', 'note', 'Not answering, the app keeps trying'));
meta = s.version;
action.textContent = 'Retry';
onAction = function () { post({ cmd: 'retry', tab: s.tab }); };
} else if (s.refused) {
body.appendChild(el('div', 'note warn', 'The login was refused'));
meta = s.version;
} else {
var shown = s.sessions.slice(0, MAX_ROWS);
shown.forEach(function (x) { body.appendChild(sessionRow(s, x)); });
var total = Math.max(s.counts.sessions, s.sessions.length);
if (total > shown.length) body.appendChild(el('div', 'more', '+' + (total - shown.length) + ' more'));
if (!total) body.appendChild(el('div', 'note', 'No sessions'));
meta = plural(total, 'session', 'sessions') + (s.version ? ' · ' + s.version : '');
if (s.counts.needsYou > 0) {
c.classList.add('urgent');
action.textContent = 'Answer';
action.classList.add('urgent');
// Answer goes straight to the first session that needs you, when the
// list names it; else to the server.
var first = s.sessions.filter(function (x) { return x.state === 'needs' && x.id; })[0];
if (first) onAction = function () { post({ cmd: 'openSession', tab: s.tab, session: first.id }); };
}
}
c.appendChild(body);
var foot = el('div', 'foot');
foot.appendChild(el('span', 'meta', meta));
action.setAttribute('aria-label', action.textContent + ' ' + name);
action.addEventListener('click', onAction);
foot.appendChild(action);
c.appendChild(foot);
return c;
}
function addCard() {
var b = button('add', 'new');
b.setAttribute('aria-label', 'Add a server');
var ico = el('span', 'add-ico');
ico.appendChild(plusIcon(16));
b.appendChild(ico);
b.appendChild(el('span', 'add-label', 'Add a server'));
b.appendChild(el('span', 'add-key', '⌘T'));
b.addEventListener('click', function () { post({ cmd: 'new' }); });
return b;
}
var lastJson = null;
function render(servers) {
// A state arrives every few seconds: the button that had the keyboard
// focus gets it back in the new cards.
var active = document.activeElement;
var focusKey = active && active.dataset ? active.dataset.focus : '';
renderSummary(servers);
var cards = servers.map(card);
cards.push(addCard());
grid.replaceChildren.apply(grid, cards);
if (focusKey) {
var all = grid.querySelectorAll('[data-focus]');
for (var i = 0; i < all.length; i++) {
if (all[i].dataset.focus === focusKey) { all[i].focus({ preventScroll: true }); break; }
}
}
}
function onNative(msg) {
if (typeof msg === 'string') {
try { msg = JSON.parse(msg); } catch (e) { return; }
}
if (!msg || typeof msg !== 'object' || msg.type !== 'state') return;
var servers = [];
var seen = new Set();
(Array.isArray(msg.servers) ? msg.servers : []).forEach(function (raw) {
var s = normServer(raw);
if (!s || seen.has(s.tab)) return;
seen.add(s.tab);
servers.push(s);
});
var json = JSON.stringify(servers);
if (json === lastJson) return;
lastJson = json;
render(servers);
}
Object.defineProperty(window, '__codemanOverview', {
value: Object.freeze({ onNative: onNative }),
writable: false,
configurable: false,
enumerable: false
});
render([]);
post({ cmd: 'ready' });
})();
</script>
</body>
</html>