mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
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>
605 lines
24 KiB
HTML
605 lines
24 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Codeman tabs</title>
|
|
<style>
|
|
/* The tab strip: a 38px web view across the top of the window, under the
|
|
macOS traffic lights. One tab per Codeman server; the native side owns
|
|
the tabs and sends the whole list on every change. */
|
|
:root { color-scheme: dark; }
|
|
* { box-sizing: border-box; }
|
|
html, body { margin: 0; height: 100%; overflow: hidden; background: #141519; }
|
|
body {
|
|
font: 500 13px/1 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
|
color: #a3a8b1; cursor: default;
|
|
-webkit-user-select: none; user-select: none;
|
|
}
|
|
#strip {
|
|
position: relative; height: 38px; display: flex; align-items: flex-end; gap: 4px;
|
|
padding: 0 8px 0 78px; overflow: hidden; background: #141519;
|
|
}
|
|
#strip.fullscreen { padding-left: 10px; }
|
|
/* The bottom line is drawn under the tabs, so the active tab covers it and
|
|
joins the page below. */
|
|
#strip::after {
|
|
content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px;
|
|
background: #23252b; pointer-events: none; z-index: 1;
|
|
}
|
|
.icon-btn {
|
|
align-self: center; flex: none; width: 28px; height: 28px; padding: 0; border: 0;
|
|
border-radius: 7px; background: transparent; color: #8b9099;
|
|
display: flex; align-items: center; justify-content: center; cursor: default; outline: none;
|
|
}
|
|
.icon-btn:hover { background: #1b1d22; color: #c9ccd2; }
|
|
#overview { margin-right: 6px; }
|
|
#overview.on { background: #2563eb; color: #ffffff; }
|
|
#new { margin-left: 2px; }
|
|
/* Empty strip moves the window, so some of it stays empty however many
|
|
tabs there are. */
|
|
#fill { flex: 1 0 48px; align-self: stretch; }
|
|
|
|
/* The tabs share the room evenly: 200px each, down to 72px; past that the
|
|
row is clipped, never wrapped or scrolled. */
|
|
#tabs {
|
|
display: flex; align-items: flex-end; gap: 2px; flex: 0 1 auto; min-width: 0;
|
|
height: 100%; overflow: hidden;
|
|
}
|
|
.tab {
|
|
--c: #94a3b8;
|
|
/* width, not only flex-basis: the row's own width is worked out from
|
|
its tabs' widths, and flex-basis does not count there. */
|
|
position: relative; flex: 0 1 auto; width: 200px; min-width: 72px; height: 31px;
|
|
display: flex; align-items: center; gap: 8px; padding: 0 7px 0 10px;
|
|
border-radius: 9px 9px 0 0; color: #a3a8b1; overflow: hidden;
|
|
}
|
|
.tab:hover { background: #1b1d22; }
|
|
/* A thin line between two tabs neither in front nor under the pointer, so a
|
|
badge at a tab's right end reads as that tab's and not the next one's. */
|
|
.tab + .tab::before {
|
|
content: ''; position: absolute; left: 0; top: 9px; bottom: 7px; width: 1px;
|
|
background: #2c2f37; pointer-events: none;
|
|
}
|
|
.tab.active::before, .tab.active + .tab::before,
|
|
.tab:hover::before, .tab:hover + .tab::before { display: none; }
|
|
.tab.active {
|
|
z-index: 2; background: #0b0c0f; color: #e8eaed;
|
|
box-shadow: inset 0 2px 0 var(--c), inset 1px 0 0 #23252b, inset -1px 0 0 #23252b;
|
|
}
|
|
/* The tab button itself is a transparent layer over the whole tab, so a
|
|
click anywhere on it is one target and the name, icon and badge stay
|
|
plain text for the screen reader's label. */
|
|
.hit {
|
|
position: absolute; inset: 0; z-index: 0; margin: 0; padding: 0; border: 0;
|
|
background: transparent; border-radius: inherit; cursor: default; outline: none;
|
|
}
|
|
.ico { flex: none; display: flex; color: var(--c); }
|
|
.tab.connect .ico { color: #60a5fa; }
|
|
.name { flex: 1 1 auto; min-width: 0; line-height: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.tab.offline .name { color: #6b7079; }
|
|
.tab.renaming .name { display: none; }
|
|
.rename {
|
|
position: relative; z-index: 1; flex: 1 1 auto; min-width: 0; height: 21px; margin: 0;
|
|
padding: 0 5px; border: 1px solid #3b82f6; border-radius: 5px; background: #0b0c0f;
|
|
color: #e8eaed; font: inherit; outline: none; -webkit-user-select: text; user-select: text;
|
|
}
|
|
.badge { flex: none; display: none; }
|
|
.badge.needs {
|
|
display: block; min-width: 18px; height: 18px; padding: 0 6px; border-radius: 9px;
|
|
background: #f87171; color: #2a0707; font: 700 11px/18px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
|
text-align: center; font-variant-numeric: tabular-nums;
|
|
}
|
|
.badge.waiting { display: block; width: 9px; height: 9px; border-radius: 50%; background: #fbbf24; }
|
|
.badge.working {
|
|
display: block; width: 11px; height: 11px; border-radius: 50%;
|
|
border: 2px solid #60a5fa; border-right-color: transparent; animation: spin 1s linear infinite;
|
|
}
|
|
.badge.offline { display: block; width: 10px; height: 10px; border-radius: 50%; border: 1.5px dashed #6b7079; }
|
|
.spin {
|
|
display: block; width: 12px; height: 12px; margin: 1.5px; border-radius: 50%;
|
|
border: 2px solid #30333b; border-top-color: var(--c); animation: spin 0.9s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.close {
|
|
position: relative; z-index: 1; flex: none; width: 16px; height: 16px; padding: 0; border: 0;
|
|
border-radius: 4px; background: transparent; color: #8b9099; cursor: default; outline: none;
|
|
display: none; align-items: center; justify-content: center;
|
|
}
|
|
.tab:hover .close, .tab.active .close { display: flex; }
|
|
.close:hover { background: #2a2d34; color: #e8eaed; }
|
|
/* Only while a tab is dragged do the others slide aside. */
|
|
#tabs.dragging .tab { transition: transform 150ms ease; }
|
|
#tabs.dragging .tab.lifted { transition: none; z-index: 3; background: #1b1d22; }
|
|
#tabs.dragging .tab.lifted.active { background: #0b0c0f; }
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.badge.working, .spin { animation: none; }
|
|
#tabs.dragging .tab { transition: none; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="strip">
|
|
<button id="overview" class="icon-btn" type="button" tabindex="-1" aria-label="All servers" aria-pressed="false" title="All servers (⌘0)">
|
|
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="1.5" y="1.5" width="5" height="5" rx="1.2"></rect><rect x="9.5" y="1.5" width="5" height="5" rx="1.2"></rect><rect x="1.5" y="9.5" width="5" height="5" rx="1.2"></rect><rect x="9.5" y="9.5" width="5" height="5" rx="1.2"></rect></svg>
|
|
</button>
|
|
<div id="tabs" role="tablist" aria-label="Servers"></div>
|
|
<button id="new" class="icon-btn" type="button" tabindex="-1" aria-label="New server tab" title="New server tab (⌘T)">
|
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M8 2.5v11M2.5 8h11"></path></svg>
|
|
</button>
|
|
<div id="fill"></div>
|
|
</div>
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
|
|
var FALLBACK_COLOR = '#94a3b8';
|
|
var COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
|
var MAX_NAME = 40;
|
|
var DRAG_START_PX = 4;
|
|
var SVG_NS = 'http://www.w3.org/2000/svg';
|
|
|
|
var strip = document.getElementById('strip');
|
|
var tabsEl = document.getElementById('tabs');
|
|
var overviewBtn = document.getElementById('overview');
|
|
var newBtn = document.getElementById('new');
|
|
|
|
function post(msg) { try { window.ipc.postMessage(JSON.stringify(msg)); } catch (e) { /* not in the app */ } }
|
|
|
|
// Everything below arrives from the native side, and most of it was typed
|
|
// on a server (names, URLs): it is only ever set as text or as a checked
|
|
// attribute value, never parsed as markup or CSS.
|
|
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 normTab(t) {
|
|
if (!t || typeof t !== 'object') return null;
|
|
var id = Number(t.id);
|
|
if (!isFinite(id)) return null;
|
|
var st = t.status && typeof t.status === 'object' ? t.status : {};
|
|
return {
|
|
id: id,
|
|
name: str(t.name),
|
|
color: color(t.color),
|
|
active: t.active === true,
|
|
kind: t.kind === 'connect' || t.kind === 'loading' ? t.kind : 'server',
|
|
local: t.local === true,
|
|
url: str(t.url),
|
|
key: shortcut(t.key),
|
|
needs: count(st.needsYou),
|
|
waiting: count(st.waiting),
|
|
working: count(st.working),
|
|
offline: st.offline === true
|
|
};
|
|
}
|
|
|
|
// One badge, by precedence: needs you, waiting, working, not answering.
|
|
function badgeOf(t) {
|
|
if (t.kind === 'connect') return '';
|
|
if (t.needs) return 'needs';
|
|
if (t.waiting) return 'waiting';
|
|
if (t.working) return 'working';
|
|
if (t.offline) return 'offline';
|
|
return '';
|
|
}
|
|
|
|
function statusWords(t) {
|
|
if (t.kind === 'loading') return ['Connecting'];
|
|
if (t.kind === 'connect') return [];
|
|
var out = [];
|
|
if (t.needs) out.push(t.needs + (t.needs === 1 ? ' needs you' : ' need you'));
|
|
if (t.waiting) out.push(t.waiting + ' waiting');
|
|
if (t.working) out.push(t.working + ' working');
|
|
if (t.offline) out.push('Not answering');
|
|
return out;
|
|
}
|
|
|
|
function displayName(t) { return t.name || 'Untitled'; }
|
|
|
|
// ---- icons (fixed shapes from this file; no outside text goes in) ----
|
|
function svg(size, width, parts) {
|
|
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', String(width));
|
|
s.setAttribute('aria-hidden', 'true');
|
|
parts.forEach(function (p) {
|
|
var el = document.createElementNS(SVG_NS, p[0]);
|
|
Object.keys(p[1]).forEach(function (k) { el.setAttribute(k, p[1][k]); });
|
|
s.appendChild(el);
|
|
});
|
|
return s;
|
|
}
|
|
var ICONS = {
|
|
local: function () {
|
|
return svg(15, 1.5, [['rect', { x: '2.5', y: '3', width: '11', height: '7.5', rx: '1.2' }], ['path', { d: 'M1 13h14' }]]);
|
|
},
|
|
server: function () {
|
|
return svg(15, 1.5, [
|
|
['rect', { x: '2', y: '2', width: '12', height: '5', rx: '1.2' }],
|
|
['rect', { x: '2', y: '9', width: '12', height: '5', rx: '1.2' }],
|
|
['path', { d: 'M4.5 4.5h1M4.5 11.5h1' }]
|
|
]);
|
|
},
|
|
connect: function () { return svg(15, 1.7, [['path', { d: 'M8 2.5v11M2.5 8h11' }]]); },
|
|
loading: function () {
|
|
var s = document.createElement('span');
|
|
s.className = 'spin';
|
|
return s;
|
|
},
|
|
close: function () { return svg(10, 1.6, [['path', { d: 'M3 3l10 10M13 3L3 13' }]]); }
|
|
};
|
|
function iconKind(t) {
|
|
if (t.kind === 'connect' || t.kind === 'loading') return t.kind;
|
|
return t.local ? 'local' : 'server';
|
|
}
|
|
|
|
// ---- state ----
|
|
var state = { tabs: [], overviewActive: false, fullscreen: false };
|
|
var lastJson = '';
|
|
var views = new Map(); // tab id -> the tab's elements and what they show
|
|
var renaming = null; // { id, input, initial }
|
|
var drag = null; // a press on a tab that may turn into a drag
|
|
var pendingRender = false;
|
|
var rendering = false;
|
|
var suppressClick = false;
|
|
|
|
function createView(id) {
|
|
var root = document.createElement('div');
|
|
root.className = 'tab';
|
|
root.setAttribute('role', 'presentation');
|
|
root.dataset.id = String(id);
|
|
var hit = document.createElement('button');
|
|
hit.type = 'button';
|
|
hit.className = 'hit';
|
|
hit.tabIndex = -1;
|
|
hit.setAttribute('role', 'tab');
|
|
var ico = document.createElement('span');
|
|
ico.className = 'ico';
|
|
ico.setAttribute('aria-hidden', 'true');
|
|
var name = document.createElement('span');
|
|
name.className = 'name';
|
|
name.setAttribute('aria-hidden', 'true');
|
|
var badge = document.createElement('span');
|
|
badge.className = 'badge';
|
|
badge.setAttribute('aria-hidden', 'true');
|
|
var close = document.createElement('button');
|
|
close.type = 'button';
|
|
close.className = 'close';
|
|
close.tabIndex = -1;
|
|
close.setAttribute('aria-label', 'Close tab');
|
|
close.title = 'Close tab';
|
|
close.appendChild(ICONS.close());
|
|
root.appendChild(hit);
|
|
root.appendChild(ico);
|
|
root.appendChild(name);
|
|
root.appendChild(badge);
|
|
root.appendChild(close);
|
|
var v = { id: id, root: root, hit: hit, ico: ico, name: name, badge: badge, close: close, icon: '', badgeKind: null, tab: null };
|
|
views.set(id, v);
|
|
return v;
|
|
}
|
|
|
|
function updateView(v, t) {
|
|
v.tab = t;
|
|
var bk = badgeOf(t);
|
|
var cls = 'tab' + (t.active ? ' active' : '') + (bk === 'offline' ? ' offline' : '') +
|
|
(t.kind !== 'server' ? ' ' + t.kind : '') + (renaming && renaming.id === t.id ? ' renaming' : '');
|
|
if (v.root.className !== cls) v.root.className = cls;
|
|
v.root.style.setProperty('--c', t.color);
|
|
var ik = iconKind(t);
|
|
if (v.icon !== ik) {
|
|
v.icon = ik;
|
|
v.ico.replaceChildren(ICONS[ik]());
|
|
}
|
|
var label = displayName(t);
|
|
if (v.name.textContent !== label) v.name.textContent = label;
|
|
var btext = bk === 'needs' ? (t.needs > 99 ? '99+' : String(t.needs)) : '';
|
|
if (v.badgeKind !== bk + btext) {
|
|
v.badgeKind = bk + btext;
|
|
v.badge.className = 'badge' + (bk ? ' ' + bk : '');
|
|
v.badge.textContent = btext;
|
|
}
|
|
var words = statusWords(t);
|
|
v.hit.setAttribute('aria-selected', t.active ? 'true' : 'false');
|
|
v.hit.setAttribute('aria-label', [label].concat(words).join(', '));
|
|
// The strip is too short for a hover card under it, so the native
|
|
// tooltip carries the same lines.
|
|
var lines = [label + (t.key ? ' ⌘' + t.key : '')];
|
|
if (t.url) lines.push(t.url);
|
|
v.hit.title = lines.concat(words).join('\n');
|
|
}
|
|
|
|
function render() {
|
|
if (drag && drag.active) { pendingRender = true; return; }
|
|
pendingRender = false;
|
|
rendering = true;
|
|
strip.classList.toggle('fullscreen', state.fullscreen);
|
|
overviewBtn.classList.toggle('on', state.overviewActive);
|
|
overviewBtn.setAttribute('aria-pressed', state.overviewActive ? 'true' : 'false');
|
|
var seen = new Set();
|
|
state.tabs.forEach(function (t, i) {
|
|
var v = views.get(t.id) || createView(t.id);
|
|
updateView(v, t);
|
|
seen.add(t.id);
|
|
var at = tabsEl.children[i];
|
|
if (at !== v.root) tabsEl.insertBefore(v.root, at || null);
|
|
});
|
|
views.forEach(function (v, id) {
|
|
if (seen.has(id)) return;
|
|
if (renaming && renaming.id === id) endRename('gone');
|
|
v.root.remove();
|
|
views.delete(id);
|
|
});
|
|
rendering = false;
|
|
// Moving a tab in the row takes the focus from a rename under way: give it back.
|
|
if (renaming && document.activeElement !== renaming.input) renaming.input.focus();
|
|
}
|
|
|
|
function applyState(msg) {
|
|
var seen = new Set();
|
|
var tabs = [];
|
|
(Array.isArray(msg.tabs) ? msg.tabs : []).forEach(function (raw) {
|
|
var t = normTab(raw);
|
|
if (!t || seen.has(t.id)) return;
|
|
seen.add(t.id);
|
|
tabs.push(t);
|
|
});
|
|
var next = { tabs: tabs, overviewActive: msg.overviewActive === true, fullscreen: msg.fullscreen === true };
|
|
var json = JSON.stringify(next);
|
|
if (json === lastJson && !pendingRender) return;
|
|
lastJson = json;
|
|
state = next;
|
|
render();
|
|
}
|
|
|
|
// ---- rename ----
|
|
function startRename(id) {
|
|
var v = views.get(id);
|
|
if (!v || (drag && drag.active)) return;
|
|
if (renaming) {
|
|
if (renaming.id === id) { renaming.input.focus(); return; }
|
|
endRename('commit');
|
|
}
|
|
var input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.className = 'rename';
|
|
input.maxLength = MAX_NAME;
|
|
input.spellcheck = false;
|
|
input.autocomplete = 'off';
|
|
input.setAttribute('aria-label', 'Tab name');
|
|
input.value = v.tab ? v.tab.name.slice(0, MAX_NAME) : '';
|
|
renaming = { id: id, input: input, before: v.tab ? v.tab.name : '', initial: input.value.trim() };
|
|
v.root.classList.add('renaming');
|
|
v.root.insertBefore(input, v.badge);
|
|
input.addEventListener('keydown', function (ev) {
|
|
ev.stopPropagation();
|
|
if (ev.key === 'Enter') { ev.preventDefault(); endRename('commit'); }
|
|
else if (ev.key === 'Escape') { ev.preventDefault(); endRename('cancel'); }
|
|
});
|
|
input.addEventListener('blur', function () {
|
|
if (rendering) return;
|
|
if (renaming && renaming.input === input) endRename('commit');
|
|
});
|
|
// The keyboard reaches this field only while this web view is the first
|
|
// responder, and the native side hands it back to the page below after
|
|
// every other click on the strip: ask for it.
|
|
post({ cmd: 'editing' });
|
|
input.focus();
|
|
input.select();
|
|
}
|
|
|
|
// how: 'commit' (Enter, a click elsewhere), 'cancel' (Escape) or 'gone'
|
|
// (the tab was closed). Every rename that ends on a tab still there posts
|
|
// one rename, a cancelled or unchanged one with the name the tab had: the
|
|
// native side takes that as "rename finished" and hands the keyboard back
|
|
// to the page below.
|
|
function endRename(how) {
|
|
var r = renaming;
|
|
if (!r) return;
|
|
renaming = null;
|
|
var name = r.input.value.trim().slice(0, MAX_NAME);
|
|
r.input.remove();
|
|
var v = views.get(r.id);
|
|
if (v) v.root.classList.remove('renaming');
|
|
if (how === 'gone') return;
|
|
if (how === 'cancel' || name === r.initial) {
|
|
post({ cmd: 'rename', id: r.id, name: r.before });
|
|
return;
|
|
}
|
|
// Shown at once; an empty name is the default the native side picks.
|
|
if (v && name) v.name.textContent = name;
|
|
post({ cmd: 'rename', id: r.id, name: name });
|
|
}
|
|
|
|
// ---- drag to reorder ----
|
|
function beginDrag() {
|
|
var roots = Array.prototype.slice.call(tabsEl.children);
|
|
drag.roots = roots;
|
|
drag.from = roots.indexOf(drag.root);
|
|
if (drag.from < 0) { drag = null; return; }
|
|
drag.rects = roots.map(function (r) { return r.getBoundingClientRect(); });
|
|
var r0 = drag.rects[drag.from];
|
|
var gap = roots.length > 1 ? drag.rects[1].left - drag.rects[0].right : 0;
|
|
drag.step = r0.width + gap;
|
|
drag.to = drag.from;
|
|
drag.active = true;
|
|
if (renaming) endRename('commit');
|
|
try { drag.root.setPointerCapture(drag.pointerId); } catch (e) { /* the pointer is gone */ }
|
|
tabsEl.classList.add('dragging');
|
|
drag.root.classList.add('lifted');
|
|
}
|
|
|
|
function moveDrag(dx) {
|
|
var rects = drag.rects;
|
|
var r0 = rects[drag.from];
|
|
var minDx = rects[0].left - r0.left;
|
|
var maxDx = rects[rects.length - 1].right - r0.right;
|
|
dx = Math.max(minDx, Math.min(maxDx, dx));
|
|
drag.root.style.transform = 'translateX(' + dx + 'px)';
|
|
var centre = r0.left + r0.width / 2 + dx;
|
|
var to = 0;
|
|
for (var i = 0; i < rects.length; i++) {
|
|
if (i !== drag.from && centre > rects[i].left + rects[i].width / 2) to++;
|
|
}
|
|
drag.to = to;
|
|
drag.roots.forEach(function (r, i) {
|
|
if (i === drag.from) return;
|
|
var shift = 0;
|
|
if (drag.from < to && i > drag.from && i <= to) shift = -drag.step;
|
|
else if (to < drag.from && i >= to && i < drag.from) shift = drag.step;
|
|
r.style.transform = shift ? 'translateX(' + shift + 'px)' : '';
|
|
});
|
|
}
|
|
|
|
function endDrag(commit) {
|
|
var d = drag;
|
|
drag = null;
|
|
if (!d || !d.active) return;
|
|
try { d.root.releasePointerCapture(d.pointerId); } catch (e) { /* already released */ }
|
|
// The slide stops before the tabs move, so nothing animates back.
|
|
tabsEl.classList.remove('dragging');
|
|
d.root.classList.remove('lifted');
|
|
d.roots.forEach(function (r) { r.style.transform = ''; });
|
|
// The click that ends a drag is not a select.
|
|
suppressClick = true;
|
|
setTimeout(function () { suppressClick = false; }, 0);
|
|
if (commit && d.to !== d.from) {
|
|
var at = state.tabs.findIndex(function (t) { return t.id === d.id; });
|
|
if (at >= 0) {
|
|
var moved = state.tabs.splice(at, 1)[0];
|
|
state.tabs.splice(d.to, 0, moved);
|
|
lastJson = JSON.stringify(state);
|
|
}
|
|
post({ cmd: 'move', id: d.id, to: d.to });
|
|
pendingRender = true;
|
|
}
|
|
if (pendingRender) render();
|
|
}
|
|
|
|
function tabOf(target) {
|
|
var root = target && target.closest ? target.closest('.tab') : null;
|
|
return root && views.has(Number(root.dataset.id)) ? Number(root.dataset.id) : null;
|
|
}
|
|
function inside(target, sel) { return !!(target && target.closest && target.closest(sel)); }
|
|
|
|
// ---- events ----
|
|
var middleDownOn = null;
|
|
|
|
document.addEventListener('mousedown', function (ev) {
|
|
var t = ev.target;
|
|
if (renaming && t !== renaming.input) endRename('commit');
|
|
if (inside(t, 'input')) return;
|
|
// The strip never takes the focus or starts a selection.
|
|
ev.preventDefault();
|
|
if (ev.button === 1) { middleDownOn = tabOf(t); return; }
|
|
if (ev.button !== 0 || ev.ctrlKey) return;
|
|
if (inside(t, '.tab, button')) return;
|
|
// The second press of a double click zooms, as a title bar does. Decided
|
|
// here, not on dblclick: the first press's window drag may keep WebKit
|
|
// from ever seeing a dblclick.
|
|
if (ev.detail === 2) post({ cmd: 'zoom' });
|
|
if (ev.detail > 1) return;
|
|
post({ cmd: 'drag' });
|
|
});
|
|
|
|
// Middle click closes, as in a browser (auxclick is not in every WebKit).
|
|
document.addEventListener('mouseup', function (ev) {
|
|
if (ev.button !== 1) return;
|
|
var id = tabOf(ev.target);
|
|
if (id !== null && id === middleDownOn) post({ cmd: 'close', id: id });
|
|
middleDownOn = null;
|
|
});
|
|
|
|
document.addEventListener('dblclick', function (ev) {
|
|
var t = ev.target;
|
|
var id = tabOf(t);
|
|
if (id !== null) {
|
|
if (!inside(t, '.close, input')) startRename(id);
|
|
return;
|
|
}
|
|
});
|
|
|
|
// WebKit's own menu (Reload, Inspect Element) never shows on the strip,
|
|
// the rename field included; only a tab has a menu, the native one, and
|
|
// not while its name is being typed.
|
|
document.addEventListener('contextmenu', function (ev) {
|
|
ev.preventDefault();
|
|
var id = inside(ev.target, 'input') ? null : tabOf(ev.target);
|
|
if (id !== null) post({ cmd: 'menu', id: id });
|
|
});
|
|
|
|
tabsEl.addEventListener('click', function (ev) {
|
|
if (suppressClick) { suppressClick = false; return; }
|
|
if (ev.ctrlKey) return; // a Control-click is the right click: the menu answers it
|
|
var id = tabOf(ev.target);
|
|
if (id === null || inside(ev.target, 'input')) return;
|
|
if (inside(ev.target, '.close')) post({ cmd: 'close', id: id });
|
|
else post({ cmd: 'select', id: id });
|
|
});
|
|
|
|
tabsEl.addEventListener('pointerdown', function (ev) {
|
|
if (ev.button !== 0 || ev.ctrlKey || !ev.isPrimary) return;
|
|
var id = tabOf(ev.target);
|
|
if (id === null || inside(ev.target, '.close, input')) return;
|
|
drag = { id: id, root: views.get(id).root, pointerId: ev.pointerId, startX: ev.clientX, active: false };
|
|
});
|
|
|
|
document.addEventListener('pointermove', function (ev) {
|
|
if (!drag || ev.pointerId !== drag.pointerId) return;
|
|
var dx = ev.clientX - drag.startX;
|
|
if (!drag.active) {
|
|
if (Math.abs(dx) < DRAG_START_PX) return;
|
|
beginDrag();
|
|
if (!drag) return;
|
|
}
|
|
moveDrag(dx);
|
|
});
|
|
|
|
document.addEventListener('pointerup', function (ev) {
|
|
if (drag && ev.pointerId === drag.pointerId) endDrag(true);
|
|
});
|
|
document.addEventListener('pointercancel', function (ev) {
|
|
if (drag && ev.pointerId === drag.pointerId) endDrag(false);
|
|
});
|
|
window.addEventListener('blur', function () { if (drag) endDrag(false); });
|
|
|
|
overviewBtn.addEventListener('click', function () { post({ cmd: 'overview' }); });
|
|
newBtn.addEventListener('click', function () { post({ cmd: 'new' }); });
|
|
|
|
function onNative(msg) {
|
|
if (typeof msg === 'string') {
|
|
try { msg = JSON.parse(msg); } catch (e) { return; }
|
|
}
|
|
if (!msg || typeof msg !== 'object') return;
|
|
if (msg.type === 'state') applyState(msg);
|
|
else if (msg.type === 'rename') {
|
|
var id = Number(msg.id);
|
|
if (isFinite(id)) startRename(id);
|
|
}
|
|
}
|
|
|
|
Object.defineProperty(window, '__codemanTabs', {
|
|
value: Object.freeze({ onNative: onNative }),
|
|
writable: false,
|
|
configurable: false,
|
|
enumerable: false
|
|
});
|
|
|
|
post({ cmd: 'ready' });
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|