detached ece7682

app.js RawHistoryFolder

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})();

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close