You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

590 lines
20 KiB
JavaScript

// Main application orchestrator
(async function () {
// Check if auth is required
const authStatus = await fetch('/api/auth/status').then((r) => r.json()).catch(() => ({ authRequired: true }));
if (authStatus.authRequired) {
const token = localStorage.getItem('cmux-remote-token');
if (!token) {
window.location.href = '/login.html';
return;
}
}
// State
let currentWorkspace = null;
let currentSurface = null;
let lastWorkspaces = [];
// URL hash helpers — format: #wsRef/surfaceRef
function getHashSurface() {
const hash = location.hash.slice(1);
if (!hash) return null;
const slash = hash.indexOf('/');
if (slash === -1) return null;
return {
ws: decodeURIComponent(hash.slice(0, slash)),
surface: decodeURIComponent(hash.slice(slash + 1)),
};
}
function setHashSurface(wsRef, surfaceRef) {
history.replaceState(null, '', '#' + encodeURIComponent(wsRef) + '/' + encodeURIComponent(surfaceRef));
}
function findAndSwitch(workspaces, wsRef, surfaceRef) {
for (const workspace of workspaces) {
if (workspace.ref === wsRef) {
for (const pane of workspace.panes) {
for (const surface of pane.surfaces) {
if (surface.ref === surfaceRef) {
switchSurface(wsRef, surfaceRef);
return true;
}
}
}
}
}
return false;
}
// Components
const ws = new WebSocketClient();
const terminal = new TerminalView(document.getElementById('terminal-output'));
const statusDot = document.getElementById('connection-status');
const terminalContainer = document.getElementById('terminal-container');
const browserView = document.getElementById('browser-view');
const terminalOutput = document.getElementById('terminal-output');
// ── Image zoom viewer ──────────────────────────────────────────
class ImageZoomViewer {
constructor(img, container) {
this._img = img;
this._con = container;
this._scale = 1;
this._x = 0; this._y = 0;
this._lastDist = 0;
this._dragging = false;
this._lastX = 0; this._lastY = 0;
container.addEventListener('touchstart', this._ts.bind(this), { passive: false });
container.addEventListener('touchmove', this._tm.bind(this), { passive: false });
container.addEventListener('touchend', () => { this._dragging = false; }, { passive: true });
container.addEventListener('wheel', this._wheel.bind(this), { passive: false });
container.addEventListener('mousedown', this._md.bind(this));
container.addEventListener('mousemove', this._mm.bind(this));
container.addEventListener('mouseup', this._mu.bind(this));
container.addEventListener('mouseleave', this._mu.bind(this));
container.addEventListener('dblclick', () => this.reset());
}
reset() { this._scale = 1; this._x = 0; this._y = 0; this._apply(); }
_apply() {
this._img.style.transform = `translate(${this._x}px, ${this._y}px) scale(${this._scale})`;
}
_clampScale(s) { return Math.min(10, Math.max(0.5, s)); }
_ts(e) {
if (e.touches.length === 2) {
e.preventDefault();
this._lastDist = this._dist(e.touches[0], e.touches[1]);
} else if (e.touches.length === 1) {
this._dragging = true;
this._lastX = e.touches[0].clientX;
this._lastY = e.touches[0].clientY;
}
}
_tm(e) {
e.preventDefault();
if (e.touches.length === 2) {
const d = this._dist(e.touches[0], e.touches[1]);
this._scale = this._clampScale(this._scale * (d / this._lastDist));
this._lastDist = d;
this._apply();
} else if (e.touches.length === 1 && this._dragging) {
this._x += e.touches[0].clientX - this._lastX;
this._y += e.touches[0].clientY - this._lastY;
this._lastX = e.touches[0].clientX;
this._lastY = e.touches[0].clientY;
this._apply();
}
}
_wheel(e) {
e.preventDefault();
this._scale = this._clampScale(this._scale * (e.deltaY < 0 ? 1.12 : 0.9));
this._apply();
}
_md(e) { this._dragging = true; this._lastX = e.clientX; this._lastY = e.clientY; this._con.classList.add('dragging'); }
_mm(e) {
if (!this._dragging) return;
this._x += e.clientX - this._lastX;
this._y += e.clientY - this._lastY;
this._lastX = e.clientX; this._lastY = e.clientY;
this._apply();
}
_mu() { this._dragging = false; this._con.classList.remove('dragging'); }
_dist(t1, t2) {
return Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
}
}
const browserLoading = document.getElementById('browser-loading');
const zoomBtn = document.getElementById('browser-zoom-btn');
const zoomOverlay = document.getElementById('img-zoom-overlay');
const zoomContainer = document.getElementById('img-zoom-container');
const zoomImg = document.getElementById('img-zoom-img');
const zoomClose = document.getElementById('img-zoom-close');
let zoomViewer = null;
zoomBtn.addEventListener('click', () => {
zoomImg.src = browserView.src;
zoomOverlay.hidden = false;
if (!zoomViewer) {
zoomViewer = new ImageZoomViewer(zoomImg, zoomContainer);
} else {
zoomViewer.reset();
}
});
zoomClose.addEventListener('click', () => { zoomOverlay.hidden = true; });
const sidebar = new Sidebar((wsRef, surfaceRef) => {
switchSurface(wsRef, surfaceRef);
});
const gestures = new GestureHandler(sidebar);
const theme = new ThemeSwitcher();
// Scrollback state
const PAGE_LINES = 500;
let scrollbackLines = 0; // 0 = live view
let inScrollMode = false;
let scrollbackPending = false;
const scrollbackLoading = document.getElementById('scrollback-loading');
function requestScrollback(lines) {
if (!currentWorkspace || !currentSurface) return;
scrollbackPending = true;
if (scrollbackLoading) scrollbackLoading.hidden = false;
ws.requestScroll(currentWorkspace, currentSurface, lines);
}
function exitScrollMode() {
inScrollMode = false;
scrollbackLines = 0;
scrollbackPending = false;
if (scrollbackLoading) scrollbackLoading.hidden = true;
terminal.autoScroll = true;
// Re-subscribe to get fresh live content
if (currentWorkspace && currentSurface) {
ws.subscribe(currentWorkspace, currentSurface);
}
}
// Scroll-driven history: reaching the top (with scroll room) loads older
// history; reaching the bottom resumes live updates. Entry is explicit (only
// on an actual scroll-to-top), never automatic — that avoids the freeze the
// old auto-load-when-fits caused.
terminalContainer.addEventListener('scroll', () => {
const c = terminalContainer;
const atBottom = c.scrollHeight - c.scrollTop - c.clientHeight < 30;
if (atBottom) {
if (inScrollMode) exitScrollMode();
return;
}
if (c.scrollTop < 40 && !scrollbackPending && terminal.lines.length &&
currentWorkspace && currentSurface) {
loadOlderHistory();
}
});
function loadOlderHistory() {
if (scrollbackPending || !terminal.lines.length ||
!currentWorkspace || !currentSurface) return;
inScrollMode = true;
terminal.autoScroll = false; // pin position; render-fix won't snap to bottom
scrollbackLines += PAGE_LINES;
requestScrollback(scrollbackLines);
}
// Desktop has no touch scroll. When the live screen fits the viewport exactly
// there is no scroll room, so the 'scroll' event never fires and history can't
// be reached. Catch the wheel-up intent directly to load older history.
terminalContainer.addEventListener('wheel', (e) => {
if (e.deltaY < 0 && terminalContainer.scrollTop < 40) {
loadOlderHistory();
}
}, { passive: true });
const keyboard = new VirtualKeyboard(
(text) => {
if (currentWorkspace && currentSurface) {
if (inScrollMode) exitScrollMode();
ws.sendText(currentWorkspace, currentSurface, text);
}
},
(key) => {
if (key === 'PageUp') {
inScrollMode = true;
scrollbackLines += PAGE_LINES;
requestScrollback(scrollbackLines);
return;
}
if (key === 'PageDown') {
if (inScrollMode) {
scrollbackLines = Math.max(0, scrollbackLines - PAGE_LINES);
if (scrollbackLines === 0) {
exitScrollMode();
} else {
requestScrollback(scrollbackLines);
}
}
return;
}
if (inScrollMode) exitScrollMode();
if (currentWorkspace && currentSurface) {
ws.sendKey(currentWorkspace, currentSurface, key);
}
}
);
const claudeKeyboard = new ClaudeKeyboard(
(text) => {
if (currentWorkspace && currentSurface) {
ws.sendText(currentWorkspace, currentSurface, text);
}
},
(key) => {
if (currentWorkspace && currentSurface) {
ws.sendKey(currentWorkspace, currentSurface, key);
}
}
);
function handleClaudeAction(action, payload) {
if (!currentWorkspace || !currentSurface) return;
if (action === 'select') {
ws.sendText(currentWorkspace, currentSurface, payload + '\n');
} else if (action === 'mode-toggle') {
ws.sendKey(currentWorkspace, currentSurface, 'Shift-Tab');
}
}
// Reload button
document.getElementById('reload-btn').addEventListener('click', () => location.reload());
// Horizontal scroll toggle
const scrollXCb = document.getElementById('scroll-x-cb');
scrollXCb.addEventListener('change', () => {
terminalContainer.classList.toggle('scroll-x', scrollXCb.checked);
});
// Keyboard toggle — restore saved state
const keyboardToggle = document.getElementById('keyboard-toggle');
const virtualKeyboardEl = document.getElementById('virtual-keyboard');
const savedKbCollapsed = localStorage.getItem('cmux-kb-collapsed');
if (savedKbCollapsed !== null) {
virtualKeyboardEl.classList.toggle('collapsed', savedKbCollapsed === 'true');
} else if (window.innerWidth >= 1024) {
virtualKeyboardEl.classList.add('collapsed');
}
keyboardToggle.addEventListener('click', () => {
virtualKeyboardEl.classList.toggle('collapsed');
localStorage.setItem('cmux-kb-collapsed', virtualKeyboardEl.classList.contains('collapsed'));
});
window.addEventListener('resize', () => {
if (window.innerWidth < 1024) {
virtualKeyboardEl.classList.remove('collapsed');
localStorage.removeItem('cmux-kb-collapsed');
}
});
// Settings panel
const settingsBtn = document.getElementById('settings-btn');
const settingsPanel = document.getElementById('settings-panel');
const settingsOverlay = document.getElementById('settings-overlay');
const settingsClose = document.getElementById('settings-close');
settingsBtn.addEventListener('click', () => {
settingsPanel.classList.add('open');
settingsOverlay.classList.add('visible');
});
function closeSettings() {
settingsPanel.classList.remove('open');
settingsOverlay.classList.remove('visible');
}
settingsClose.addEventListener('click', closeSettings);
settingsOverlay.addEventListener('click', closeSettings);
function updateTitle(wsRef, surfaceRef) {
const el = document.getElementById('app-title');
if (!el) return;
let rawTitle = surfaceRef || '';
for (const workspace of lastWorkspaces) {
if (workspace.ref === wsRef) {
for (const pane of workspace.panes) {
for (const surface of pane.surfaces) {
if (surface.ref === surfaceRef) {
rawTitle = surface.title || surface.ref;
break;
}
}
}
}
}
const status = Sidebar.parseStatus(rawTitle);
el.innerHTML = '';
if (status.state) {
const dot = document.createElement('span');
dot.className = 'title-status ' + status.state;
el.appendChild(dot);
}
el.appendChild(document.createTextNode(status.title));
}
// Connection events
ws.on('connected', () => {
statusDot.classList.add('connected');
statusDot.classList.remove('reconnecting');
statusDot.title = 'Connected';
terminalContainer.classList.remove('stale');
});
ws.on('disconnected', () => {
statusDot.classList.remove('connected');
statusDot.classList.add('reconnecting');
statusDot.title = 'Reconnecting...';
terminalContainer.classList.add('stale');
});
ws.on('reconnecting', ({ attempt }) => {
statusDot.title = `Reconnecting (${attempt})...`;
});
ws.on('auth-ok', () => {
ws.listWorkspaces();
// Re-subscribe to current surface after reconnect
if (currentWorkspace && currentSurface) {
ws.subscribe(currentWorkspace, currentSurface);
}
});
ws.on('workspaces', (msg) => {
sidebar.setWorkspaces(msg.workspaces);
lastWorkspaces = msg.workspaces;
// Auto-subscribe only on first load (no surface selected yet)
if (!currentSurface && msg.workspaces.length > 0) {
// 1. Try to restore from URL hash
const saved = getHashSurface();
if (saved && findAndSwitch(msg.workspaces, saved.ws, saved.surface)) return;
// 2. Try to restore from localStorage
try {
const ls = JSON.parse(localStorage.getItem('cmux-last-surface') || 'null');
if (ls && findAndSwitch(msg.workspaces, ls.ws, ls.surface)) return;
} catch {}
// 3. Fall back to first terminal surface
const firstWs = msg.workspaces[0];
for (const pane of firstWs.panes) {
for (const surface of pane.surfaces) {
if (surface.type === 'terminal') {
switchSurface(firstWs.ref, surface.ref);
return;
}
}
}
// 3. Fall back to first surface of any type
if (firstWs.panes.length > 0 && firstWs.panes[0].surfaces.length > 0) {
const s = firstWs.panes[0].surfaces[0];
switchSurface(firstWs.ref, s.ref);
}
}
});
ws.on('screen', (msg) => {
if (msg.surface !== currentSurface) return;
// While viewing loaded history, pause live screen replacement.
if (inScrollMode && !msg.scrollback) return;
if (msg.scrollback) {
// User-triggered history load (scroll-to-top or PageUp): show it, pause
// live updates, and preserve the user's reading position. Older lines are
// prepended, so shift scrollTop by the height added above.
const prevScrollHeight = terminalContainer.scrollHeight;
const prevScrollTop = terminalContainer.scrollTop;
terminal.setContent(msg.lines);
terminal.autoScroll = false;
scrollbackPending = false;
if (scrollbackLoading) scrollbackLoading.hidden = true;
requestAnimationFrame(() => {
const added = terminalContainer.scrollHeight - prevScrollHeight;
terminalContainer.scrollTop = prevScrollTop + Math.max(added, 0);
});
} else {
terminal.setContent(msg.lines);
}
});
ws.on('screen-diff', (msg) => {
if (msg.surface === currentSurface && !inScrollMode) {
terminal.applyDiff(msg.patches);
}
});
ws.on('browser-screenshot', (msg) => {
if (msg.surface !== currentSurface) return;
const mime = msg.mime || 'image/jpeg';
const src = `data:${mime};base64,${msg.imageData}`;
terminalOutput.hidden = true;
terminalContainer.classList.add('browser-mode');
// Hide loading bar, show image
browserLoading.hidden = true;
browserLoading.classList.remove('flash');
browserView.hidden = false;
zoomBtn.hidden = false;
browserView.src = src;
if (!zoomOverlay.hidden) zoomImg.src = src;
});
async function updateCodexMode(surfaceRef) {
// Skip if claude mode is already active (mutually exclusive)
if (document.body.classList.contains('claude-mode')) return;
try {
const data = await fetch('/api/codex-surfaces').then(r => r.json());
if (surfaceRef !== currentSurface) return;
const isCodex = data.surfaces.includes(surfaceRef);
document.body.classList.toggle('codex-mode', isCodex);
if (terminal.setCodexMode) terminal.setCodexMode(isCodex);
} catch {
if (surfaceRef !== currentSurface) return;
document.body.classList.remove('codex-mode');
if (terminal.setCodexMode) terminal.setCodexMode(false);
}
}
async function updateClaudeMode(surfaceRef) {
try {
const data = await fetch('/api/claude-surfaces').then(r => r.json());
if (surfaceRef !== currentSurface) return;
const isClaudeCode = data.surfaces.includes(surfaceRef);
document.body.classList.toggle('claude-mode', isClaudeCode);
terminal.setClaudeMode(isClaudeCode, handleClaudeAction);
if (isClaudeCode) {
claudeKeyboard.activate();
} else {
claudeKeyboard.deactivate();
}
} catch {
if (surfaceRef !== currentSurface) return;
document.body.classList.remove('claude-mode');
terminal.setClaudeMode(false, null);
claudeKeyboard.deactivate();
}
}
function switchSurface(wsRef, surfaceRef) {
if (currentSurface) ws.unsubscribe(currentSurface);
inScrollMode = false;
scrollbackLines = 0;
scrollbackPending = false;
if (scrollbackLoading) scrollbackLoading.hidden = true;
currentWorkspace = wsRef;
currentSurface = surfaceRef;
updateTitle(wsRef, surfaceRef);
localStorage.setItem('cmux-last-surface', JSON.stringify({ ws: wsRef, surface: surfaceRef }));
// Immediately reset claude/codex mode; update functions will re-enable if needed
document.body.classList.remove('claude-mode');
document.body.classList.remove('codex-mode');
terminal.setClaudeMode(false, null);
if (terminal.setCodexMode) terminal.setCodexMode(false);
claudeKeyboard.deactivate();
terminal.clear();
terminal.autoScroll = true;
browserView.hidden = true;
browserLoading.hidden = true;
browserLoading.classList.remove('flash');
zoomBtn.hidden = true;
zoomOverlay.hidden = true;
terminalOutput.hidden = false;
terminalContainer.classList.remove('browser-mode');
ws.subscribe(wsRef, surfaceRef);
sidebar.setActiveSurface(surfaceRef);
setHashSurface(wsRef, surfaceRef);
updateClaudeMode(surfaceRef);
updateCodexMode(surfaceRef);
// If this is a browser surface, show loading indicator immediately
const surfaceInfo = lastWorkspaces.flatMap(w => w.panes.flatMap(p => p.surfaces))
.find(s => s.ref === surfaceRef);
if (surfaceInfo?.type === 'browser') {
terminalOutput.hidden = true;
terminalContainer.classList.add('browser-mode');
browserLoading.hidden = false;
browserLoading.classList.add('flash');
}
}
// Periodically re-check Claude mode in case initial detection failed transiently.
setInterval(() => {
if (currentSurface && !document.body.classList.contains('claude-mode')) {
updateClaudeMode(currentSurface);
}
}, 8000);
// Browser back/forward navigation
window.addEventListener('hashchange', () => {
const saved = getHashSurface();
if (saved && ws.isConnected()) {
findAndSwitch(lastWorkspaces, saved.ws, saved.surface);
}
});
// Visibility API: pause polling when hidden, reconnect when visible
let hiddenSince = 0;
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
hiddenSince = Date.now();
if (currentSurface) ws.unsubscribe(currentSurface);
} else {
const elapsed = hiddenSince ? Date.now() - hiddenSince : 0;
hiddenSince = 0;
if (!currentWorkspace || !currentSurface) return;
// iOS kills WebSocket connections in background.
// If hidden >5s or connection is dead, force fresh reconnect.
if (!ws.isConnected() || elapsed > 5000) {
ws.reconnectNow();
} else {
ws.subscribe(currentWorkspace, currentSurface);
}
}
});
// Periodic workspace refresh
setInterval(() => {
if (ws.isConnected()) {
ws.listWorkspaces();
}
}, 10000);
// Start
ws.connect(authStatus.authRequired);
})();