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.
505 lines
16 KiB
JavaScript
505 lines
16 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;
|
|
|
|
function requestScrollback(lines) {
|
|
if (!currentWorkspace || !currentSurface) return;
|
|
ws.requestScroll(currentWorkspace, currentSurface, lines);
|
|
}
|
|
|
|
function exitScrollMode() {
|
|
inScrollMode = false;
|
|
scrollbackLines = 0;
|
|
terminal.autoScroll = true;
|
|
// Re-subscribe to get fresh live content
|
|
if (currentWorkspace && currentSurface) {
|
|
ws.subscribe(currentWorkspace, currentSurface);
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
// Title helper — reuses sidebar's status-parsing logic
|
|
function _parseStatus(title) {
|
|
const workingRe = /^[\u2800-\u28FF\u23FA\u25CF\u2B24]\s*/;
|
|
const idleRe = /^[\u2700-\u27BF]\s*/;
|
|
if (workingRe.test(title)) return { state: 'working', title: title.replace(workingRe, '') };
|
|
if (idleRe.test(title)) return { state: 'idle', title: title.replace(idleRe, '') };
|
|
return { state: null, title };
|
|
}
|
|
|
|
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 = _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', () => {
|
|
// Request workspace list after auth
|
|
ws.listWorkspaces();
|
|
});
|
|
|
|
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;
|
|
if (inScrollMode && !msg.scrollback) return;
|
|
terminal.setContent(msg.lines);
|
|
|
|
if (msg.scrollback) {
|
|
terminal.autoScroll = false;
|
|
// After render, scroll to show content just before current view (most recently scrolled-off)
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
terminalContainer.scrollTop = terminalContainer.scrollHeight - terminalContainer.clientHeight;
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
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 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;
|
|
|
|
currentWorkspace = wsRef;
|
|
currentSurface = surfaceRef;
|
|
updateTitle(wsRef, surfaceRef);
|
|
localStorage.setItem('cmux-last-surface', JSON.stringify({ ws: wsRef, surface: surfaceRef }));
|
|
|
|
// Immediately reset claude mode; updateClaudeMode will re-enable if needed
|
|
document.body.classList.remove('claude-mode');
|
|
terminal.setClaudeMode(false, null);
|
|
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);
|
|
|
|
// 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');
|
|
}
|
|
}
|
|
|
|
// 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
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.hidden) {
|
|
if (currentSurface) ws.unsubscribe(currentSurface);
|
|
} else {
|
|
if (currentWorkspace && currentSurface) {
|
|
ws.subscribe(currentWorkspace, currentSurface);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Periodic workspace refresh
|
|
setInterval(() => {
|
|
if (ws.isConnected()) {
|
|
ws.listWorkspaces();
|
|
}
|
|
}, 10000);
|
|
|
|
// Start
|
|
ws.connect(authStatus.authRequired);
|
|
})();
|