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, preserving every incremental update in order. Each
138 // queued delta is still applied in order; never drops intermediate
139 // deltas. requestAnimationFrame is throttled to zero in hidden pages,
140 // so a timeout fallback keeps the terminal live there.
141 let queuedUpdates = [];
142 let presentScheduled = false;
143 function flushUpdates() {
144 presentScheduled = false;
145 const batch = queuedUpdates;
146 queuedUpdates = [];
147 try {
148 for (const update of batch) terminal.apply(update);
149 } catch {
150 status.textContent = 'Invalid terminal update — connection stopped.';
151 socket.close();
152 }
153 }
154 function scheduleFlush() {
155 if (presentScheduled) return;
156 presentScheduled = true;
157 if (document.visibilityState === 'visible') requestAnimationFrame(flushUpdates);
158 else setTimeout(flushUpdates, 0);
159 }
160 socket.addEventListener('message', event => {
161 try {
162 const message = JSON.parse(event.data);
163 if (!message || typeof message.update !== 'object' || message.update === null) throw new Error('bad update');
164 queuedUpdates.push(message.update);
165 scheduleFlush();
166 if (mouse !== message.mouse) {
167 mouse = message.mouse;
168 terminal.setMouseCapture(mouse && !selecting);
169 }
170 if (!received) { received = true; if (!coarsePointer.matches) terminal.focus(); fit(); refreshViews(); }
171 if (status.textContent !== 'Connected') status.textContent = 'Connected';
172 } catch {
173 status.textContent = 'Invalid terminal update — connection stopped.';
174 socket.close();
175 }
176 });
177 socket.addEventListener('close', () => {
178 if (status.textContent === 'Connected' || status.textContent === 'Connecting…') {
179 status.textContent = 'Disconnected — session shells remain in the daemon.';
180 }
181 setViewCount();
182 reconnect.hidden = false;
183 });
184 socket.addEventListener('error', () => { status.textContent = 'Connection failed.'; });
185 }
186
187 root.addEventListener('pointerdown', event => { if (!event.shiftKey && !selecting && terminal) terminal.focus(); });
188 document.querySelector('#palette').addEventListener('click', () => {
189 if (!terminal) return;
190 send({ type: 'key', key: ' ', code: 'Space', alt: true, ctrl: false, meta: false, shift: false, repeat: false, location: 0 });
191 terminal.focus();
192 });
193 reconnect.addEventListener('click', start);
194 new ResizeObserver(fit).observe(workspace);
195 document.fonts.ready.then(fit);
196 window.addEventListener('pagehide', () => { if (socket) socket.close(); });
197
198 async function start() {
199 const token = new URLSearchParams(location.hash.slice(1)).get('token');
200 try {
201 if (token) {
202 const login = await fetch('/login', { method: 'POST', body: new URLSearchParams({ token }), credentials: 'same-origin' });
203 if (!login.ok) throw new Error('login');
204 }
205 const health = await fetch('/health', { credentials: 'same-origin' });
206 if (!health.ok) throw new Error('login');
207 history.replaceState(null, '', '/');
208 document.querySelector('#help').hidden = true;
209 connect();
210 } catch (error) {
211 if (error?.message === 'login') {
212 status.textContent = 'Authentication required';
213 reconnect.hidden = true;
214 document.querySelector('#help').hidden = false;
215 return;
216 }
217 status.textContent = 'Disconnected — gateway unreachable. Reconnect to retry.';
218 reconnect.hidden = false;
219 }
220 }
221 window.addEventListener('hashchange', () => {
222 if (new URLSearchParams(location.hash.slice(1)).has('token')) start();
223 });
224 start();
225})();