From c97977d645d6a20a084fef959354b08264084573 Mon Sep 17 00:00:00 2001 From: singchia Date: Thu, 1 Oct 2026 09:26:42 +0800 Subject: [PATCH] fix(webssh): frame terminal workspace and add fullscreen controls Refs #455. Keep responsive terminal sizing and leave fullscreen before reconnect dialogs. --- web/src/pages/DeviceShell.test.tsx | 7 +++++ web/src/pages/DeviceShell.tsx | 46 ++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/web/src/pages/DeviceShell.test.tsx b/web/src/pages/DeviceShell.test.tsx index 8d3db1fb..b1a8f4bb 100644 --- a/web/src/pages/DeviceShell.test.tsx +++ b/web/src/pages/DeviceShell.test.tsx @@ -81,6 +81,13 @@ describe('ConnectModal', () => { await act(async () => { await first.onmessage?.({ data: JSON.stringify({ type: 'ready' }) } as MessageEvent); }); expect(first.send).toHaveBeenLastCalledWith(JSON.stringify({ type: 'resize', cols: 132, rows: 38 })); + const requestFullscreen = vi.fn().mockRejectedValue(new Error('denied')); + Object.defineProperty(screen.getByRole('main'), 'requestFullscreen', { value: requestFullscreen, configurable: true }); + fireEvent.click(screen.getByRole('button', { name: '全屏' })); + await waitFor(() => expect(requestFullscreen).toHaveBeenCalledOnce()); + expect(await screen.findByRole('alert')).toHaveTextContent('无法切换全屏'); + expect(first.close).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '重新连接' })); fireEvent.change(await screen.findByLabelText('密码'), { target: { value: 'secret' } }); fireEvent.click(screen.getByRole('button', { name: '连接' })); diff --git a/web/src/pages/DeviceShell.tsx b/web/src/pages/DeviceShell.tsx index 9a8d9a42..f9eb8113 100644 --- a/web/src/pages/DeviceShell.tsx +++ b/web/src/pages/DeviceShell.tsx @@ -24,6 +24,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { ChevronLeft, + Maximize, + Minimize, KeyRound, Plus, Power, @@ -112,6 +114,34 @@ export function DeviceShell() { const [modalOpen, setModalOpen] = useState(true); const [conn, setConn] = useState({ kind: 'idle' }); const [connectionError, setConnectionError] = useState(null); + const [fullscreen, setFullscreen] = useState(false); + const [layoutError, setLayoutError] = useState(null); + const workspaceRef = useRef(null); + + useEffect(() => { + const syncFullscreen = () => setFullscreen(document.fullscreenElement === workspaceRef.current); + document.addEventListener('fullscreenchange', syncFullscreen); + return () => document.removeEventListener('fullscreenchange', syncFullscreen); + }, []); + + const toggleFullscreen = async () => { + setLayoutError(null); + try { + if (document.fullscreenElement === workspaceRef.current) await document.exitFullscreen(); + else await workspaceRef.current?.requestFullscreen(); + } catch { + setLayoutError(tr('无法切换全屏,请重试', 'Unable to toggle fullscreen. Please retry.')); + } + }; + + // Connection dialogs use a body portal, so leave native fullscreen first. + useEffect(() => { + if (modalOpen && document.fullscreenElement === workspaceRef.current) { + void document.exitFullscreen().catch(() => { + setLayoutError(tr('请先退出全屏再连接', 'Exit fullscreen before connecting.')); + }); + } + }, [modalOpen, tr]); // The terminal API + ws live on refs — they're side-effectful and // outliving any single render is the whole point of this page. @@ -463,8 +493,8 @@ export function DeviceShell() { extractHostname(edge?.host_info) || edge?.name || deviceId || tr('设备', 'device'); return ( - <>{dialog}
-
+ <>{dialog}
+
{hostname} @@ -493,6 +523,15 @@ export function DeviceShell() { {tr('重连', 'Reconnect')} +
-
+ {layoutError &&

{layoutError}

} +