1(() => {
2 'use strict';
3 const root = document.querySelector('#terminal');
4 const workspace = document.querySelector('#workspace');
5 const status = document.querySelector('#status');
6 const reconnect = document.querySelector('#reconnect');
7 let socket;
8 let terminal;
9 let mouse = false;
10 let lastGeometry = '';
11 let fitting = 0;
12 let received = false;
13 const coarsePointer = matchMedia('(pointer: coarse)');
14 let selecting = coarsePointer.matches;
15 const selectButton = document.querySelector('#select-mode');
16 function setSelecting(value) {
17 selecting = value;
18 selectButton.setAttribute('aria-pressed', String(value));
19 if (terminal) terminal.setMouseCapture(mouse && !selecting);
20 if (value) root.querySelector('textarea')?.blur();
21 }
22 setSelecting(selecting);
23 selectButton.addEventListener('click', () => setSelecting(!selecting));
24 document.querySelector('#keyboard').addEventListener('click', () => {
25 setSelecting(false);
26 terminal?.focus();
27 });
28 function fitVisualViewport() {
29 const viewport = window.visualViewport;
30 // Pinch zoom must not resize the PTY or fight the browser's pan gesture.
31 if (!viewport || Math.abs(viewport.scale - 1) > 0.01) return;
32 document.body.style.setProperty('--app-height', `${viewport.height}px`);
33 window.scrollTo(0, 0);
34 fit();
35 }
36 window.visualViewport?.addEventListener('resize', fitVisualViewport);
37 fitVisualViewport();
38
39 function syncStatus() {
40 const text = status.textContent;
41 const indicator = document.querySelector('#connection');
42 indicator.title = text;
43 indicator.dataset.state = text === 'Connected' ? 'connected' : text === 'Connecting…' ? 'connecting' : text.startsWith('Disconnected') ? 'disconnected' : 'error';
44 }
45 new MutationObserver(syncStatus).observe(status, { childList: true, characterData: true, subtree: true });
46 function setViewCount(count = null) {
47 document.querySelector('#view-count').textContent = count === null ? '—' : String(count);
48 document.querySelector('#view-description').textContent = `Connected browser terminal views: ${count === null ? 'unknown' : count}`;
49 }
50 let counting = false;
51 async function refreshViews() {
52 if (counting) return;
53 if (!socket || socket.readyState !== WebSocket.OPEN) {
54 setViewCount();
55 return;
56 }
57 counting = true;
58 const current = socket;
59 try {
60 const response = await fetch('/views', { signal: AbortSignal.timeout(3000) });
61 if (!response.ok) throw new Error('unavailable');
62 const { active } = await response.json();
63 if (!Number.isInteger(active) || active < 0) throw new Error('invalid count');
64 if (socket === current && current.readyState === WebSocket.OPEN) setViewCount(active);
65 } catch {
66 if (socket === current) setViewCount();
67 } finally { counting = false; }
68 }
69 setInterval(refreshViews, 2000);
70
71 function send(event) {
72 if (!socket || socket.readyState !== WebSocket.OPEN) return;
73 if (socket.bufferedAmount > 1 << 20) {
74 status.textContent = 'Connection too slow — input stopped. Reconnect to continue.';
75 socket.close();
76 return;
77 }
78 socket.send(JSON.stringify({ ...event, schemaVersion: VevTerminal.schemaVersion }));
79 }
80
81 function fit() {
82 if (fitting || !received) return;
83 fitting = requestAnimationFrame(() => {
84 fitting = 0;
85 const cell = root.querySelector('.vev-terminal__cell');
86 const row = root.querySelector('.vev-terminal__row');
87 if (!cell || !row) return;
88 const style = getComputedStyle(row);
89 const cellWidth = parseFloat(style.gridTemplateColumns);
90 const cellHeight = row.getBoundingClientRect().height;
91 if (!(cellWidth > 0 && cellHeight > 0)) return;
92 const columns = Math.max(2, Math.min(512, Math.floor((workspace.clientWidth - 8) / cellWidth)));
93 const rows = Math.max(2, Math.min(256, Math.floor((workspace.clientHeight - 8) / cellHeight)));
94 const event = { type: 'resize', columns, rows, pixelWidth: columns * cellWidth, pixelHeight: rows * cellHeight, cellWidth, cellHeight, devicePixelRatio: window.devicePixelRatio };
95 const identity = JSON.stringify(event);
96 if (identity !== lastGeometry) { lastGeometry = identity; send(event); }
97 });
98 }
99
100 function connect() {
101 if (socket && socket.readyState < WebSocket.CLOSING) return;
102 if (terminal) terminal.destroy();
103 received = false;
104 mouse = false;
105 lastGeometry = '';
106 terminal = VevTerminal.mount(root, {
107 label: 'vev terminal multiplexer',
108 decide(event) {
109 if (event.type === 'resize' || event.type === 'focus') return { emit: false, preventDefault: false };
110 if (event.type === 'pointer') {
111 const capture = mouse && !selecting && !event.shift;
112 return { emit: capture, preventDefault: capture };
113 }
114 if (event.type === 'wheel') {
115 // Shift+wheel is fast scroll (x10 server-side); Shift+pointer stays
116 // reserved for native selection.
117 const capture = mouse && !selecting;
118 return { emit: capture, preventDefault: capture };
119 }
120 // Ctrl+Shift+F6 releases terminal focus for keyboard navigation.
121 if (event.type === 'key' && event.key === 'F6' && event.ctrl && event.shift) {
122 document.querySelector('#palette').focus();
123 return { emit: false, preventDefault: true };
124 }
125 // Preserve native copy/paste and browser/OS shortcuts.
126 if (event.type === 'key' && (event.meta || (event.ctrl && event.shift && ['C', 'V', 'c', 'v'].includes(event.key)) || (event.ctrl && !event.alt && event.key.toLowerCase() === 'v'))) {
127 return { emit: false, preventDefault: false };
128 }
129 return { emit: true, preventDefault: true };
130 },
131 send
132 });
133 socket = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`);
134 status.textContent = 'Connecting…';
135 reconnect.hidden = true;
136 // Presentation coalescing: at most one scheduled present task per
137 // animation frame. applyAll validates every queued update, then merges
138 // them so each changed row is rebuilt once per frame. requestAnimationFrame
139 // is throttled to zero in hidden pages, so a timeout fallback keeps the
140 // terminal live there.
141 // Presents are capped at about 30 per second: a full-screen redraw (fast
142 // history scroll) costs the browser more style, layout and paint than one
143 // 60 Hz frame allows. The first update after a pause is presented on the
144 // next frame, so typing latency is unchanged.
145 const minPresentMs = 32;
146 let queuedUpdates = [];
147 let presentScheduled = false;
148 let lastPresent = -Infinity;
149 function flushUpdates(now = performance.now()) {
150 if (now - lastPresent < minPresentMs) {
151 if (document.visibilityState === 'visible') requestAnimationFrame(flushUpdates);
152 else setTimeout(flushUpdates, minPresentMs - (now - lastPresent));
153 return;
154 }
155 lastPresent = now;
156 presentScheduled = false;
157 const batch = queuedUpdates;
158 queuedUpdates = [];
159 try {
160 terminal.applyAll(batch);
161 } catch {
162 status.textContent = 'Invalid terminal update — connection stopped.';
163 socket.close();
164 }
165 }
166 function scheduleFlush() {
167 if (presentScheduled) return;
168 presentScheduled = true;
169 if (document.visibilityState === 'visible') requestAnimationFrame(flushUpdates);
170 else setTimeout(flushUpdates, 0);
171 }
172 socket.addEventListener('message', event => {
173 try {
174 const message = JSON.parse(event.data);
175 if (!message || typeof message.update !== 'object' || message.update === null) throw new Error('bad update');
176 queuedUpdates.push(message.update);
177 scheduleFlush();
178 if (mouse !== message.mouse) {
179 mouse = message.mouse;
180 terminal.setMouseCapture(mouse && !selecting);
181 }
182 if (!received) { received = true; if (!coarsePointer.matches) terminal.focus(); fit(); refreshViews(); }
183 if (status.textContent !== 'Connected') status.textContent = 'Connected';
184 } catch {
185 status.textContent = 'Invalid terminal update — connection stopped.';
186 socket.close();
187 }
188 });
189 socket.addEventListener('close', () => {
190 if (status.textContent === 'Connected' || status.textContent === 'Connecting…') {
191 status.textContent = 'Disconnected — session shells remain in the daemon.';
192 }
193 setViewCount();
194 reconnect.hidden = false;
195 });
196 socket.addEventListener('error', () => { status.textContent = 'Connection failed.'; });
197 }
198
199 root.addEventListener('pointerdown', event => { if (!event.shiftKey && !selecting && terminal) terminal.focus(); });
200 document.querySelector('#palette').addEventListener('click', () => {
201 if (!terminal) return;
202 send({ type: 'key', key: ' ', code: 'Space', alt: true, ctrl: false, meta: false, shift: false, repeat: false, location: 0 });
203 terminal.focus();
204 });
205 reconnect.addEventListener('click', start);
206 new ResizeObserver(fit).observe(workspace);
207 document.fonts.ready.then(fit);
208 window.addEventListener('pagehide', () => { if (socket) socket.close(); });
209
210 async function start() {
211 const token = new URLSearchParams(location.hash.slice(1)).get('token');
212 try {
213 if (token) {
214 const login = await fetch('/login', { method: 'POST', body: new URLSearchParams({ token }), credentials: 'same-origin' });
215 if (!login.ok) throw new Error('login');
216 }
217 const health = await fetch('/health', { credentials: 'same-origin' });
218 if (!health.ok) throw new Error('login');
219 history.replaceState(null, '', '/');
220 document.querySelector('#help').hidden = true;
221 connect();
222 } catch (error) {
223 if (error?.message === 'login') {
224 status.textContent = 'Authentication required';
225 reconnect.hidden = true;
226 document.querySelector('#help').hidden = false;
227 return;
228 }
229 status.textContent = 'Disconnected — gateway unreachable. Reconnect to retry.';
230 reconnect.hidden = false;
231 }
232 }
233 window.addEventListener('hashchange', () => {
234 if (new URLSearchParams(location.hash.slice(1)).has('token')) start();
235 });
236 start();
237})();