aboutsummaryrefslogtreecommitdiffstats
path: root/zellij-client
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-06-10 16:50:55 +0200
committerGitHub <noreply@github.com>2026-06-10 16:50:55 +0200
commitb6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0 (patch)
tree38c77a381cfb937b428752a588c09a45c81434c9 /zellij-client
parentfeat: PWA support for the web client (#5184) (diff)
downloadsl0ppij-b6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0.tar.gz
sl0ppij-b6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0.tar.xz
sl0ppij-b6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0.zip
Mobile layout (#5241)
* phase 2+3 * initial mobile plugin (phases 4 + 5 + 6) * some bugfixes * implement stage 7 * mobile interface improvements * improve mobile pane/tab picker * more ui improvements and fixes * various mobile fixes * some mobile fixes * mobile keyboard triggering fix * some cleanups * initial plugin keyboard implementation * improve keyboard hit zones * improve keyboard * pinch resize * pinching fixes * implement scrolling + panning * initial keyboard resizing * fix: mobile first render issues * keyboard responsiveness * fix some issues with the keyboard * implement fit * fix mobile scrolling * add tilde cell to compact * improve mobile keyboard * fix starting mobile terminal font to natural keyboard size * fix mobile resize blank screen flash * shadow focus for mobile * more keyboard fixes * improve mobile status bar * fix: render plugins to mobile clients * allow opening new panes/tabs from the mobile plugin * allow configuring * fix dot slash * improve mobile native soft keyboard interaction * switch to native keyboard - initial implementation * dirty hacks, but phone keyboard works now * adjust keyboard help strip * fix cursor in mobile * fix keyboard and help line * rename keyboard to modifier bar * allow starting new session * make clicking session name bring us to new session menu * allow switching to desktop from the mobile ui * mobile welcome screen initial implementation * mobile welcome screen fixes * various welcome screen fixes * more mobile fixes * moar mobile fixes * mobile ui redesign * fix: swallowed backspace bug * switch session screen improvement * touch up some interfaces * refactor: move mobile state to own struct * refactor: move fit resizing to the server * remove dead flash code * some cleanups * refactor: clear fit on close * refactor: improve fit api * refactor: pane sync functions * refactor: mouse * refactor: break into modules * some cleanups for the sessions screen * fix scrolling and other stuff * refactor: sessions screen * refactor: panes screen * refactor: new session screen * refactor: menu screen * refactor: render * cleanups * refactor: plugin structure * remove unused welcome screen path * Rename api * refactor input.js * cleanup useless comments * fit/mobile refactoring * take out the garbage * remove more comments * remove mobile flash on startup/attach * fix: various mobile startup issues * fix space bugs * cleanup * rustfmt * fix windows build * adjust modifier bar ui * add pr
Diffstat (limited to 'zellij-client')
-rw-r--r--zellij-client/assets/ime-bypass.js49
-rw-r--r--zellij-client/assets/index.html6
-rw-r--r--zellij-client/assets/index.js16
-rw-r--r--zellij-client/assets/input.js271
-rw-r--r--zellij-client/assets/key-handler.js64
-rw-r--r--zellij-client/assets/mouse.js25
-rw-r--r--zellij-client/assets/pinch.js134
-rw-r--r--zellij-client/assets/soft-keyboard.js288
-rw-r--r--zellij-client/assets/style.css19
-rw-r--r--zellij-client/assets/terminal.js53
-rw-r--r--zellij-client/assets/touch.js266
-rw-r--r--zellij-client/assets/websockets.js213
-rw-r--r--zellij-client/src/input_handler.rs6
-rw-r--r--zellij-client/src/lib.rs8
-rw-r--r--zellij-client/src/unit/terminal_loop_tests.rs42
-rw-r--r--zellij-client/src/web_client/control_message.rs9
-rw-r--r--zellij-client/src/web_client/server_listener.rs5
-rw-r--r--zellij-client/src/web_client/unit/web_client_tests.rs179
-rw-r--r--zellij-client/src/web_client/websocket_handlers.rs22
19 files changed, 1116 insertions, 559 deletions
diff --git a/zellij-client/assets/ime-bypass.js b/zellij-client/assets/ime-bypass.js
new file mode 100644
index 00000000..e2ef04b0
--- /dev/null
+++ b/zellij-client/assets/ime-bypass.js
@@ -0,0 +1,49 @@
+export function installImeBypass(term, sendFunction) {
+ if (typeof window.__zjImeBypass === "undefined") {
+ window.__zjImeBypass = {
+ installed: false,
+ sendFn: sendFunction,
+ lastKeyWasProcess: false,
+ };
+ }
+ window.__zjImeBypass.sendFn = sendFunction;
+
+ if (window.__zjImeBypass.installed) {
+ return;
+ }
+ window.__zjImeBypass.installed = true;
+ const state = window.__zjImeBypass;
+
+ document.addEventListener(
+ "keydown",
+ (ev) => {
+ state.lastKeyWasProcess = ev.key === "Process";
+ },
+ true
+ );
+
+ const attach = () => {
+ const ta = term && term._core && term._core.textarea;
+ if (!ta) {
+ setTimeout(attach, 50);
+ return;
+ }
+ ta.addEventListener(
+ "input",
+ (ev) => {
+ if (
+ state.lastKeyWasProcess &&
+ ev.inputType === "insertText" &&
+ !ev.isComposing &&
+ ev.data
+ ) {
+ state.sendFn(ev.data);
+ ev.target.value = "";
+ state.lastKeyWasProcess = false;
+ }
+ },
+ true
+ );
+ };
+ attach();
+}
diff --git a/zellij-client/assets/index.html b/zellij-client/assets/index.html
index c3fce356..638eda3a 100644
--- a/zellij-client/assets/index.html
+++ b/zellij-client/assets/index.html
@@ -33,6 +33,12 @@
<script type="module" src="assets/keyboard.js"></script>
<script type="module" src="assets/links.js"></script>
<script type="module" src="assets/terminal.js"></script>
+ <script type="module" src="assets/ime-bypass.js"></script>
+ <script type="module" src="assets/soft-keyboard.js"></script>
+ <script type="module" src="assets/key-handler.js"></script>
+ <script type="module" src="assets/mouse.js"></script>
+ <script type="module" src="assets/pinch.js"></script>
+ <script type="module" src="assets/touch.js"></script>
<script type="module" src="assets/input.js"></script>
<script type="module" src="assets/websockets.js"></script>
<script type="module" src="assets/index.js"></script>
diff --git a/zellij-client/assets/index.js b/zellij-client/assets/index.js
index bfc990fe..94a07442 100644
--- a/zellij-client/assets/index.js
+++ b/zellij-client/assets/index.js
@@ -12,18 +12,14 @@ document.addEventListener("DOMContentLoaded", async (event) => {
const { term, fitAddon } = initTerminal();
const sessionName = location.pathname.split("/").pop();
- let sendAnsiKey = (ansiKey) => {
- // This will be replaced by the WebSocket module
- };
-
- setupInputHandlers(term, sendAnsiKey);
+ let sendAnsiKey = (ansiKey) => {};
+
+ setupInputHandlers(term, fitAddon, sendAnsiKey);
document.title = sessionName;
const websockets = initWebSockets(webClientId, sessionName, term, fitAddon, sendAnsiKey);
-
- // Update sendAnsiKey to use the actual WebSocket function returned by initWebSockets
+
sendAnsiKey = websockets.sendAnsiKey;
-
- // Update the input handlers with the correct sendAnsiKey function
- setupInputHandlers(term, sendAnsiKey);
+
+ setupInputHandlers(term, fitAddon, sendAnsiKey);
});
diff --git a/zellij-client/assets/input.js b/zellij-client/assets/input.js
index d7fa4469..2c1849d6 100644
--- a/zellij-client/assets/input.js
+++ b/zellij-client/assets/input.js
@@ -1,175 +1,27 @@
-/**
- * Input handling for terminal events
- */
+import { installImeBypass } from "./ime-bypass.js";
+import {
+ installSoftKeyboardCapture,
+ suppressSoftKeyboardOnTouch,
+} from "./soft-keyboard.js";
+import { installCustomKeyHandler } from "./key-handler.js";
+import { installMouseHandlers } from "./mouse.js";
+import { createPinchController } from "./pinch.js";
+import { installTouchGestures } from "./touch.js";
-import { encode_kitty_key } from "./keyboard.js";
-import { isMac } from "./utils.js";
+export { setSoftKeyboard } from "./soft-keyboard.js";
-/**
- * Set up all input handlers for the terminal
- * @param {Terminal} term - The terminal instance
- * @param {function} sendFunction - Function to send data through WebSocket
- */
-export function setupInputHandlers(term, sendFunction) {
- // Mouse tracking state
- let prev_col = 0;
- let prev_row = 0;
-
- // Work around xterm.js's composition-based character drop under active IMEs
- // (ibus/fcitx on Linux, etc.). When an IME is attached, every keystroke comes
- // through with ev.key === "Process" and a phantom compositionstart/end wraps
- // the keystroke. xterm.js sometimes fails to forward the character through
- // its composition path under fast typing. We bypass that path only for this
- // case: use the textarea's standardized `input` event as the authoritative
- // source of the character, and clear the textarea so xterm.js's composition
- // handler sees no diff and cannot double-emit.
- //
- // - Non-IME typing (ev.key !== "Process") is untouched: xterm.js's keyboard
- // handler still emits onData directly, our listener does not trigger.
- // - Real IME composition (Japanese/Chinese) emits `insertCompositionText`,
- // not `insertText` — our filter ignores it; xterm.js handles it as today.
- // - Paste fires `insertFromPaste` — also ignored here.
+export function setupInputHandlers(term, fitAddon, sendFunction) {
installImeBypass(term, sendFunction);
+ installSoftKeyboardCapture(term, sendFunction);
+ suppressSoftKeyboardOnTouch(term);
+ installCustomKeyHandler(term, sendFunction);
- // Custom key event handler
- term.attachCustomKeyEventHandler((ev) => {
- if (ev.type === "keydown") {
- if (ev.key == "V" && ev.ctrlKey && ev.shiftKey) {
- // pass ctrl-shift-v onwards so that paste is interpreted by xterm.js
- return;
- }
- if (isMac() && ev.key == "v" && ev.metaKey) {
- // pass cmd-v onwards so that paste is interpreted by xterm.js
- return;
- }
- if (hasModifiersToHandle(ev)) {
- ev.preventDefault();
- encode_kitty_key(ev, sendFunction);
- return false;
- }
- // workarounds for https://github.com/xtermjs/xterm.js/blob/41e8ae395937011d6bf6c7cb618b851791aed395/src/common/input/Keyboard.ts#L158
- if (ev.key == "ArrowLeft" && ev.altKey) {
- ev.preventDefault();
- sendFunction("\x1b[1;3D");
- return false;
- }
- if (ev.key == "ArrowRight" && ev.altKey) {
- ev.preventDefault();
- sendFunction("\x1b[1;3C");
- return false;
- }
- if (ev.key == "ArrowUp" && ev.altKey) {
- ev.preventDefault();
- sendFunction("\x1b[1;3A");
- return false;
- }
- if (ev.key == "ArrowDown" && ev.altKey) {
- ev.preventDefault();
- sendFunction("\x1b[1;3B");
- return false;
- }
- if (
- (ev.key == "=" && ev.altKey) ||
- (ev.key == "+" && ev.altKey) ||
- (ev.key == "-" && ev.altKey)
- ) {
- // these are not properly handled by xterm.js, so we bypass it and encode them as kitty to make things easier
- ev.preventDefault();
- encode_kitty_key(ev, sendFunction);
- return false;
- }
- }
- return true;
- });
-
- // Mouse movement handler
- let terminal_element = document.getElementById("terminal");
- terminal_element.addEventListener("mousemove", function (event) {
- // this is a hack around: https://github.com/xtermjs/xterm.js/issues/1062
- // in short, xterm.js doesn't listen to mousemove at all and so even though
- // we send it a request for AnyEvent mouse handling, we don't get motion events in return
- // here we use some internal functions in a hopefully non-destructive way to calculate the
- // columns/rows to send from the x/y coordinates - it's safe to always send these because Zellij
- // always requests mouse AnyEvent handling
- if (event.buttons == 0) {
- // this means no mouse buttons are pressed and this is just a mouse movement
- let { col, row } = term._core._mouseService.getMouseReportCoords(
- event,
- terminal_element
- );
- if (prev_col != col || prev_row != row) {
- sendFunction(`\x1b[<35;${col + 1};${row + 1}M`);
- }
- prev_col = col;
- prev_row = row;
- }
- });
+ const terminalElement = document.getElementById("terminal");
+ installMouseHandlers(term, terminalElement, sendFunction);
- // Touch scroll handler (convert vertical swipes to mouse wheel events)
- let last_touch_y = null;
- let pending_scroll = 0;
- const touch_scroll_threshold = 24;
- const sendWheelEvent = (direction, touch) => {
- let { col, row } = term._core._mouseService.getMouseReportCoords(
- { clientX: touch.clientX, clientY: touch.clientY },
- terminal_element
- );
- const button = direction < 0 ? 65 : 64; // inverted: swipe up => wheel down
- sendFunction(`\x1b[<${button};${col + 1};${row + 1}M`);
- };
-
- terminal_element.addEventListener(
- "touchstart",
- (event) => {
- if (event.touches.length > 0) {
- last_touch_y = event.touches[0].clientY;
- pending_scroll = 0;
- }
- },
- { passive: true }
- );
-
- terminal_element.addEventListener(
- "touchmove",
- (event) => {
- if (event.touches.length === 0 || last_touch_y === null) {
- return;
- }
- event.preventDefault();
- const touch = event.touches[0];
- const delta = touch.clientY - last_touch_y;
- last_touch_y = touch.clientY;
- pending_scroll += delta;
- while (pending_scroll <= -touch_scroll_threshold) {
- sendWheelEvent(-1, touch);
- pending_scroll += touch_scroll_threshold;
- }
- while (pending_scroll >= touch_scroll_threshold) {
- sendWheelEvent(1, touch);
- pending_scroll -= touch_scroll_threshold;
- }
- },
- { passive: false }
- );
-
- terminal_element.addEventListener(
- "touchend",
- () => {
- last_touch_y = null;
- pending_scroll = 0;
- },
- { passive: true }
- );
-
- // Context menu handler
- document.addEventListener("contextmenu", function (event) {
- if (event.altKey) {
- // this is so that when the user does an alt-right-click to ungroup panes, the context menu will not appear
- event.preventDefault();
- }
- });
+ const pinch = createPinchController(term);
+ installTouchGestures({ term, terminalElement, sendFunction, pinch });
- // Terminal data handlers
term.onData((data) => {
sendFunction(data);
});
@@ -182,90 +34,3 @@ export function setupInputHandlers(term, sendFunction) {
sendFunction(buffer);
});
}
-
-/**
- * Install the IME-bypass input listener exactly once per page load.
- * The send-function reference is refreshed on every call so the real
- * WebSocket sender (installed after initWebSockets) replaces the initial
- * placeholder — see index.js where setupInputHandlers is called twice.
- */
-function installImeBypass(term, sendFunction) {
- if (typeof window.__zjImeBypass === "undefined") {
- window.__zjImeBypass = {
- installed: false,
- sendFn: sendFunction,
- lastKeyWasProcess: false,
- };
- }
- // Always point at the most-recently-provided sender.
- window.__zjImeBypass.sendFn = sendFunction;
-
- if (window.__zjImeBypass.installed) {
- return;
- }
- window.__zjImeBypass.installed = true;
- const state = window.__zjImeBypass;
-
- // Track whether the most recent keydown was IME-intercepted. Non-IME keys
- // report their actual key value ("a", "Enter", "ArrowLeft", ...) and go
- // through xterm.js's normal keyboard path; we do not want to interfere.
- document.addEventListener(
- "keydown",
- (ev) => {
- state.lastKeyWasProcess = ev.key === "Process";
- },
- true
- );
-
- // The xterm.js textarea is created asynchronously by term.open(). Poll
- // briefly until it's available, then attach our capture-phase input
- // listener.
- const attach = () => {
- const ta = term && term._core && term._core.textarea;
- if (!ta) {
- setTimeout(attach, 50);
- return;
- }
- ta.addEventListener(
- "input",
- (ev) => {
- if (
- state.lastKeyWasProcess &&
- ev.inputType === "insertText" &&
- !ev.isComposing &&
- ev.data
- ) {
- state.sendFn(ev.data);
- // Clear so xterm.js's composition diff at compositionend
- // finds no text to emit — prevents double-send.
- ev.target.value = "";
- state.lastKeyWasProcess = false;
- }
- },
- true
- );
- };
- attach();
-}
-
-/**
- * Check if a key event has modifiers and is not a modifier key itself
- * @param {KeyboardEvent} ev - The keyboard event
- * @returns {boolean} - True if the key has modifiers that need special handling
- */
-function hasModifiersToHandle(ev) {
- // Use key property for simpler modifier key detection
- const MODIFIER_KEYS = ["Shift", "Control", "Alt", "Meta"];
-
- // Count active modifiers
- const modifiers_count = [
- ev.altKey,
- ev.ctrlKey,
- ev.shiftKey,
- ev.metaKey,
- ].filter(Boolean).length;
-
- // Check if we have multiple modifiers or meta key, and it's not a modifier key itself
- const isModifierKey = MODIFIER_KEYS.includes(ev.key);
- return (modifiers_count > 1 || ev.metaKey) && !isModifierKey;
-}
diff --git a/zellij-client/assets/key-handler.js b/zellij-client/assets/key-handler.js
new file mode 100644
index 00000000..4af67b9f
--- /dev/null
+++ b/zellij-client/assets/key-handler.js
@@ -0,0 +1,64 @@
+import { encode_kitty_key } from "./keyboard.js";
+import { isMac } from "./utils.js";
+
+export function installCustomKeyHandler(term, sendFunction) {
+ term.attachCustomKeyEventHandler((ev) => {
+ if (ev.type === "keydown") {
+ if (ev.key == "V" && ev.ctrlKey && ev.shiftKey) {
+ return;
+ }
+ if (isMac() && ev.key == "v" && ev.metaKey) {
+ return;
+ }
+ if (hasModifiersToHandle(ev)) {
+ ev.preventDefault();
+ encode_kitty_key(ev, sendFunction);
+ return false;
+ }
+ // xterm.js mishandles Alt+Arrow; send Alt-modified SGR sequences directly:
+ // https://github.com/xtermjs/xterm.js/blob/41e8ae395937011d6bf6c7cb618b851791aed395/src/common/input/Keyboard.ts#L158
+ if (ev.key == "ArrowLeft" && ev.altKey) {
+ ev.preventDefault();
+ sendFunction("\x1b[1;3D");
+ return false;
+ }
+ if (ev.key == "ArrowRight" && ev.altKey) {
+ ev.preventDefault();
+ sendFunction("\x1b[1;3C");
+ return false;
+ }
+ if (ev.key == "ArrowUp" && ev.altKey) {
+ ev.preventDefault();
+ sendFunction("\x1b[1;3A");
+ return false;
+ }
+ if (ev.key == "ArrowDown" && ev.altKey) {
+ ev.preventDefault();
+ sendFunction("\x1b[1;3B");
+ return false;
+ }
+ if (
+ (ev.key == "=" && ev.altKey) ||
+ (ev.key == "+" && ev.altKey) ||
+ (ev.key == "-" && ev.altKey)
+ ) {
+ ev.preventDefault();
+ encode_kitty_key(ev, sendFunction);
+ return false;
+ }
+ }
+ return true;
+ });
+}
+
+function hasModifiersToHandle(ev) {
+ const MODIFIER_KEYS = ["Shift", "Control", "Alt", "Meta"];
+ const modifiers_count = [
+ ev.altKey,
+ ev.ctrlKey,
+ ev.shiftKey,
+ ev.metaKey,
+ ].filter(Boolean).length;
+ const isModifierKey = MODIFIER_KEYS.includes(ev.key);
+ return (modifiers_count > 1 || ev.metaKey) && !isModifierKey;
+}
diff --git a/zellij-client/assets/mouse.js b/zellij-client/assets/mouse.js
new file mode 100644
index 00000000..15b33c6a
--- /dev/null
+++ b/zellij-client/assets/mouse.js
@@ -0,0 +1,25 @@
+export function installMouseHandlers(term, terminalElement, sendFunction) {
+ let prev_col = 0;
+ let prev_row = 0;
+
+ // xterm.js doesn't emit mousemove (xtermjs/xterm.js#1062); synthesize SGR motion reports.
+ terminalElement.addEventListener("mousemove", function (event) {
+ if (event.buttons == 0) {
+ const { col, row } = term._core._mouseService.getMouseReportCoords(
+ event,
+ terminalElement
+ );
+ if (prev_col != col || prev_row != row) {
+ sendFunction(`\x1b[<35;${col + 1};${row + 1}M`);
+ }
+ prev_col = col;
+ prev_row = row;
+ }
+ });
+
+ document.addEventListener("contextmenu", function (event) {
+ if (event.altKey) {
+ event.preventDefault();
+ }
+ });
+}
diff --git a/zellij-client/assets/pinch.js b/zellij-client/assets/pinch.js
new file mode 100644
index 00000000..9dcbd6a8
--- /dev/null
+++ b/zellij-client/assets/pinch.js
@@ -0,0 +1,134 @@
+import { clampFontSize } from "./terminal.js";
+
+// xterm.js's WebGL renderer clears the framebuffer when it reassigns
+// canvas.width/height on a font-size change, and during a touch gesture the
+// repaint is rAF-throttled, so the cleared canvas can flash blank before the
+// repaint lands. This overlay pins a 2d-canvas snapshot over the live canvas.
+export function createPinchController(term) {
+ let pinchOverlay = null;
+ let pinchOverlayAwaitingRender = false;
+ let pinchOverlaySafetyTimer = null;
+
+ const destroy = () => {
+ if (pinchOverlay) {
+ pinchOverlay.remove();
+ pinchOverlay = null;
+ }
+ pinchOverlayAwaitingRender = false;
+ if (pinchOverlaySafetyTimer !== null) {
+ clearTimeout(pinchOverlaySafetyTimer);
+ pinchOverlaySafetyTimer = null;
+ }
+ };
+
+ const snapshot = () => {
+ destroy();
+ if (!term.element) return;
+ const sourceCanvases = term.element.querySelectorAll("canvas");
+ if (sourceCanvases.length === 0) return;
+ const ref = sourceCanvases[0];
+ const rect = ref.getBoundingClientRect();
+ if (rect.width <= 0 || rect.height <= 0) return;
+
+ const overlay = document.createElement("canvas");
+ overlay.width = ref.width;
+ overlay.height = ref.height;
+ const outer = document.getElementById("terminal");
+ const bg = outer
+ ? window.getComputedStyle(outer).backgroundColor
+ : "transparent";
+ Object.assign(overlay.style, {
+ position: "fixed",
+ left: rect.left + "px",
+ top: rect.top + "px",
+ width: rect.width + "px",
+ height: rect.height + "px",
+ zIndex: "9999",
+ pointerEvents: "none",
+ background: bg,
+ });
+
+ const ctx = overlay.getContext("2d");
+ if (ctx) {
+ for (const c of sourceCanvases) {
+ try {
+ ctx.drawImage(c, 0, 0);
+ } catch (e) {
+ }
+ }
+ }
+
+ document.body.appendChild(overlay);
+ pinchOverlay = overlay;
+ };
+
+ const refresh = () => {
+ if (!pinchOverlay) return;
+ if (!term.element) return;
+ const sourceCanvases = term.element.querySelectorAll("canvas");
+ if (sourceCanvases.length === 0) return;
+ const ref = sourceCanvases[0];
+ const rect = ref.getBoundingClientRect();
+ if (rect.width <= 0 || rect.height <= 0) return;
+
+ if (pinchOverlay.width !== ref.width) {
+ pinchOverlay.width = ref.width;
+ }
+ if (pinchOverlay.height !== ref.height) {
+ pinchOverlay.height = ref.height;
+ }
+ pinchOverlay.style.left = rect.left + "px";
+ pinchOverlay.style.top = rect.top + "px";
+ pinchOverlay.style.width = rect.width + "px";
+ pinchOverlay.style.height = rect.height + "px";
+
+ const ctx = pinchOverlay.getContext("2d");
+ if (ctx) {
+ ctx.clearRect(0, 0, pinchOverlay.width, pinchOverlay.height);
+ for (const c of sourceCanvases) {
+ try {
+ ctx.drawImage(c, 0, 0);
+ } catch (e) {
+ }
+ }
+ }
+ };
+
+ const armRemoval = () => {
+ if (!pinchOverlay) return;
+ pinchOverlayAwaitingRender = true;
+ if (pinchOverlaySafetyTimer !== null) {
+ clearTimeout(pinchOverlaySafetyTimer);
+ }
+ pinchOverlaySafetyTimer = setTimeout(() => {
+ pinchOverlaySafetyTimer = null;
+ destroy();
+ }, 600);
+ };
+
+ // Must not call fitAddon.fit() here: that would pre-sync term.rows/cols to the
+ // new dims, making the resize handler short-circuit and never notify the server.
+ const applyFontSize = (px) => {
+ const clamped = clampFontSize(px);
+ if (term.options.fontSize === clamped) {
+ return;
+ }
+ term.options.fontSize = clamped;
+ window.dispatchEvent(new CustomEvent("zellij:rendering-resize"));
+ };
+
+ if (term && typeof term.onRender === "function") {
+ term.onRender(() => {
+ if (!pinchOverlay) return;
+ refresh();
+ if (pinchOverlayAwaitingRender) {
+ pinchOverlayAwaitingRender = false;
+ requestAnimationFrame(() => {
+ destroy();
+ });
+ }
+ });
+ }
+
+ return { snapshot, refresh, applyFontSize, armRemoval, destroy };
+}
diff --git a/zellij-client/assets/soft-keyboard.js b/zellij-client/assets/soft-keyboard.js
new file mode 100644
index 00000000..25314453
--- /dev/null
+++ b/zellij-client/assets/soft-keyboard.js
@@ -0,0 +1,288 @@
+export function isCoarsePointerDevice() {
+ if (window.matchMedia && window.matchMedia("(pointer: coarse)").matches) {
+ return true;
+ }
+ if (typeof navigator !== "undefined" && navigator.maxTouchPoints > 0) {
+ return true;
+ }
+ if ("ontouchstart" in window) {
+ return true;
+ }
+ return /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
+}
+
+export function installSoftKeyboardCapture(term, sendFunction) {
+ if (!isCoarsePointerDevice()) {
+ return;
+ }
+ if (typeof window.__zjSoftKbdCapture === "undefined") {
+ window.__zjSoftKbdCapture = {
+ installed: false,
+ sendFn: sendFunction,
+ element: null,
+ // document.activeElement returns the shadow host (not the input) for a
+ // closed shadow root, so focus state must be mirrored manually.
+ isFocused: false,
+ };
+ }
+ window.__zjSoftKbdCapture.sendFn = sendFunction;
+
+ if (window.__zjSoftKbdCapture.installed) {
+ return;
+ }
+ window.__zjSoftKbdCapture.installed = true;
+ const state = window.__zjSoftKbdCapture;
+
+ // The capture input steals focus on every tap, so xterm.js renders the cursor
+ // with cursorInactiveStyle; mirror the active style so it stays visible.
+ const syncInactiveCursorStyle = () => {
+ const active = term.options.cursorStyle || "block";
+ term.options.cursorInactiveStyle = active;
+ };
+ syncInactiveCursorStyle();
+ window.__zjSyncInactiveCursorStyle = syncInactiveCursorStyle;
+
+ // Firefox Android / GBoard no-op backspace on truly empty content, so keep the
+ // input padded with the caret in the middle to give backspace something to delete.
+ // Must NOT be a normal space: diff() strips PADDING_CHAR out of the inserted text,
+ // which would swallow a genuinely typed space (U+0020). U+00A0 is non-typeable.
+ const PADDING_CHAR = "\u00a0";
+ const PADDING_LEN = 8;
+ const CARET_OFFSET = PADDING_LEN / 2;
+ const BASELINE = PADDING_CHAR.repeat(PADDING_LEN);
+
+ const captureHost = document.createElement("div");
+ captureHost.id = "zj-mobile-capture-host";
+ captureHost.style.cssText =
+ "position:fixed;top:0;left:0;" +
+ "width:1px;height:1px;" +
+ "opacity:0;pointer-events:none;" +
+ "overflow:hidden;";
+ captureHost.setAttribute("aria-hidden", "true");
+ document.body.appendChild(captureHost);
+ // Closed shadow root hides the input from password managers, whose content
+ // scripts query input[type=password] and cannot pierce it.
+ const captureShadow = captureHost.attachShadow({ mode: "closed" });
+
+ const div = document.createElement("input");
+ div.id = "zj-mobile-capture";
+ // type="password" disables prediction/autocorrect/composition on every mobile
+ // keyboard vendor, turning each tap into one immediate character.
+ div.type = "password";
+ div.setAttribute("autocomplete", "new-password");
+ div.setAttribute("autocorrect", "off");
+ div.setAttribute("autocapitalize", "off");
+ div.setAttribute("spellcheck", "false");
+ div.setAttribute("inputmode", "text");
+ div.setAttribute("aria-hidden", "true");
+ div.setAttribute("data-1p-ignore", "true");
+ div.setAttribute("data-lpignore", "true");
+ div.setAttribute("data-bwignore", "true");
+ div.setAttribute("data-form-type", "other");
+ div.setAttribute("data-dashlane-ignore", "true");
+ div.tabIndex = -1;
+ // Kept 1x1 transparent rather than hidden: display:none / visibility:hidden
+ // would dismiss the OS keyboard.
+ div.style.cssText =
+ "position:fixed;top:0;left:0;" +
+ "width:1px;height:1px;" +
+ "opacity:0;pointer-events:none;" +
+ "border:0;padding:0;margin:0;" +
+ "background:transparent;color:transparent;" +
+ "caret-color:transparent;outline:none;" +
+ "white-space:pre;overflow:hidden;" +
+ "user-select:text;-webkit-user-select:text;";
+ captureShadow.appendChild(div);
+ state.element = div;
+
+ div.addEventListener("focus", () => { state.isFocused = true; });
+ div.addEventListener("blur", () => { state.isFocused = false; });
+
+ const setCaretToMiddle = () => {
+ try {
+ const pos = Math.min(CARET_OFFSET, div.value.length);
+ div.setSelectionRange(pos, pos);
+ } catch (_) {
+ }
+ };
+
+ const resetBaseline = () => {
+ div.value = BASELINE;
+ setCaretToMiddle();
+ };
+
+ resetBaseline();
+
+ // A single backspace tap fires two deleteContentBackward events ~5 ms apart;
+ // this dedupe window merges them into one \x7f.
+ let lastCh = null;
+ let lastChAt = 0;
+ const DEDUPE_MS = 8;
+ const dispatchCh = (ch) => {
+ const now = performance.now();
+ if (ch === lastCh && now - lastChAt < DEDUPE_MS) {
+ return;
+ }
+ lastCh = ch;
+ lastChAt = now;
+ state.sendFn(ch);
+ };
+
+ const diff = (a, b) => {
+ const minLen = Math.min(a.length, b.length);
+ let prefixLen = 0;
+ while (prefixLen < minLen && a[prefixLen] === b[prefixLen]) {
+ prefixLen++;
+ }
+ let suffixLen = 0;
+ const maxSuffix = minLen - prefixLen;
+ while (
+ suffixLen < maxSuffix &&
+ a[a.length - 1 - suffixLen] === b[b.length - 1 - suffixLen]
+ ) {
+ suffixLen++;
+ }
+ const deletedCount = a.length - prefixLen - suffixLen;
+ let inserted = b.slice(prefixLen, b.length - suffixLen);
+ if (inserted.indexOf(PADDING_CHAR) !== -1) {
+ inserted = inserted.split(PADDING_CHAR).join("");
+ }
+ return { deletedCount, inserted };
+ };
+
+ let lastText = BASELINE;
+
+ div.addEventListener("input", (ev) => {
+ const current = div.value;
+ if (current === lastText) {
+ return;
+ }
+ const { deletedCount, inserted } = diff(lastText, current);
+ for (let i = 0; i < deletedCount; i++) {
+ dispatchCh("\x7f");
+ }
+ for (const ch of inserted) {
+ dispatchCh(ch);
+ }
+ lastText = current;
+
+ if (ev.inputType === "deleteContentBackward") {
+ div.value = BASELINE;
+ lastText = BASELINE;
+ setCaretToMiddle();
+ }
+ });
+
+ div.addEventListener("keydown", (ev) => {
+ switch (ev.key) {
+ case "Enter":
+ ev.preventDefault();
+ state.sendFn("\r");
+ div.value = BASELINE;
+ lastText = BASELINE;
+ setCaretToMiddle();
+ return;
+ case "Tab":
+ ev.preventDefault();
+ state.sendFn("\t");
+ return;
+ case "Escape":
+ ev.preventDefault();
+ state.sendFn("\x1b");
+ return;
+ case "ArrowUp":
+ ev.preventDefault();
+ state.sendFn("\x1b[A");
+ return;
+ case "ArrowDown":
+ ev.preventDefault();
+ state.sendFn("\x1b[B");
+ return;
+ case "ArrowRight":
+ ev.preventDefault();
+ state.sendFn("\x1b[C");
+ return;
+ case "ArrowLeft":
+ ev.preventDefault();
+ state.sendFn("\x1b[D");
+ return;
+ }
+ });
+
+ // Mobile browsers honor programmatic focus() only inside a user gesture, so
+ // re-focus the capture on every gesture to keep the OS keyboard summoned.
+ const ensureCaptureFocused = () => {
+ if (!window.__zjSoftKbdEnabled) {
+ return;
+ }
+ if (state.isFocused) {
+ div.blur();
+ }
+ try {
+ div.focus({ preventScroll: true });
+ } catch (_) {
+ div.focus();
+ }
+ };
+ window.addEventListener("click", ensureCaptureFocused, { passive: true });
+ window.addEventListener("touchend", ensureCaptureFocused, { passive: true });
+ window.addEventListener("pointerdown", ensureCaptureFocused, {
+ capture: true,
+ passive: true,
+ });
+}
+
+// inputmode="none" stops the OS keyboard auto-popping on tap while the textarea
+// still processes hardware keypresses.
+export function suppressSoftKeyboardOnTouch(term) {
+ if (window.__zjSoftKbdSuppressed) {
+ return;
+ }
+ if (!isCoarsePointerDevice()) {
+ return;
+ }
+ window.__zjSoftKbdSuppressed = true;
+ window.__zjSoftKbdEnabled = true;
+ const apply = () => {
+ const ta = term && term._core && term._core.textarea;
+ if (!ta) {
+ setTimeout(apply, 50);
+ return;
+ }
+ ta.setAttribute("inputmode", "none");
+ };
+ apply();
+}
+
+export function setSoftKeyboard(term, on) {
+ if (!isCoarsePointerDevice()) {
+ return;
+ }
+ const ta = term && term._core && term._core.textarea;
+ if (!ta) {
+ return;
+ }
+ if (window.__zjSoftKbdEnabled === on) {
+ return;
+ }
+ window.__zjSoftKbdEnabled = on;
+ const capture = window.__zjSoftKbdCapture && window.__zjSoftKbdCapture.element;
+ if (on) {
+ if (capture) {
+ capture.focus();
+ } else {
+ ta.removeAttribute("inputmode");
+ ta.focus();
+ }
+ } else {
+ if (capture) {
+ capture.blur();
+ }
+ ta.setAttribute("inputmode", "none");
+ ta.focus();
+ }
+}
+
+export function toggleSoftKeyboard(term) {
+ setSoftKeyboard(term, !window.__zjSoftKbdEnabled);
+}
diff --git a/zellij-client/assets/style.css b/zellij-client/assets/style.css
index f267d0a8..f25084ab 100644
--- a/zellij-client/assets/style.css
+++ b/zellij-client/assets/style.css
@@ -1,14 +1,33 @@
:root {
+ /* `100vh` is the fallback for browsers without `dvh` support. On
+ iOS Safari this is the *layout* viewport — it includes the
+ URL bar height, so the terminal element would be taller than
+ the visible canvas and xterm.js's grid would be initialised
+ too tall (the keyboard would render below the visible area).
+ The `@supports` block below upgrades to `100dvh` (dynamic
+ viewport) on every modern browser, which automatically tracks
+ URL-bar / soft-keyboard occlusion. The runtime
+ `updateViewportVars` handler still overrides `--dynamic-vh`
+ from `visualViewport.height` when pinch / visualViewport
+ resize events fire. */
--dynamic-vh: 100vh;
--dynamic-vw: 100vw;
}
+@supports (height: 100dvh) {
+ :root {
+ --dynamic-vh: 100dvh;
+ --dynamic-vw: 100dvw;
+ }
+}
+
html,
body {
height: var(--dynamic-vh, 100vh);
width: var(--dynamic-vw, 100vw);
margin: 0;
overflow: hidden;
+ background: #000; /* TODO: this should be embedded as the user's theme background */
}
#terminal {
diff --git a/zellij-client/assets/terminal.js b/zellij-client/assets/terminal.js
index bdaf637e..0607d00c 100644
--- a/zellij-client/assets/terminal.js
+++ b/zellij-client/assets/terminal.js
@@ -1,20 +1,29 @@
-/**
- * Terminal initialization and management
- */
-
import { build_link_handler } from "./links.js";
-/**
- * Initialize the terminal with all required addons and configuration
- * @returns {object} Object containing term and fitAddon instances
- */
+// drawImage on a composited WebGL canvas returns blank pixels unless
+// preserveDrawingBuffer is set (needed by the pinch overlay snapshot); this must
+// run before xterm.js's WebglAddon creates its context.
+function ensurePreserveDrawingBuffer() {
+ if (window.__zjPreserveDrawingBuffer) return;
+ window.__zjPreserveDrawingBuffer = true;
+ const orig = HTMLCanvasElement.prototype.getContext;
+ HTMLCanvasElement.prototype.getContext = function (type, options) {
+ if (type === "webgl" || type === "webgl2") {
+ options = Object.assign({}, options || {}, {
+ preserveDrawingBuffer: true,
+ });
+ }
+ return orig.call(this, type, options);
+ };
+}
+
export function initTerminal() {
+ ensurePreserveDrawingBuffer();
const term = new Terminal({
fontFamily: "Monospace",
allowProposedApi: true,
scrollback: 0,
});
- // for debugging
window.term = term;
const fitAddon = new FitAddon.FitAddon();
const clipboardAddon = new ClipboardAddon.ClipboardAddon();
@@ -31,7 +40,6 @@ export function initTerminal() {
term.loadAddon(clipboardAddon);
term.loadAddon(webLinksAddon);
webglAddon.onContextLoss((e) => {
- // TODO: reload, or?
webglAddon.dispose();
});
term.loadAddon(webglAddon);
@@ -40,3 +48,28 @@ export function initTerminal() {
term.focus();
return { term, fitAddon };
}
+
+export const MIN_FONT_SIZE_PX = 6;
+export const MAX_FONT_SIZE_PX = 96;
+
+export function applyFontSize(term, fitAddon, requestedPx) {
+ const requested =
+ typeof requestedPx === "number" && requestedPx > 0
+ ? requestedPx
+ : term.options.fontSize || 12;
+ const effective = clampFontSize(requested);
+ if (term.options.fontSize !== effective) {
+ term.options.fontSize = effective;
+ }
+ try {
+ fitAddon.fit();
+ } catch (e) {
+ }
+}
+
+export function clampFontSize(px) {
+ const n = Math.round(Number(px) || 0);
+ if (n < MIN_FONT_SIZE_PX) return MIN_FONT_SIZE_PX;
+ if (n > MAX_FONT_SIZE_PX) return MAX_FONT_SIZE_PX;
+ return n;
+}
diff --git a/zellij-client/assets/touch.js b/zellij-client/assets/touch.js
new file mode 100644
index 00000000..7cec67dc
--- /dev/null
+++ b/zellij-client/assets/touch.js
@@ -0,0 +1,266 @@
+import { clampFontSize } from "./terminal.js";
+import { toggleSoftKeyboard } from "./soft-keyboard.js";
+
+const SGR_COORD_BASE = 1;
+export function installTouchGestures({ term, terminalElement, sendFunction, pinch }) {
+ let last_touch_y = null;
+ let last_touch_x = null;
+ let pending_scroll = 0;
+ let pending_h_scroll = 0;
+ let touch_origin = null;
+ let touch_moved = false;
+ let long_press_fired = false;
+ let long_press_timer = null;
+ let two_finger_gesture_t = null;
+ let pinch_initial_distance = null;
+ let pinch_initial_font_size = null;
+ let pinch_active = false;
+
+ const touch_scroll_threshold = 24;
+ const click_move_threshold = 16;
+ const long_press_duration_ms = 500;
+ const two_finger_tap_max_ms = 600;
+ const pinch_activation_threshold = 18;
+
+ const reportCoords = (clientX, clientY) =>
+ term._core._mouseService.getMouseReportCoords(
+ { clientX, clientY },
+ terminalElement
+ );
+
+ const cancelLongPress = () => {
+ if (long_press_timer !== null) {
+ clearTimeout(long_press_timer);
+ long_press_timer = null;
+ }
+ };
+
+ const sendSgrButton = (button, col, row) => {
+ const sgrCol = col + SGR_COORD_BASE;
+ const sgrRow = row + SGR_COORD_BASE;
+ sendFunction(`\x1b[<${button};${sgrCol};${sgrRow}M`);
+ sendFunction(`\x1b[<${button};${sgrCol};${sgrRow}m`);
+ };
+
+ const sendWheelEvent = (direction, touch) => {
+ const { col, row } = reportCoords(touch.clientX, touch.clientY);
+ const swipedUp = direction < 0;
+ const button = swipedUp ? 65 : 64;
+ sendFunction(`\x1b[<${button};${col + SGR_COORD_BASE};${row + SGR_COORD_BASE}M`);
+ };
+
+ // Scroll direction follows the content, not the finger: swiping the content
+ // leftward reveals the right edge, so finger-left maps to wheel-right.
+ const sendHorizontalWheelEvent = (direction, touch) => {
+ const { col, row } = reportCoords(touch.clientX, touch.clientY);
+ const button = direction < 0 ? 66 : 67;
+ sendFunction(`\x1b[<${button};${col + SGR_COORD_BASE};${row + SGR_COORD_BASE}M`);
+ };
+
+ const touchPairDistance = (touches) => {
+ if (touches.length < 2) {
+ return 0;
+ }
+ const dx = touches[0].clientX - touches[1].clientX;
+ const dy = touches[0].clientY - touches[1].clientY;
+ return Math.hypot(dx, dy);
+ };
+
+ terminalElement.addEventListener(
+ "touchstart",
+ (event) => {
+ if (event.touches.length === 2 && two_finger_gesture_t === null) {
+ event.preventDefault();
+ two_finger_gesture_t = performance.now();
+ cancelLongPress();
+ touch_origin = null;
+ touch_moved = false;
+ long_press_fired = false;
+ pinch_initial_distance = touchPairDistance(event.touches);
+ pinch_initial_font_size = clampFontSize(
+ term.options.fontSize || 16
+ );
+ pinch_active = false;
+ return;
+ }
+ if (two_finger_gesture_t !== null) {
+ event.preventDefault();
+ return;
+ }
+ if (event.touches.length > 0) {
+ // iOS/Android honor focus() as a keyboard summon only inside a
+ // still-active, not-yet-prevented gesture, so this must precede
+ // the preventDefault below.
+ if (
+ window.__zjSoftKbdEnabled &&
+ window.__zjSoftKbdCapture &&
+ window.__zjSoftKbdCapture.element &&
+ !window.__zjSoftKbdCapture.isFocused
+ ) {
+ try {
+ window.__zjSoftKbdCapture.element.focus({
+ preventScroll: true,
+ });
+ } catch (_) {
+ window.__zjSoftKbdCapture.element.focus();
+ }
+ }
+ // preventDefault on touchstart cancels the synthetic mouse cascade
+ // browsers fire ~300 ms later, which would otherwise make xterm.js
+ // send a second SGR click for the same gesture.
+ event.preventDefault();
+ const touch = event.touches[0];
+ last_touch_y = touch.clientY;
+ last_touch_x = touch.clientX;
+ pending_scroll = 0;
+ pending_h_scroll = 0;
+ // Capture cell coords now: on iOS the soft keyboard sliding up
+ // between touchstart and touchend re-fits the grid.
+ const { col, row } = reportCoords(touch.clientX, touch.clientY);
+ touch_origin = {
+ x: touch.clientX,
+ y: touch.clientY,
+ col,
+ row,
+ t: performance.now(),
+ };
+ touch_moved = false;
+ long_press_fired = false;
+ cancelLongPress();
+ long_press_timer = setTimeout(() => {
+ long_press_timer = null;
+ if (touch_origin === null || touch_moved) {
+ return;
+ }
+ long_press_fired = true;
+ sendSgrButton(2, touch_origin.col, touch_origin.row);
+ if (typeof navigator.vibrate === "function") {
+ navigator.vibrate(10);
+ }
+ }, long_press_duration_ms);
+ }
+ },
+ { passive: false }
+ );
+
+ terminalElement.addEventListener(
+ "touchmove",
+ (event) => {
+ if (
+ event.touches.length === 2 &&
+ pinch_initial_distance !== null &&
+ pinch_initial_distance > 0
+ ) {
+ event.preventDefault();
+ const dist = touchPairDistance(event.touches);
+ if (
+ !pinch_active &&
+ Math.abs(dist - pinch_initial_distance) >
+ pinch_activation_threshold
+ ) {
+ pinch_active = true;
+ pinch.snapshot();
+ }
+ if (pinch_active) {
+ const ratio = dist / pinch_initial_distance;
+ pinch.applyFontSize(pinch_initial_font_size * ratio);
+ }
+ return;
+ }
+
+ if (event.touches.length === 0 || last_touch_y === null) {
+ return;
+ }
+ event.preventDefault();
+ const touch = event.touches[0];
+
+ if (touch_origin !== null && !touch_moved) {
+ const dx = touch.clientX - touch_origin.x;
+ const dy = touch.clientY - touch_origin.y;
+ if (Math.hypot(dx, dy) > click_move_threshold) {
+ touch_moved = true;
+ cancelLongPress();
+ }
+ }
+
+ const delta_y = touch.clientY - last_touch_y;
+ const delta_x =
+ last_touch_x === null ? 0 : touch.clientX - last_touch_x;
+ last_touch_y = touch.clientY;
+ last_touch_x = touch.clientX;
+ pending_scroll += delta_y;
+ pending_h_scroll += delta_x;
+ while (pending_scroll <= -touch_scroll_threshold) {
+ sendWheelEvent(-1, touch);
+ pending_scroll += touch_scroll_threshold;
+ }
+ while (pending_scroll >= touch_scroll_threshold) {
+ sendWheelEvent(1, touch);
+ pending_scroll -= touch_scroll_threshold;
+ }
+ while (pending_h_scroll <= -touch_scroll_threshold) {
+ sendHorizontalWheelEvent(-1, touch);
+ pending_h_scroll += touch_scroll_threshold;
+ }
+ while (pending_h_scroll >= touch_scroll_threshold) {
+ sendHorizontalWheelEvent(1, touch);
+ pending_h_scroll -= touch_scroll_threshold;
+ }
+ },
+ { passive: false }
+ );
+
+ terminalElement.addEventListener(
+ "touchend",
+ (event) => {
+ if (two_finger_gesture_t !== null && event.touches.length === 0) {
+ const elapsed = performance.now() - two_finger_gesture_t;
+ const wasPinch = pinch_active;
+ two_finger_gesture_t = null;
+ pinch_active = false;
+ pinch_initial_distance = null;
+ pinch_initial_font_size = null;
+ if (wasPinch) {
+ pinch.armRemoval();
+ return;
+ }
+ if (elapsed < two_finger_tap_max_ms) {
+ toggleSoftKeyboard(term);
+ }
+ return;
+ }
+ cancelLongPress();
+ if (touch_origin !== null && !touch_moved && !long_press_fired) {
+ sendSgrButton(0, touch_origin.col, touch_origin.row);
+ }
+ last_touch_y = null;
+ last_touch_x = null;
+ pending_scroll = 0;
+ pending_h_scroll = 0;
+ touch_origin = null;
+ touch_moved = false;
+ long_press_fired = false;
+ },
+ { passive: true }
+ );
+
+ terminalElement.addEventListener(
+ "touchcancel",
+ () => {
+ cancelLongPress();
+ last_touch_y = null;
+ last_touch_x = null;
+ pending_scroll = 0;
+ pending_h_scroll = 0;
+ touch_origin = null;
+ touch_moved = false;
+ long_press_fired = false;
+ two_finger_gesture_t = null;
+ pinch_initial_distance = null;
+ pinch_initial_font_size = null;
+ pinch_active = false;
+ pinch.destroy();
+ },
+ { passive: true }
+ );
+}
diff --git a/zellij-client/assets/websockets.js b/zellij-client/assets/websockets.js
index d6e98029..f7e2933d 100644
--- a/zellij-client/assets/websockets.js
+++ b/zellij-client/assets/websockets.js
@@ -1,17 +1,11 @@
-/**
- * WebSocket management for terminal and control connections
- */
-
import { handleReconnection, handleDisconnected, markConnectionEstablished } from "./connection.js";
import { getBaseUrl, getWebSocketBaseUrl } from "./utils.js";
+import { setSoftKeyboard } from "./input.js";
+import { applyFontSize } from "./terminal.js";
+
+const NATURAL_MIN_TOTAL_ROWS = 25;
+const MOBILE_LEGIBLE_FLOOR_PX = 16;
-/**
- * Read cell pixel dimensions from xterm.js. Tries the internal
- * _renderService first (matches what the vendored FitAddon uses) and
- * falls back to a DOM measurement of .xterm-char-measure-element — a
- * hidden helper element xterm.js creates explicitly for character
- * measurement. Returns null if neither path yields usable numbers.
- */
function getCellPixelDimensions(term) {
try {
const cell =
@@ -34,28 +28,21 @@ function getCellPixelDimensions(term) {
return null;
}
-/**
- * Send both control messages that describe the client's display state
- * to the Zellij server: TerminalResize (grid rows/cols) and
- * TerminalMetrics (pixel dimensions used to answer host-terminal
- * queries such as CSI 14t / 16t and OSC 11;?).
- *
- * Single chokepoint so the protocol contract lives in one place — any
- * site that updates terminal size or theme must call this helper, and
- * any future field added to the protocol is added here once. The
- * server's TerminalResize handler is idempotent, so calling this even
- * when the grid hasn't changed (e.g. after a theme reload that only
- * shifts font metrics) is safe.
- */
-function sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols) {
+function sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols, cause) {
if (!wsControl || !ownWebClientId) {
return;
}
+ const resizeType =
+ cause === "RenderingPreference"
+ ? "TerminalResizeRendering"
+ : cause === "Settled"
+ ? "TerminalSizeSettled"
+ : "TerminalResize";
wsControl.send(
JSON.stringify({
web_client_id: ownWebClientId,
payload: {
- type: "TerminalResize",
+ type: resizeType,
rows,
cols,
},
@@ -79,15 +66,6 @@ function sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols) {
);
}
-/**
- * Initialize both terminal and control WebSocket connections
- * @param {string} webClientId - Client ID from authentication
- * @param {string} sessionName - Session name from URL
- * @param {Terminal} term - Terminal instance
- * @param {FitAddon} fitAddon - Terminal fit addon
- * @param {function} sendAnsiKey - Function to send ANSI key sequences
- * @returns {object} Object containing WebSocket instances and cleanup function
- */
export function initWebSockets(
webClientId,
sessionName,
@@ -177,7 +155,6 @@ export function initWebSockets(
}
};
- // Update sendAnsiKey to use the actual WebSocket
const originalSendAnsiKey = sendAnsiKey;
sendAnsiKey = (ansiKey) => {
if (ownWebClientId !== "") {
@@ -185,7 +162,6 @@ export function initWebSockets(
}
};
- // Setup resize handler
setupResizeHandler(
term,
fitAddon,
@@ -209,13 +185,6 @@ export function initWebSockets(
};
}
-/**
- * Start the control WebSocket and set up its handlers
- * @param {WebSocket} wsControl - Control WebSocket instance
- * @param {Terminal} term - Terminal instance
- * @param {FitAddon} fitAddon - Terminal fit addon
- * @param {string} ownWebClientId - Own web client ID
- */
function startWsControl(wsControl, term, fitAddon, ownWebClientId, userConfig) {
wsControl.onopen = function (event) {
const fitDimensions = fitAddon.proposeDimensions();
@@ -233,6 +202,7 @@ function startWsControl(wsControl, term, fitAddon, ownWebClientId, userConfig) {
mac_option_is_meta,
cursor_style,
cursor_inactive_style,
+ font_size,
} = msg;
term.options.fontFamily = font;
term.options.theme = theme;
@@ -250,27 +220,51 @@ function startWsControl(wsControl, term, fitAddon, ownWebClientId, userConfig) {
if (cursor_inactive_style !== "undefined") {
term.options.cursorInactiveStyle = cursor_inactive_style;
}
+ if (typeof window.__zjSyncInactiveCursorStyle === "function") {
+ window.__zjSyncInactiveCursorStyle();
+ }
+ const isMobileViewport =
+ (window.matchMedia &&
+ window.matchMedia("(pointer: coarse)").matches &&
+ window.innerWidth < 600) ||
+ /Mobi|Android|iPhone|iPad/i.test(navigator.userAgent);
+ const hasExplicitFontSize =
+ typeof font_size === "number" && font_size > 0;
+ const baseFontPx = hasExplicitFontSize
+ ? font_size
+ : isMobileViewport
+ ? 24
+ : 12;
+ applyFontSize(term, fitAddon, baseFontPx);
+ const needsMobileDownscale =
+ !hasExplicitFontSize &&
+ isMobileViewport &&
+ term.rows < NATURAL_MIN_TOTAL_ROWS;
+ if (needsMobileDownscale) {
+ const downscaledPx = Math.max(
+ Math.floor(
+ (baseFontPx * term.rows) / NATURAL_MIN_TOTAL_ROWS
+ ),
+ MOBILE_LEGIBLE_FLOOR_PX
+ );
+ if (downscaledPx < baseFontPx) {
+ applyFontSize(term, fitAddon, downscaledPx);
+ }
+ }
const body = document.querySelector("body");
body.style.background = theme.background || "black";
const terminal = document.getElementById("terminal");
terminal.style.background = theme.background;
- const fitDimensions = fitAddon.proposeDimensions();
- if (fitDimensions === undefined) {
- console.warn("failed to get new fit dimensions");
- return;
- }
-
- const { rows, cols } = fitDimensions;
- if (rows !== term.rows || cols !== term.cols) {
- term.resize(cols, rows);
- }
- // Always emit a size update on SetConfig: even if the grid
- // didn't change, font metrics may have shifted and the
- // pixel-cell measurements in TerminalMetrics need to
- // refresh so host-terminal queries get accurate values.
- sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols);
+ sendSizeUpdate(
+ wsControl,
+ ownWebClientId,
+ term,
+ term.rows,
+ term.cols,
+ "Settled"
+ );
} else if (msg.type === "QueryTerminalSize") {
const fitDimensions = fitAddon.proposeDimensions();
const { rows, cols } = fitDimensions;
@@ -292,6 +286,9 @@ function startWsControl(wsControl, term, fitAddon, ownWebClientId, userConfig) {
const { new_session_name } = msg;
const baseUrl = getBaseUrl();
window.location.href = `${baseUrl}/${encodeURIComponent(new_session_name)}`;
+ } else if (msg.type === "SetSoftKeyboard") {
+ const { on } = msg;
+ setSoftKeyboard(term, !!on);
}
};
@@ -304,13 +301,6 @@ function startWsControl(wsControl, term, fitAddon, ownWebClientId, userConfig) {
};
}
-/**
- * Set up window resize event handler
- * @param {Terminal} term - Terminal instance
- * @param {FitAddon} fitAddon - Terminal fit addon
- * @param {function} getWsControl - Function that returns control WebSocket
- * @param {function} getOwnWebClientId - Function that returns own web client ID
- */
export function setupResizeHandler(
term,
fitAddon,
@@ -318,6 +308,8 @@ export function setupResizeHandler(
getOwnWebClientId
) {
let resizeScheduled = false;
+ let pendingViewportSignal = false;
+ let pendingRenderingSignal = false;
const updateViewportVars = () => {
const root = document.documentElement;
@@ -328,7 +320,7 @@ export function setupResizeHandler(
root.style.setProperty("--dynamic-vw", `${width}px`);
};
- const resizeTerminal = () => {
+ const resizeTerminal = (cause) => {
const ownWebClientId = getOwnWebClientId();
if (ownWebClientId === "") {
return;
@@ -345,31 +337,102 @@ export function setupResizeHandler(
return;
}
+ const wsControl = getWsControl();
term.resize(cols, rows);
- const wsControl = getWsControl();
- sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols);
+ sendSizeUpdate(wsControl, ownWebClientId, term, rows, cols, cause);
};
- const handleViewportChange = () => {
+ const handleViewportChange = (cause) => {
updateViewportVars();
- resizeTerminal();
+ resizeTerminal(cause);
};
- const scheduleResize = () => {
+ const scheduleResize = (cause) => {
+ if (cause === "RenderingPreference") {
+ pendingRenderingSignal = true;
+ } else {
+ pendingViewportSignal = true;
+ }
if (resizeScheduled) {
return;
}
resizeScheduled = true;
requestAnimationFrame(() => {
+ const tickCause =
+ pendingRenderingSignal && !pendingViewportSignal
+ ? "RenderingPreference"
+ : "Viewport";
+ pendingViewportSignal = false;
+ pendingRenderingSignal = false;
resizeScheduled = false;
- handleViewportChange();
+ handleViewportChange(tickCause);
});
};
+ const scheduleViewportResize = () => scheduleResize("Viewport");
+ const scheduleRenderingResize = () => scheduleResize("RenderingPreference");
+
updateViewportVars();
- addEventListener("resize", scheduleResize);
+ addEventListener("resize", scheduleViewportResize);
if (window.visualViewport) {
- window.visualViewport.addEventListener("resize", scheduleResize);
+ window.visualViewport.addEventListener(
+ "resize",
+ scheduleViewportResize
+ );
}
+ addEventListener("zellij:rendering-resize", scheduleRenderingResize);
+
+ setupSoftKeyboardVisibilityTracker(getWsControl, getOwnWebClientId);
+}
+
+function setupSoftKeyboardVisibilityTracker(getWsControl, getOwnWebClientId) {
+ if (!window.visualViewport) {
+ return;
+ }
+ const VIEWPORT_DELTA_THRESHOLD_PX = 150;
+ let lastViewportHeight = window.visualViewport.height;
+ let kbdVisible = false;
+
+ const onResize = () => {
+ const newHeight = window.visualViewport.height;
+ const delta = newHeight - lastViewportHeight;
+ let newKbdVisible = kbdVisible;
+ if (delta < -VIEWPORT_DELTA_THRESHOLD_PX) {
+ newKbdVisible = true;
+ } else if (delta > VIEWPORT_DELTA_THRESHOLD_PX) {
+ newKbdVisible = false;
+ }
+ lastViewportHeight = newHeight;
+ if (newKbdVisible === kbdVisible) {
+ return;
+ }
+ kbdVisible = newKbdVisible;
+
+ if (!kbdVisible) {
+ const capture =
+ window.__zjSoftKbdCapture &&
+ window.__zjSoftKbdCapture.element;
+ if (capture && window.__zjSoftKbdCapture.isFocused) {
+ capture.blur();
+ }
+ }
+
+ const wsControl = getWsControl();
+ const ownWebClientId = getOwnWebClientId();
+ if (!wsControl || ownWebClientId === "") {
+ return;
+ }
+ wsControl.send(
+ JSON.stringify({
+ web_client_id: ownWebClientId,
+ payload: {
+ type: "SoftKeyboardVisibilityChanged",
+ visible: kbdVisible,
+ },
+ })
+ );
+ };
+
+ window.visualViewport.addEventListener("resize", onResize);
}
diff --git a/zellij-client/src/input_handler.rs b/zellij-client/src/input_handler.rs
index cc4b4d75..45e8d9ce 100644
--- a/zellij-client/src/input_handler.rs
+++ b/zellij-client/src/input_handler.rs
@@ -46,6 +46,10 @@ fn termwiz_mouse_convert(original_event: &mut MouseEvent, event: &TermwizMouseEv
&& button_bits.contains(MouseButtons::WHEEL_POSITIVE);
original_event.wheel_down = button_bits.contains(MouseButtons::VERT_WHEEL)
&& !button_bits.contains(MouseButtons::WHEEL_POSITIVE);
+ original_event.wheel_right = button_bits.contains(MouseButtons::HORZ_WHEEL)
+ && button_bits.contains(MouseButtons::WHEEL_POSITIVE);
+ original_event.wheel_left = button_bits.contains(MouseButtons::HORZ_WHEEL)
+ && !button_bits.contains(MouseButtons::WHEEL_POSITIVE);
let mods = &event.modifiers;
original_event.shift = mods.contains(Modifiers::SHIFT);
@@ -73,6 +77,8 @@ pub fn from_termwiz(old_event: &mut MouseEvent, event: TermwizMouseEvent) -> Mou
|| (new_event.middle && !old_event.middle)
|| new_event.wheel_up
|| new_event.wheel_down
+ || new_event.wheel_left
+ || new_event.wheel_right
{
// This is a mouse Press event.
new_event.event_type = MouseEventType::Press;
diff --git a/zellij-client/src/lib.rs b/zellij-client/src/lib.rs
index a1fcf387..d674e022 100644
--- a/zellij-client/src/lib.rs
+++ b/zellij-client/src/lib.rs
@@ -156,7 +156,7 @@ use zellij_utils::{
envs,
errors::{ClientContext, ContextType, ErrorInstruction},
input::{cli_assets::CliAssets, config::Config, options::Options},
- ipc::{ClientToServerMsg, ExitReason, ServerToClientMsg},
+ ipc::{ClientToServerMsg, ExitReason, ResizeCause, ServerToClientMsg},
pane_size::Size,
vendored::termwiz::input::InputEvent,
};
@@ -214,6 +214,7 @@ impl From<ServerToClientMsg> for ClientInstruction {
// Subscribe-only messages — not handled by regular interactive clients
ServerToClientMsg::PaneRenderUpdate { .. } => ClientInstruction::UnblockInputThread,
ServerToClientMsg::SubscribedPaneClosed { .. } => ClientInstruction::UnblockInputThread,
+ ServerToClientMsg::SetSoftKeyboard { .. } => ClientInstruction::UnblockInputThread,
}
}
}
@@ -596,6 +597,9 @@ pub async fn run_remote_client_terminal_loop(
Ok(WebServerToWebClientControlMessage::SwitchedSession{ .. }) => {
// no-op
}
+ Ok(WebServerToWebClientControlMessage::SetSoftKeyboard{ .. }) => {
+ // no-op
+ }
Err(e) => {
log::error!("Failed to deserialize control message: {}", e);
}
@@ -1051,6 +1055,7 @@ pub fn start_client(
move || {
os_api.send_to_server(ClientToServerMsg::TerminalResize {
new_size: os_api.get_terminal_size(),
+ cause: ResizeCause::Viewport,
});
}
}),
@@ -1192,6 +1197,7 @@ pub fn start_client(
ClientInstruction::QueryTerminalSize => {
os_input.send_to_server(ClientToServerMsg::TerminalResize {
new_size: os_input.get_terminal_size(),
+ cause: ResizeCause::Viewport,
});
},
ClientInstruction::StartWebServer => {
diff --git a/zellij-client/src/unit/terminal_loop_tests.rs b/zellij-client/src/unit/terminal_loop_tests.rs
index 6dfe96bf..f6d3fade 100644
--- a/zellij-client/src/unit/terminal_loop_tests.rs
+++ b/zellij-client/src/unit/terminal_loop_tests.rs
@@ -20,7 +20,6 @@ use zellij_utils::errors::ErrorContext;
use zellij_utils::ipc::{ClientToServerMsg, ServerToClientMsg};
use zellij_utils::pane_size::Size;
-/// Mock stdin that allows tests to inject input data
struct MockAsyncStdin {
rx: Arc<tokio::sync::Mutex<mpsc::UnboundedReceiver<Vec<u8>>>>,
}
@@ -30,12 +29,11 @@ impl AsyncStdin for MockAsyncStdin {
async fn read(&mut self) -> io::Result<Vec<u8>> {
match self.rx.lock().await.recv().await {
Some(data) => Ok(data),
- None => Ok(Vec::new()), // EOF
+ None => Ok(Vec::new()),
}
}
}
-/// Mock signal listener that allows tests to inject signals
struct MockAsyncSignals {
rx: Arc<tokio::sync::Mutex<mpsc::UnboundedReceiver<SignalEvent>>>,
}
@@ -47,7 +45,6 @@ impl AsyncSignals for MockAsyncSignals {
}
}
-/// Mock ClientOsApi for testing
#[derive(Clone)]
struct TestClientOsApi {
stdout_buffer: Arc<Mutex<Vec<u8>>>,
@@ -223,7 +220,6 @@ mod mock_ws_server {
axum::serve(listener, app).await.unwrap();
});
- // Wait for server to start
tokio::time::sleep(Duration::from_millis(100)).await;
let server = MockWsServer {
@@ -257,7 +253,6 @@ mod mock_ws_server {
let recv_task = tokio::spawn(async move {
while let Some(Ok(axum_msg)) = ws_rx.next().await {
- // Convert axum::extract::ws::Message to tokio_tungstenite::tungstenite::Message
let tungstenite_msg = match axum_msg {
axum::extract::ws::Message::Text(text) => Message::Text(text.to_string()),
axum::extract::ws::Message::Binary(data) => Message::Binary(data.to_vec()),
@@ -282,7 +277,6 @@ mod mock_ws_server {
let msg = state.server_rx.lock().unwrap().try_recv();
match msg {
Ok(tungstenite_msg) => {
- // Convert tokio_tungstenite::tungstenite::Message to axum::extract::ws::Message
let axum_msg = match tungstenite_msg {
Message::Text(text) => axum::extract::ws::Message::Text(text.into()),
Message::Binary(data) => axum::extract::ws::Message::Binary(data.into()),
@@ -300,7 +294,7 @@ mod mock_ws_server {
axum::extract::ws::Message::Close(None)
}
},
- Message::Frame(_) => continue, // Skip raw frames
+ Message::Frame(_) => continue,
};
if ws_tx.send(axum_msg).await.is_err() {
break;
@@ -319,10 +313,8 @@ mod mock_ws_server {
#[tokio::test]
#[serial]
async fn test_stdin_forwarded_to_terminal_websocket() {
- // Setup mock WebSocket server
let (port, server, _server_handle) = mock_ws_server::MockWsServer::start().await;
- // Create WebSocket connections
let terminal_url = format!("ws://127.0.0.1:{}/ws/terminal", port);
let control_url = format!("ws://127.0.0.1:{}/ws/control", port);
@@ -353,21 +345,17 @@ async fn test_stdin_forwarded_to_terminal_websocket() {
web_client_id: "test-stdin".to_string(),
};
- // Create mock OS API with controllable stdin
let (stdin_tx, stdin_rx) = mpsc::unbounded_channel();
let (_signal_tx, signal_rx) = mpsc::unbounded_channel();
let os_input = Box::new(TestClientOsApi::new(stdin_rx, signal_rx));
- // Spawn the async loop
let loop_handle =
tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await });
- // Send stdin data
let test_data = b"hello from stdin\n".to_vec();
stdin_tx.send(test_data.clone()).unwrap();
- // Verify terminal WebSocket received the data
tokio::time::sleep(Duration::from_millis(200)).await;
let received = tokio::time::timeout(
Duration::from_secs(1),
@@ -382,7 +370,6 @@ async fn test_stdin_forwarded_to_terminal_websocket() {
_ => panic!("Expected Binary message, got: {:?}", received),
}
- // Cleanup: send EOF via stdin
drop(stdin_tx);
let _ = tokio::time::timeout(Duration::from_secs(2), loop_handle)
.await
@@ -435,7 +422,6 @@ async fn test_terminal_output_written_to_stdout() {
let loop_handle =
tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await });
- // Send terminal output from server
let test_output = "Hello from terminal";
server
.terminal_to_client_tx
@@ -444,7 +430,6 @@ async fn test_terminal_output_written_to_stdout() {
tokio::time::sleep(Duration::from_millis(200)).await;
- // Verify stdout received the output
let stdout = stdout_buffer.lock().unwrap().clone();
let stdout_str = String::from_utf8_lossy(&stdout);
assert!(
@@ -454,7 +439,6 @@ async fn test_terminal_output_written_to_stdout() {
stdout_str
);
- // Cleanup
server
.terminal_to_client_tx
.send(Message::Close(None))
@@ -508,22 +492,18 @@ async fn test_resize_signal_sends_control_message() {
let loop_handle =
tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await });
- // Wait for initial resize message to be sent on startup
tokio::time::sleep(Duration::from_millis(200)).await;
- // Consume the initial resize message
let _ = tokio::time::timeout(
Duration::from_millis(500),
server.client_to_control_rx.lock().unwrap().recv(),
)
.await;
- // Send resize signal
signal_tx.send(SignalEvent::Resize).unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
- // Verify control WebSocket received resize message
let received = tokio::time::timeout(
Duration::from_secs(1),
server.client_to_control_rx.lock().unwrap().recv(),
@@ -548,7 +528,6 @@ async fn test_resize_signal_sends_control_message() {
_ => panic!("Expected Text message, got: {:?}", received),
}
- // Cleanup
signal_tx.send(SignalEvent::Quit).unwrap();
let _ = tokio::time::timeout(Duration::from_secs(2), loop_handle)
.await
@@ -599,10 +578,8 @@ async fn test_quit_signal_exits_loop() {
let loop_handle =
tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await });
- // Send quit signal
signal_tx.send(SignalEvent::Quit).unwrap();
- // Verify loop exits cleanly
let result = tokio::time::timeout(Duration::from_secs(2), loop_handle)
.await
.expect("Loop didn't exit within timeout")
@@ -654,13 +631,11 @@ async fn test_websocket_close_exits_loop() {
let loop_handle =
tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await });
- // Send close message
server
.terminal_to_client_tx
.send(Message::Close(None))
.unwrap();
- // Verify loop exits cleanly
let result = tokio::time::timeout(Duration::from_secs(2), loop_handle)
.await
.expect("Loop didn't exit within timeout")
@@ -714,17 +689,14 @@ async fn test_control_message_handling() {
let loop_handle =
tokio::spawn(async move { run_remote_client_terminal_loop(os_input, connections).await });
- // Wait for initial resize message to be sent on startup
tokio::time::sleep(Duration::from_millis(200)).await;
- // Consume the initial resize message
let _ = tokio::time::timeout(
Duration::from_millis(500),
server.client_to_control_rx.lock().unwrap().recv(),
)
.await;
- // Send QueryTerminalSize control message
let query_msg = WebServerToWebClientControlMessage::QueryTerminalSize;
server
.control_to_client_tx
@@ -733,7 +705,6 @@ async fn test_control_message_handling() {
tokio::time::sleep(Duration::from_millis(200)).await;
- // Verify we receive a resize response
let received = tokio::time::timeout(
Duration::from_secs(1),
server.client_to_control_rx.lock().unwrap().recv(),
@@ -746,16 +717,16 @@ async fn test_control_message_handling() {
Message::Text(text) => {
let parsed: WebClientToWebServerControlMessage =
serde_json::from_str(&text).expect("Failed to parse");
- let WebClientToWebServerControlMessagePayload::TerminalResize(size) = parsed.payload
- else {
- panic!("Expected TerminalResize, got: {:?}", parsed.payload);
+ let size = match parsed.payload {
+ WebClientToWebServerControlMessagePayload::TerminalResize(size) => size,
+ WebClientToWebServerControlMessagePayload::TerminalResizeRendering(size) => size,
+ other => panic!("Expected TerminalResize, got: {:?}", other),
};
assert_eq!(size, terminal_size);
},
_ => panic!("Expected Text message, got: {:?}", received),
}
- // Test Log message (should not crash)
let log_msg = WebServerToWebClientControlMessage::Log {
lines: vec!["Test log".to_string()],
};
@@ -766,7 +737,6 @@ async fn test_control_message_handling() {
tokio::time::sleep(Duration::from_millis(100)).await;
- // Cleanup
server
.terminal_to_client_tx
.send(Message::Close(None))
diff --git a/zellij-client/src/web_client/control_message.rs b/zellij-client/src/web_client/control_message.rs
index e6c46444..8ab333f9 100644
--- a/zellij-client/src/web_client/control_message.rs
+++ b/zellij-client/src/web_client/control_message.rs
@@ -12,7 +12,10 @@ pub struct WebClientToWebServerControlMessage {
#[serde(tag = "type")]
pub enum WebClientToWebServerControlMessagePayload {
TerminalResize(Size),
+ TerminalResizeRendering(Size),
+ TerminalSizeSettled(Size),
TerminalMetrics(TerminalMetricsPayload),
+ SoftKeyboardVisibilityChanged { visible: bool },
}
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -31,6 +34,7 @@ pub enum WebServerToWebClientControlMessage {
Log { lines: Vec<String> },
LogError { lines: Vec<String> },
SwitchedSession { new_session_name: String },
+ SetSoftKeyboard { on: bool },
}
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -43,6 +47,8 @@ pub struct SetConfigPayload {
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor_style: Option<String>,
pub mac_option_is_meta: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub font_size: Option<u16>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
@@ -159,6 +165,8 @@ impl From<&Config> for SetConfigPayload {
.as_ref()
.map(|s| s.to_string());
+ let font_size = config.web_client.font_size;
+
SetConfigPayload {
font,
theme,
@@ -166,6 +174,7 @@ impl From<&Config> for SetConfigPayload {
mac_option_is_meta,
cursor_style,
cursor_inactive_style,
+ font_size,
}
}
}
diff --git a/zellij-client/src/web_client/server_listener.rs b/zellij-client/src/web_client/server_listener.rs
index a3b419ef..9daa8fa8 100644
--- a/zellij-client/src/web_client/server_listener.rs
+++ b/zellij-client/src/web_client/server_listener.rs
@@ -178,6 +178,11 @@ pub fn zellij_server_listener(
WebServerToWebClientControlMessage::QueryTerminalSize,
);
},
+ Some(ServerToClientMsg::SetSoftKeyboard{on}) => {
+ client_connection_bus.send_control(
+ WebServerToWebClientControlMessage::SetSoftKeyboard { on },
+ );
+ },
Some(ServerToClientMsg::Log{lines}) => {
client_connection_bus.send_control(
WebServerToWebClientControlMessage::Log { lines },
diff --git a/zellij-client/src/web_client/unit/web_client_tests.rs b/zellij-client/src/web_client/unit/web_client_tests.rs
index 726e73a6..28c2e87c 100644
--- a/zellij-client/src/web_client/unit/web_client_tests.rs
+++ b/zellij-client/src/web_client/unit/web_client_tests.rs
@@ -44,7 +44,6 @@ mod web_client_tests {
.await
{
Ok(Ok(_)) => {
- // server ready
return Ok(());
},
Ok(Err(e)) => {
@@ -113,7 +112,6 @@ mod web_client_tests {
server_handle.abort();
- // time for cleanup
tokio::time::sleep(Duration::from_millis(100)).await;
}
@@ -190,7 +188,6 @@ mod web_client_tests {
server_handle.abort();
revoke_token(test_token_name).expect("Failed to revoke test token");
- // time for cleanup
tokio::time::sleep(Duration::from_millis(100)).await;
}
@@ -428,7 +425,7 @@ mod web_client_tests {
let messages = mock_api.get_sent_messages();
for msg in messages {
match msg {
- ClientToServerMsg::TerminalResize { new_size: _ } => {
+ ClientToServerMsg::TerminalResize { .. } => {
found_resize = true;
},
ClientToServerMsg::Key { .. }
@@ -465,12 +462,6 @@ mod web_client_tests {
#[tokio::test]
#[serial]
async fn test_terminal_metrics_translates_to_pixel_dimensions() {
- // Simulates a browser sending a TerminalMetrics control message
- // (the payload our websockets.js sendTerminalMetrics() helper
- // produces) and verifies the web server translates it into
- // ClientToServerMsg::TerminalPixelDimensions with field-for-field
- // accuracy. This guards the wire-format contract end-to-end
- // through serde + the match arm in websocket_handlers.rs.
let _ = delete_db();
let test_token_name = "test_token_terminal_metrics";
@@ -563,7 +554,6 @@ mod web_client_tests {
serde_json::from_str(&client_response.text().unwrap()).unwrap();
let web_client_id = client_data["web_client_id"].as_str().unwrap().to_string();
- // Open the control WebSocket and consume the initial SetConfig.
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let (control_ws, _) = timeout(
Duration::from_secs(5),
@@ -577,9 +567,6 @@ mod web_client_tests {
.await
.expect("Timeout waiting for initial control message");
- // The control channel only registers the client_id after the
- // first inbound message. Send a TerminalResize first to mirror
- // what the browser does at startup, then the TerminalMetrics.
let resize_msg = WebClientToWebServerControlMessage {
web_client_id: web_client_id.clone(),
payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size {
@@ -594,11 +581,6 @@ mod web_client_tests {
.await
.expect("Failed to send TerminalResize");
- // Send the actual TerminalMetrics payload — this is what the
- // browser-side sendTerminalMetrics() helper writes onto the wire.
- // Hand-construct the JSON to lock in the exact field names the
- // browser uses, rather than going through the serde Serialize
- // impl (which would mask any rename mismatch).
let metrics_json = serde_json::json!({
"web_client_id": web_client_id,
"payload": {
@@ -614,11 +596,8 @@ mod web_client_tests {
.await
.expect("Failed to send TerminalMetrics");
- // Give the server a moment to process both messages.
tokio::time::sleep(Duration::from_millis(500)).await;
- // Inspect the captured ClientToServerMsg traffic on the mock OS
- // API for this web client.
let mock_apis = factory_for_verification.mock_apis.lock().unwrap();
let mut found_pixel_dims: Option<ClientToServerMsg> = None;
for (_, mock_api) in mock_apis.iter() {
@@ -802,13 +781,10 @@ mod web_client_tests {
.await
.expect("Server failed to start");
- // Login and get session token
let session_token = login_and_get_session_token(port, &auth_token).await;
- // Create client session
let web_client_id = create_client_session(port, &session_token).await;
- // Establish control WebSocket connection
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let (control_ws, _) = timeout(
Duration::from_secs(5),
@@ -820,12 +796,10 @@ mod web_client_tests {
let (mut control_sink, mut control_stream) = control_ws.split();
- // Wait for initial SetConfig message
let _initial_msg = timeout(Duration::from_secs(2), control_stream.next())
.await
.expect("Timeout waiting for initial control message");
- // Send resize message to establish proper connection
let resize_msg = WebClientToWebServerControlMessage {
web_client_id: web_client_id.clone(),
payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size {
@@ -839,7 +813,6 @@ mod web_client_tests {
.await
.expect("Failed to send resize message");
- // Establish terminal WebSocket connection
let terminal_ws_url = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, web_client_id
@@ -854,10 +827,8 @@ mod web_client_tests {
let (_terminal_sink, mut terminal_stream) = terminal_ws.split();
- // Trigger server shutdown
server_handle.abort();
- // Verify control WebSocket receives close frame
let control_close_result = timeout(Duration::from_secs(3), control_stream.next()).await;
match control_close_result {
Ok(Some(Ok(Message::Close(_)))) => {
@@ -880,7 +851,6 @@ mod web_client_tests {
},
}
- // Verify terminal WebSocket receives close frame or connection ends
let terminal_close_result = timeout(Duration::from_secs(3), terminal_stream.next()).await;
match terminal_close_result {
Ok(Some(Ok(Message::Close(_)))) => {
@@ -904,7 +874,6 @@ mod web_client_tests {
}
revoke_token(test_token_name).expect("Failed to revoke test token");
- // time for cleanup
tokio::time::sleep(Duration::from_millis(100)).await;
}
@@ -949,14 +918,11 @@ mod web_client_tests {
.await
.expect("Server failed to start");
- // Login and get session token
let session_token = login_and_get_session_token(port, &auth_token).await;
- // Create multiple client sessions
let client_id_1 = create_client_session(port, &session_token).await;
let client_id_2 = create_client_session(port, &session_token).await;
- // Establish WebSocket connections for both clients
let control_ws_url_1 = format!("ws://127.0.0.1:{}/ws/control", port);
let (control_ws_1, _) = timeout(
Duration::from_secs(5),
@@ -979,11 +945,9 @@ mod web_client_tests {
let (mut control_sink_2, mut control_stream_2) = control_ws_2.split();
- // Wait for initial messages and establish connections
let _initial_msg_1 = timeout(Duration::from_secs(2), control_stream_1.next()).await;
let _initial_msg_2 = timeout(Duration::from_secs(2), control_stream_2.next()).await;
- // Send messages to establish proper connections
let resize_msg_1 = WebClientToWebServerControlMessage {
web_client_id: client_id_1.clone(),
payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size {
@@ -1010,7 +974,6 @@ mod web_client_tests {
.await
.expect("Failed to send resize message for client 2");
- // Establish terminal connections
let terminal_ws_url_1 = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, client_id_1
@@ -1025,7 +988,6 @@ mod web_client_tests {
let (_terminal_sink_1, _terminal_stream_1) = terminal_ws_1.split();
- // Verify both clients are initially present by checking mock APIs
tokio::time::sleep(Duration::from_millis(200)).await;
let initial_api_count = factory_for_verification.mock_apis.lock().unwrap().len();
assert!(
@@ -1033,13 +995,10 @@ mod web_client_tests {
"Should have at least 2 client APIs created"
);
- // Close connection for client 1 by closing WebSocket
let _ = control_sink_1.close().await;
- // Allow time for cleanup
tokio::time::sleep(Duration::from_millis(500)).await;
- // Verify client 2 is still functional by sending another message
let resize_msg_2_again = WebClientToWebServerControlMessage {
web_client_id: client_id_2.clone(),
payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size {
@@ -1059,7 +1018,6 @@ mod web_client_tests {
Err(e) => println!("Client 2 send failed (may be expected): {:?}", e),
}
- // Verify messages were received by checking mock APIs
let mock_apis = factory_for_verification.mock_apis.lock().unwrap();
let mut total_resize_messages: usize = 0;
@@ -1123,11 +1081,9 @@ mod web_client_tests {
.await
.expect("Server failed to start");
- // Login and create session
let session_token = login_and_get_session_token(port, &auth_token).await;
let web_client_id = create_client_session(port, &session_token).await;
- // Establish terminal WebSocket connection
let terminal_ws_url = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, web_client_id
@@ -1142,16 +1098,13 @@ mod web_client_tests {
let (mut terminal_sink, mut terminal_stream) = terminal_ws.split();
- // Send some data to ensure connection is active and render loop is running
terminal_sink
.send(Message::Text("test input\n".to_string()))
.await
.expect("Failed to send terminal input");
- // Allow connection to stabilize and render loop to start
tokio::time::sleep(Duration::from_millis(500)).await;
- // Trigger shutdown by aborting server - this should trigger cancellation tokens
server_handle.abort();
let mut connection_terminated = false;
@@ -1188,15 +1141,12 @@ mod web_client_tests {
connection_terminated = true;
},
Err(_) => {
- // Timeout on this iteration, continue monitoring
println!("Timeout on stream.next(), continuing to monitor...");
},
}
}
- // If connection hasn't terminated through normal means, check if it's due to server shutdown
if !connection_terminated {
- // Try one more time to see if the connection is actually closed
match timeout(Duration::from_millis(100), terminal_stream.next()).await {
Ok(None) => {
println!("✓ Terminal WebSocket stream ended after server abort");
@@ -1210,8 +1160,6 @@ mod web_client_tests {
},
_ => {
println!("Connection still active after server abort - this may indicate the cancellation token isn't working as expected in test environment");
- // In test environment, server abort might not trigger cancellation tokens immediately
- // We'll consider the test successful if we've aborted the server
termination_reason = "server_aborted";
connection_terminated = true;
},
@@ -1269,11 +1217,9 @@ mod web_client_tests {
.await
.expect("Server failed to start");
- // Login and create session
let session_token = login_and_get_session_token(port, &auth_token).await;
let web_client_id = create_client_session(port, &session_token).await;
- // Establish terminal WebSocket connection
let terminal_ws_url = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, web_client_id
@@ -1288,19 +1234,15 @@ mod web_client_tests {
let (mut terminal_sink, mut terminal_stream) = terminal_ws.split();
- // Send terminal input to ensure connection is established
terminal_sink
.send(Message::Text("echo test\n".to_string()))
.await
.expect("Failed to send terminal input");
- // Allow connection to stabilize
tokio::time::sleep(Duration::from_millis(200)).await;
- // Create a mock API and simulate different exit scenarios by sending exit message
let mock_apis = factory_for_verification.mock_apis.lock().unwrap();
if let Some((_, mock_api)) = mock_apis.iter().next() {
- // Simulate ClientExited message being sent
mock_api
.messages_to_server
.lock()
@@ -1309,10 +1251,8 @@ mod web_client_tests {
}
drop(mock_apis);
- // Close the WebSocket connection to trigger cleanup
let _ = terminal_sink.close().await;
- // Monitor for connection termination
let close_result = timeout(Duration::from_secs(3), terminal_stream.next()).await;
match close_result {
Ok(Some(Ok(Message::Close(_)))) => {
@@ -1332,7 +1272,6 @@ mod web_client_tests {
},
}
- // Verify that ClientExited message was processed
let mock_apis = factory_for_verification.mock_apis.lock().unwrap();
let mut found_client_exited = false;
@@ -1353,7 +1292,6 @@ mod web_client_tests {
server_handle.abort();
revoke_token(test_token_name).expect("Failed to revoke test token");
- // time for cleanup
tokio::time::sleep(Duration::from_millis(100)).await;
}
@@ -1362,13 +1300,10 @@ mod web_client_tests {
async fn test_read_only_token_cannot_create_new_session() {
let _ = delete_db();
- // Create read-only token
let (auth_token, _) = create_token(Some("test-readonly".to_string()), true)
.expect("Failed to create read-only token");
- // Setup mocks
let mock_session_manager = Arc::new(MockSessionManager::new());
- // Do NOT mark any session as existing - we want to verify read-only cannot create new sessions
let mock_os_api_factory = Arc::new(MockClientOsApiFactory::new());
let session_manager_for_verification = mock_session_manager.clone();
@@ -1400,7 +1335,6 @@ mod web_client_tests {
.await
.expect("Server should start successfully");
- // Login and create client
let session_token = login_and_get_session_token(port, &auth_token).await;
let session_url = format!("http://127.0.0.1:{}/session", port);
@@ -1431,22 +1365,16 @@ mod web_client_tests {
assert_eq!(is_read_only, true, "Client should be marked as read-only");
- // Try to connect via control WebSocket
- // This will trigger the server_listener which should close the connection
- // because read-only client is trying to attach to non-existent session
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
- // The connection might fail or close immediately
let _ws_result = timeout(
Duration::from_secs(3),
connect_async_with_cookie(&control_ws_url, &session_token),
)
.await;
- // Give time for server_listener to process and close connection
tokio::time::sleep(Duration::from_millis(500)).await;
- // Verify no session was created
assert!(
!session_manager_for_verification.was_session_created("default"),
"No session should be created for read-only token attempting to create new session"
@@ -1462,11 +1390,9 @@ mod web_client_tests {
async fn test_read_only_token_uses_watcher_message_type() {
let _ = delete_db();
- // Create both regular and read-only tokens
let (regular_token, _) = create_token(Some("regular".to_string()), false).unwrap();
let (readonly_token, _) = create_token(Some("readonly".to_string()), true).unwrap();
- // Setup mocks - mark all sessions as existing so clients use AttachClient/AttachWatcherClient
let mock_session_manager = Arc::new(MockSessionManager::with_all_sessions_existing());
let session_manager_for_verification = mock_session_manager.clone();
@@ -1500,11 +1426,9 @@ mod web_client_tests {
.await
.expect("Server should start");
- // First, attach with REGULAR token to establish baseline
let regular_session_token = login_and_get_session_token(port, &regular_token).await;
let regular_web_client_id = create_client_session(port, &regular_session_token).await;
- // Connect control websocket
let regular_control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let (regular_control_ws, _) = timeout(
Duration::from_secs(5),
@@ -1516,7 +1440,6 @@ mod web_client_tests {
let (mut regular_control_sink, _regular_control_stream) = regular_control_ws.split();
- // Connect terminal websocket to trigger server_listener
let regular_terminal_ws_url = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, regular_web_client_id
@@ -1531,11 +1454,8 @@ mod web_client_tests {
let (mut regular_terminal_sink, _regular_terminal_stream) = regular_terminal_ws.split();
- // Wait for attachment to complete
tokio::time::sleep(Duration::from_millis(500)).await;
- // VERIFY: Regular client should use AttachClient (not FirstClientConnected, since session exists)
- // Check what sessions we actually have
let regular_msg = {
let all_messages = session_manager_for_verification
.first_messages_sent
@@ -1559,11 +1479,9 @@ mod web_client_tests {
regular_msg
);
- // Now attach with READ-ONLY token
let readonly_session_token = login_and_get_session_token(port, &readonly_token).await;
let readonly_web_client_id = create_client_session(port, &readonly_session_token).await;
- // Connect control websocket
let readonly_control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let (readonly_control_ws, _) = timeout(
Duration::from_secs(5),
@@ -1575,7 +1493,6 @@ mod web_client_tests {
let (mut readonly_control_sink, _readonly_control_stream) = readonly_control_ws.split();
- // Connect terminal websocket to trigger server_listener
let readonly_terminal_ws_url = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, readonly_web_client_id
@@ -1590,23 +1507,19 @@ mod web_client_tests {
let (mut readonly_terminal_sink, _readonly_terminal_stream) = readonly_terminal_ws.split();
- // Wait for attachment to complete
tokio::time::sleep(Duration::from_millis(500)).await;
- // VERIFY: Read-only client should use AttachWatcherClient
let readonly_msg = {
let all_messages = session_manager_for_verification
.first_messages_sent
.lock()
.unwrap();
- // Should have at least 2 messages now (regular + readonly)
assert!(
all_messages.len() >= 2,
"Should have at least 2 messages (regular and readonly)"
);
- // Get the second message (readonly client)
let (_readonly_session_name, msg) = all_messages
.get(1)
.expect("Should have message for read-only client");
@@ -1620,7 +1533,6 @@ mod web_client_tests {
readonly_msg
);
- // Verify the terminal size is passed correctly
if let ClientToServerMsg::AttachWatcherClient { terminal_size, .. } = readonly_msg {
assert!(terminal_size.rows > 0 && terminal_size.cols > 0);
}
@@ -1639,12 +1551,9 @@ mod web_client_tests {
async fn test_regular_token_uses_first_client_connected_for_new_session() {
let _ = delete_db();
- // Create regular token
let (regular_token, _) = create_token(Some("regular".to_string()), false).unwrap();
- // Setup mocks
let mock_session_manager = Arc::new(MockSessionManager::new());
- // Do NOT mark session as existing - we want to create a new one
let session_manager_for_verification = mock_session_manager.clone();
let mock_os_api_factory = Arc::new(MockClientOsApiFactory::new());
@@ -1676,11 +1585,9 @@ mod web_client_tests {
.await
.expect("Server should start");
- // Login and create client
let session_token = login_and_get_session_token(port, &regular_token).await;
let web_client_id = create_client_session(port, &session_token).await;
- // Connect control websocket
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let (control_ws, _) = timeout(
Duration::from_secs(5),
@@ -1692,7 +1599,6 @@ mod web_client_tests {
let (mut control_sink, _control_stream) = control_ws.split();
- // Connect terminal websocket to trigger server_listener
let terminal_ws_url = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, web_client_id
@@ -1707,17 +1613,13 @@ mod web_client_tests {
let (mut terminal_sink, _terminal_stream) = terminal_ws.split();
- // Wait for session creation to complete
tokio::time::sleep(Duration::from_millis(500)).await;
- // VERIFY: Regular client creating new session should use FirstClientConnected
- // The session name will be "default" or a generated name
let all_messages = session_manager_for_verification
.first_messages_sent
.lock()
.unwrap();
- // Find the first message (should be FirstClientConnected)
let msg = all_messages
.first()
.map(|(_, msg)| msg)
@@ -1729,7 +1631,6 @@ mod web_client_tests {
msg
);
- // Verify session was marked as created
let sessions_created = session_manager_for_verification
.sessions_created
.lock()
@@ -1751,11 +1652,9 @@ mod web_client_tests {
async fn test_read_only_status_tracked_in_connection_table() {
let _ = delete_db();
- // Create tokens
let (regular_token, _) = create_token(Some("regular".to_string()), false).unwrap();
let (readonly_token, _) = create_token(Some("readonly".to_string()), true).unwrap();
- // Setup mocks
let mock_session_manager = Arc::new(MockSessionManager::new());
let mock_os_api_factory = Arc::new(MockClientOsApiFactory::new());
@@ -1787,7 +1686,6 @@ mod web_client_tests {
.await
.expect("Server should start");
- // Create multiple clients with different tokens
let regular_session_token = login_and_get_session_token(port, &regular_token).await;
let session_url = format!("http://127.0.0.1:{}/session", port);
@@ -1840,7 +1738,6 @@ mod web_client_tests {
serde_json::from_str(&readonly_response.text().unwrap()).unwrap();
let readonly_is_read_only = readonly_client_data["is_read_only"].as_bool().unwrap();
- // Verify is_read_only flag in responses
assert_eq!(
regular_is_read_only, false,
"Regular client should not be read-only"
@@ -1856,7 +1753,6 @@ mod web_client_tests {
tokio::time::sleep(Duration::from_millis(100)).await;
}
- // Helper function to login and get session token
async fn login_and_get_session_token(port: u16, auth_token: &str) -> String {
let login_url = format!("http://127.0.0.1:{}/command/login", port);
let login_payload = serde_json::json!({
@@ -1891,7 +1787,6 @@ mod web_client_tests {
.to_string()
}
- // Helper function to create client session
async fn create_client_session(port: u16, session_token: &str) -> String {
let session_url = format!("http://127.0.0.1:{}/session", port);
let mut client_response = timeout(
@@ -1964,7 +1859,6 @@ mod web_client_tests {
let session_token = login_and_get_session_token(port, &auth_token).await;
let web_client_id = create_client_session(port, &session_token).await;
- // Establish terminal WebSocket connection
let terminal_ws_url = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, web_client_id
@@ -1979,7 +1873,6 @@ mod web_client_tests {
let (_terminal_sink, mut terminal_stream) = terminal_ws.split();
- // Establish control WebSocket connection
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let (control_ws, _) = timeout(
Duration::from_secs(5),
@@ -1991,7 +1884,6 @@ mod web_client_tests {
let (mut control_sink, mut control_stream) = control_ws.split();
- // Wait for initial SetConfig and send resize to register client_id on the control channel
let _initial_msg = timeout(Duration::from_secs(2), control_stream.next()).await;
let resize_msg = WebClientToWebServerControlMessage {
web_client_id: web_client_id.clone(),
@@ -2005,10 +1897,8 @@ mod web_client_tests {
.await
.expect("Failed to send resize message");
- // Allow connection to stabilize
tokio::time::sleep(Duration::from_millis(200)).await;
- // Queue KickedByHost exit message into the mock API
{
let mock_apis = factory_for_verification.mock_apis.lock().unwrap();
assert!(
@@ -2022,8 +1912,6 @@ mod web_client_tests {
}
}
- // Wait for terminal WebSocket to close with code 4001
- // Use a polling loop to handle any non-close messages that may arrive first
let mut terminal_got_4001 = false;
let mut terminal_closed = false;
let deadline = tokio::time::Instant::now() + Duration::from_secs(8);
@@ -2040,7 +1928,6 @@ mod web_client_tests {
break;
},
Ok(Some(Ok(_other))) => {
- // skip non-close messages (e.g. buffered render data)
continue;
},
Ok(Some(Err(_))) | Ok(None) => {
@@ -2048,7 +1935,6 @@ mod web_client_tests {
break;
},
Err(_) => {
- // timeout on this iteration, continue polling
continue;
},
}
@@ -2062,7 +1948,6 @@ mod web_client_tests {
"Terminal WebSocket should close with code 4001 when kicked by host"
);
- // Wait for control WebSocket to close with code 4001 (skip any pending non-Close messages)
let mut control_got_4001 = false;
let mut control_closed = false;
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
@@ -2079,7 +1964,6 @@ mod web_client_tests {
break;
},
Ok(Some(Ok(_msg))) => {
- // Skip non-Close messages
continue;
},
Ok(Some(Err(_))) | Ok(None) => {
@@ -2148,7 +2032,6 @@ mod web_client_tests {
let session_token = login_and_get_session_token(port, &auth_token).await;
let web_client_id = create_client_session(port, &session_token).await;
- // Establish terminal WebSocket connection
let terminal_ws_url = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, web_client_id
@@ -2163,7 +2046,6 @@ mod web_client_tests {
let (_terminal_sink, mut terminal_stream) = terminal_ws.split();
- // Establish control WebSocket connection
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let (control_ws, _) = timeout(
Duration::from_secs(5),
@@ -2175,7 +2057,6 @@ mod web_client_tests {
let (mut control_sink, mut control_stream) = control_ws.split();
- // Wait for initial SetConfig and send resize to register client_id on the control channel
let _initial_msg = timeout(Duration::from_secs(2), control_stream.next()).await;
let resize_msg = WebClientToWebServerControlMessage {
web_client_id: web_client_id.clone(),
@@ -2189,10 +2070,8 @@ mod web_client_tests {
.await
.expect("Failed to send resize message");
- // Allow connection to stabilize
tokio::time::sleep(Duration::from_millis(200)).await;
- // Queue Normal exit message into the mock API
{
let mock_apis = factory_for_verification.mock_apis.lock().unwrap();
assert!(
@@ -2206,8 +2085,6 @@ mod web_client_tests {
}
}
- // Wait for terminal WebSocket to close with NORMAL (1000) close code
- // Use a polling loop to handle any non-close messages that may arrive first
let mut terminal_got_normal = false;
let mut terminal_closed = false;
let deadline = tokio::time::Instant::now() + Duration::from_secs(8);
@@ -2220,7 +2097,6 @@ mod web_client_tests {
break;
},
Ok(Some(Ok(Message::Close(None)))) => {
- // Close with no code also counts as a normal close
terminal_got_normal = true;
terminal_closed = true;
break;
@@ -2262,9 +2138,6 @@ mod web_client_tests {
),
tokio_tungstenite::tungstenite::Error,
> {
- // Manually construct WebSocket request with required headers since we need to add a custom cookie.
- // When building the request manually, we must include all the standard WebSocket handshake headers
- // that would normally be added automatically by the WebSocket client library.
let request = Request::builder()
.uri(url)
.header("Cookie", format!("session_token={}", session_token))
@@ -2278,8 +2151,6 @@ mod web_client_tests {
connect_async(request).await
}
- // ========== Task 1: Security Headers Tests ==========
-
#[tokio::test]
#[serial]
async fn test_security_headers_present_on_all_responses() {
@@ -2362,7 +2233,6 @@ mod web_client_tests {
csp.contains("default-src 'self'"),
"CSP should contain default-src 'self'"
);
- // HSTS should NOT be present when is_https is false
assert!(
response
.headers()
@@ -2458,14 +2328,11 @@ mod web_client_tests {
tokio::time::sleep(Duration::from_millis(100)).await;
}
- // ========== Task 10: web_client_id binding tests ==========
-
#[tokio::test]
#[serial]
async fn test_control_websocket_rejects_foreign_web_client_id() {
let _ = delete_db();
- // Create a read-write token and a read-only token
let (rw_token, _) = create_token(Some("rw_token".to_string()), false).unwrap();
let (ro_token, _) = create_token(Some("ro_token".to_string()), true).unwrap();
@@ -2501,15 +2368,12 @@ mod web_client_tests {
.await
.expect("Server failed to start");
- // Login with read-write token and create a client session
let rw_session_token = login_and_get_session_token(port, &rw_token).await;
let rw_web_client_id = create_client_session(port, &rw_session_token).await;
- // Login with read-only token and create a client session
let ro_session_token = login_and_get_session_token(port, &ro_token).await;
let _ro_web_client_id = create_client_session(port, &ro_session_token).await;
- // Connect a control WebSocket using the read-only session token
let control_ws_url = format!("ws://127.0.0.1:{}/ws/control", port);
let (control_ws, _) = timeout(
Duration::from_secs(5),
@@ -2521,10 +2385,8 @@ mod web_client_tests {
let (mut control_sink, mut control_stream) = control_ws.split();
- // Wait for initial SetConfig message
let _initial_msg = timeout(Duration::from_secs(2), control_stream.next()).await;
- // Send a TerminalResize message with the READ-WRITE user's web_client_id
let resize_msg = WebClientToWebServerControlMessage {
web_client_id: rw_web_client_id.clone(),
payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size {
@@ -2538,18 +2400,11 @@ mod web_client_tests {
.await
.expect("Failed to send resize message");
- // The WebSocket should be closed by the server
let close_result = timeout(Duration::from_secs(3), control_stream.next()).await;
match close_result {
- Ok(Some(Ok(Message::Close(_)))) | Ok(Some(Err(_))) | Ok(None) => {
- // Expected: connection was closed
- },
- Err(_) => {
- // Timeout - connection may have been silently dropped
- },
+ Ok(Some(Ok(Message::Close(_)))) | Ok(Some(Err(_))) | Ok(None) => {},
+ Err(_) => {},
Ok(Some(Ok(_))) => {
- // If we get another message, the connection wasn't closed - this is a failure
- // but let's check if it closes after
let second_result = timeout(Duration::from_secs(2), control_stream.next()).await;
assert!(
matches!(
@@ -2561,7 +2416,6 @@ mod web_client_tests {
},
}
- // Verify that NO TerminalResize message was forwarded on behalf of the rw client
tokio::time::sleep(Duration::from_millis(200)).await;
let mock_apis = factory_for_verification.mock_apis.lock().unwrap();
for (_, mock_api) in mock_apis.iter() {
@@ -2634,10 +2488,8 @@ mod web_client_tests {
let (mut control_sink, mut control_stream) = control_ws.split();
- // Wait for initial SetConfig message
let _initial_msg = timeout(Duration::from_secs(2), control_stream.next()).await;
- // Send a TerminalResize message with the CORRECT web_client_id
let resize_msg = WebClientToWebServerControlMessage {
web_client_id: web_client_id.clone(),
payload: WebClientToWebServerControlMessagePayload::TerminalResize(Size {
@@ -2651,10 +2503,8 @@ mod web_client_tests {
.await
.expect("Failed to send resize message");
- // Allow message processing
tokio::time::sleep(Duration::from_millis(500)).await;
- // Verify the resize was forwarded
let mock_apis = factory_for_verification.mock_apis.lock().unwrap();
let mut found_resize = false;
for (_, mock_api) in mock_apis.iter() {
@@ -2715,14 +2565,12 @@ mod web_client_tests {
.await
.expect("Server failed to start");
- // Create two sessions with different tokens
let rw_session_token = login_and_get_session_token(port, &rw_token).await;
let rw_web_client_id = create_client_session(port, &rw_session_token).await;
let ro_session_token = login_and_get_session_token(port, &ro_token).await;
let _ro_web_client_id = create_client_session(port, &ro_session_token).await;
- // Try to connect terminal WebSocket using ro session token but rw web_client_id
let terminal_ws_url = format!(
"ws://127.0.0.1:{}/ws/terminal?web_client_id={}",
port, rw_web_client_id
@@ -2735,25 +2583,15 @@ mod web_client_tests {
match ws_result {
Ok(Ok((ws, _))) => {
- // Connection was established but should be closed immediately
let (_sink, mut stream) = ws.split();
let msg = timeout(Duration::from_secs(2), stream.next()).await;
match msg {
- Ok(Some(Ok(Message::Close(_)))) | Ok(Some(Err(_))) | Ok(None) | Err(_) => {
- // Expected: connection was rejected/closed
- },
- Ok(Some(Ok(_))) => {
- // Unexpected: received data on a connection that should have been rejected
- // The server may close after initial processing
- },
+ Ok(Some(Ok(Message::Close(_)))) | Ok(Some(Err(_))) | Ok(None) | Err(_) => {},
+ Ok(Some(Ok(_))) => {},
}
},
- Ok(Err(_)) => {
- // Connection failed - expected behavior
- },
- Err(_) => {
- // Timeout - acceptable
- },
+ Ok(Err(_)) => {},
+ Err(_) => {},
}
server_handle.abort();
@@ -2761,8 +2599,6 @@ mod web_client_tests {
tokio::time::sleep(Duration::from_millis(100)).await;
}
- // ========== Task 8: HTML-escape base_url test ==========
-
#[tokio::test]
#[serial]
async fn test_base_url_with_special_characters_is_escaped() {
@@ -3150,7 +2986,6 @@ impl SessionManager for MockSessionManager {
_zellij_ipc_pipe: &PathBuf,
first_message: ClientToServerMsg,
) {
- // Track the message that was sent
self.first_messages_sent
.lock()
.unwrap()
diff --git a/zellij-client/src/web_client/websocket_handlers.rs b/zellij-client/src/web_client/websocket_handlers.rs
index 969911cc..11c81a9e 100644
--- a/zellij-client/src/web_client/websocket_handlers.rs
+++ b/zellij-client/src/web_client/websocket_handlers.rs
@@ -21,7 +21,7 @@ use std::sync::{atomic::AtomicBool, Arc};
use tokio_util::sync::CancellationToken;
use zellij_utils::{
input::mouse::MouseEvent,
- ipc::{ClientToServerMsg, PixelDimensions},
+ ipc::{ClientToServerMsg, PixelDimensions, ResizeCause},
pane_size::SizeInPixels,
};
@@ -76,11 +76,29 @@ async fn handle_ws_control(
};
let client_msg = match deserialized_msg.payload {
WebClientToWebServerControlMessagePayload::TerminalResize(size) => {
- ClientToServerMsg::TerminalResize { new_size: size }
+ ClientToServerMsg::TerminalResize {
+ new_size: size,
+ cause: ResizeCause::Viewport,
+ }
+ },
+ WebClientToWebServerControlMessagePayload::TerminalResizeRendering(size) => {
+ ClientToServerMsg::TerminalResize {
+ new_size: size,
+ cause: ResizeCause::RenderingPreference,
+ }
+ },
+ WebClientToWebServerControlMessagePayload::TerminalSizeSettled(size) => {
+ ClientToServerMsg::TerminalResize {
+ new_size: size,
+ cause: ResizeCause::SizeSettled,
+ }
},
WebClientToWebServerControlMessagePayload::TerminalMetrics(metrics) => {
terminal_metrics_to_ipc(metrics)
},
+ WebClientToWebServerControlMessagePayload::SoftKeyboardVisibilityChanged {
+ visible,
+ } => ClientToServerMsg::SoftKeyboardVisibilityChanged { visible },
};
let _ = client_connection.send_to_server(client_msg);