diff options
| author | Aram Drevekenin <aram@poor.dev> | 2026-06-10 16:50:55 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-10 16:50:55 +0200 |
| commit | b6a5ad0e5a93d9f584b3f7a0fc90f497a09cbdb0 (patch) | |
| tree | 38c77a381cfb937b428752a588c09a45c81434c9 /zellij-client | |
| parent | feat: PWA support for the web client (#5184) (diff) | |
| download | sl0ppij-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.js | 49 | ||||
| -rw-r--r-- | zellij-client/assets/index.html | 6 | ||||
| -rw-r--r-- | zellij-client/assets/index.js | 16 | ||||
| -rw-r--r-- | zellij-client/assets/input.js | 271 | ||||
| -rw-r--r-- | zellij-client/assets/key-handler.js | 64 | ||||
| -rw-r--r-- | zellij-client/assets/mouse.js | 25 | ||||
| -rw-r--r-- | zellij-client/assets/pinch.js | 134 | ||||
| -rw-r--r-- | zellij-client/assets/soft-keyboard.js | 288 | ||||
| -rw-r--r-- | zellij-client/assets/style.css | 19 | ||||
| -rw-r--r-- | zellij-client/assets/terminal.js | 53 | ||||
| -rw-r--r-- | zellij-client/assets/touch.js | 266 | ||||
| -rw-r--r-- | zellij-client/assets/websockets.js | 213 | ||||
| -rw-r--r-- | zellij-client/src/input_handler.rs | 6 | ||||
| -rw-r--r-- | zellij-client/src/lib.rs | 8 | ||||
| -rw-r--r-- | zellij-client/src/unit/terminal_loop_tests.rs | 42 | ||||
| -rw-r--r-- | zellij-client/src/web_client/control_message.rs | 9 | ||||
| -rw-r--r-- | zellij-client/src/web_client/server_listener.rs | 5 | ||||
| -rw-r--r-- | zellij-client/src/web_client/unit/web_client_tests.rs | 179 | ||||
| -rw-r--r-- | zellij-client/src/web_client/websocket_handlers.rs | 22 |
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, ®ular_token).await; let regular_web_client_id = create_client_session(port, ®ular_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, ®ular_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, ®ular_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); |
