From bde2aecbe8089a453d73999dfea753263cca3935 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 3 Sep 2026 21:43:14 +0800 Subject: [PATCH] feat(terminal): mobile virtual key bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an on-screen virtual key bar for the terminal panel so mobile users can send escape / tab / control / arrow / page keys without a hardware keyboard. The bar mirrors the pios implementation: two rows of 14 buttons (ESC / / — HOME ↑ END PGUP | TAB CTRL ALT ← ↓ → PGDN) with a CTRL/ALT latch that wraps the next soft-keyboard input as a control byte or ESC prefix respectively. Pure encoding lives in lib/terminal/keybar.ts — 12 keys + termKeySeq + applyTermMods + kbdLiftPx — covered by 18 unit tests. The new component is purely presentational and uses shadcn Button + Tailwind utilities, so there is no raw CSS. Wiring: - terminal-panel.tsx adds useMediaQuery('(max-width: 768px)') and a localStorage-backed collapse flag (codeg:term-keybar) shared across all tabs, then forwards keybarVisible down. - terminal-tab-bar.tsx gains a ⌨ toggle button (mobile-only, with tooltip + active highlight) that flips the flag. - terminal-view.tsx exposes writeQueueRef so the key bar's bytes feed the existing ordered single-flight write pump (without this the key bar would bypass the queue and scramble with onData on slow transport). onData is wrapped with applyTermMods to honor the latch. The visualViewport observer drives a kbdLift max-height on the outer flex column so the bar lifts above the soft keyboard and xterm auto-refits via the existing ResizeObserver. - workspace/layout.tsx bumps the mobile Drawer from 70vh to 95vh to give the new bar enough room when folded in. i18n: 16 new keys under Folder.terminal.keybar across all 10 locales (symbols are language-neutral; show/hide/label are translated for zh-CN/zh-TW/ja/ko/es/de/fr/pt/ar). English fallbacks keep the type contract aligned. Desktop path is untouched: useMediaQuery stays false, the toggle and the bar never render, and the writeQueueRef sits at null. --- src/app/workspace/layout.tsx | 2 +- src/components/terminal/term-keybar.test.tsx | 99 +++++++++ src/components/terminal/term-keybar.tsx | 201 +++++++++++++++++++ src/components/terminal/terminal-panel.tsx | 51 ++++- src/components/terminal/terminal-tab-bar.tsx | 37 +++- src/components/terminal/terminal-view.tsx | 130 +++++++++++- src/i18n/messages/ar.json | 21 +- src/i18n/messages/de.json | 21 +- src/i18n/messages/en.json | 21 +- src/i18n/messages/es.json | 21 +- src/i18n/messages/fr.json | 21 +- src/i18n/messages/ja.json | 21 +- src/i18n/messages/ko.json | 21 +- src/i18n/messages/pt.json | 21 +- src/i18n/messages/zh-CN.json | 21 +- src/i18n/messages/zh-TW.json | 21 +- src/lib/terminal/keybar.test.ts | 144 +++++++++++++ src/lib/terminal/keybar.ts | 114 +++++++++++ 18 files changed, 972 insertions(+), 16 deletions(-) create mode 100644 src/components/terminal/term-keybar.test.tsx create mode 100644 src/components/terminal/term-keybar.tsx create mode 100644 src/lib/terminal/keybar.test.ts create mode 100644 src/lib/terminal/keybar.ts diff --git a/src/app/workspace/layout.tsx b/src/app/workspace/layout.tsx index 8f45055ac4..cef8021bb8 100644 --- a/src/app/workspace/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -683,7 +683,7 @@ function MobileFolderWorkspaceShell({ swipeDirection="down" disablePointerDismissal={false} > - + Terminal
diff --git a/src/components/terminal/term-keybar.test.tsx b/src/components/terminal/term-keybar.test.tsx new file mode 100644 index 0000000000..1914519299 --- /dev/null +++ b/src/components/terminal/term-keybar.test.tsx @@ -0,0 +1,99 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { TermKeybar } from "./term-keybar" + +// next-intl is mocked at module level via vitest config; provide the minimum +// shape used by `useTranslations` so `t("...")` returns the key path. +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})) + +const NO_MODS = { ctrl: false, alt: false } + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe("", () => { + it("renders all 12 keys + 2 modifier buttons", () => { + render( + {}} onPressKey={() => {}} /> + ) + // Two-row layout, 7 + 7 buttons. + const all = screen.getAllByRole("button") + expect(all).toHaveLength(14) + }) + + it("fires onPressKey with the right key on pointerdown, and skips click", () => { + const onPressKey = vi.fn() + render( + {}} + onPressKey={onPressKey} + /> + ) + + // Find the "up" key by its localized label (we mocked useTranslations to + // return the key path, so labels are "up", "down", etc.). + const upBtn = screen.getByRole("button", { name: "up" }) + fireEvent.pointerDown(upBtn) + + expect(onPressKey).toHaveBeenCalledTimes(1) + expect(onPressKey).toHaveBeenCalledWith("up") + + // Click after pointerdown would double-fire without the swallowClick. + fireEvent.click(upBtn) + expect(onPressKey).toHaveBeenCalledTimes(1) + }) + + it("fires onToggleMod('ctrl' | 'alt') on the modifier buttons", () => { + const onToggleMod = vi.fn() + render( + {}} + /> + ) + + fireEvent.pointerDown(screen.getByRole("button", { name: "ctrl" })) + expect(onToggleMod).toHaveBeenCalledWith("ctrl") + + fireEvent.pointerDown(screen.getByRole("button", { name: "alt" })) + expect(onToggleMod).toHaveBeenCalledWith("alt") + }) + + it("marks CTRL/ALT active when mods prop is true", () => { + render( + {}} + onPressKey={() => {}} + /> + ) + // active class includes `bg-primary text-primary-foreground border-primary` + const ctrl = screen.getByRole("button", { name: "ctrl" }) + expect(ctrl.className).toContain("bg-primary") + + // ALT not armed — no accent. + const alt = screen.getByRole("button", { name: "alt" }) + expect(alt.className).not.toContain("bg-primary") + }) + + it("disables every button when disabled=true", () => { + render( + {}} + onPressKey={() => {}} + disabled + /> + ) + const all = screen.getAllByRole("button") + for (const btn of all) { + expect(btn).toBeDisabled() + } + }) +}) diff --git a/src/components/terminal/term-keybar.tsx b/src/components/terminal/term-keybar.tsx new file mode 100644 index 0000000000..b6a641787e --- /dev/null +++ b/src/components/terminal/term-keybar.tsx @@ -0,0 +1,201 @@ +"use client" + +import { useTranslations } from "next-intl" +import type { MouseEvent, PointerEvent } from "react" + +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { type TermKeyBarKey, type TermMods } from "@/lib/terminal/keybar" + +interface TermKeybarProps { + /** 当前 CTRL/ALT 闩锁状态(驱动按钮高亮)。 */ + mods: TermMods + /** 切换 CTRL/ALT 闩锁。 */ + onToggleMod: (mod: "ctrl" | "alt") => void + /** 按下普通键(ESC/TAB/方向键 等)——不消费闩锁,由父级在发完字节后复位。 */ + onPressKey: (key: TermKeyBarKey) => void + /** IME 组合进行中时禁用整组按钮(避免送出误触发的控制码)。 */ + disabled?: boolean +} + +/** + * 移动端终端虚拟键栏:两行按钮,覆盖 ESC/TAB/方向键/PgUp/PgDn + CTRL/ALT 闩锁。 + * + * 关键细节: + * · `onPointerDown` 触发 + `preventDefault`:按钮不夺走 xterm helper textarea + * 的焦点,软键盘保持弹出。 + * · CTRL/ALT 互斥闩锁:armed 状态由父级 state 控制,本组件只渲染高亮。 + * · 桌面端由父级用媒体查询(`useMediaQuery("(max-width: 768px)")`)判定, + * 此处不再二次过滤,避免 SSR/CSR 不一致导致水合闪烁。 + */ +export function TermKeybar({ + mods, + onToggleMod, + onPressKey, + disabled = false, +}: TermKeybarProps) { + const t = useTranslations("Folder.terminal.keybar") + + // onPointerDown handler — preventDefault keeps the soft keyboard open and the + // xterm helper textarea focused. Calling preventDefault on the React synthetic + // event is enough to suppress the default focus shift; we don't need a native + // addEventListener here. + const handlePointerDown = + (key: TermKeyBarKey) => (e: PointerEvent) => { + e.preventDefault() + onPressKey(key) + } + + const handleModPointerDown = + (mod: "ctrl" | "alt") => (e: PointerEvent) => { + e.preventDefault() + onToggleMod(mod) + } + + // Same handler for click — onPointerDown above already fires the action, so a + // click after touchend would re-fire and double the byte. Suppress it. + const swallowClick = (e: MouseEvent) => { + e.preventDefault() + } + + return ( +
+
+ + + + + + + +
+
+ + + + + + + +
+
+ ) +} + +interface KeyBtnProps { + label: string + active?: boolean + disabled?: boolean + onPointerDown: (e: PointerEvent) => void + onClick: (e: MouseEvent) => void +} + +/** + * 键栏单键: + * · `flex-1 min-w-0`:等宽均分剩余空间,超长 label 截断。 + * · `active`:CTRL/ALT 闩锁 armed 高亮(accent 背景)。 + * · `tabIndex={-1}`:跳过 Tab 焦点环(虚拟键栏不该拦截方向键导航)。 + */ +function KeyBtn({ + label, + active = false, + disabled = false, + onPointerDown, + onClick, +}: KeyBtnProps) { + return ( + + ) +} diff --git a/src/components/terminal/terminal-panel.tsx b/src/components/terminal/terminal-panel.tsx index 40665ee4e3..1dd81bf685 100644 --- a/src/components/terminal/terminal-panel.tsx +++ b/src/components/terminal/terminal-panel.tsx @@ -1,18 +1,66 @@ "use client" +import { useCallback, useEffect, useState } from "react" import { useTerminalContext } from "@/contexts/terminal-context" +import { useMediaQuery } from "@/hooks/use-media-query" import { TerminalTabBar } from "./terminal-tab-bar" import { TerminalView } from "./terminal-view" +const KEYBAR_COLLAPSED_STORAGE_KEY = "codeg:term-keybar" +const MOBILE_BREAKPOINT = "(max-width: 768px)" + +/** + * 终端面板:顶栏(tab + 折叠键栏开关)+ 所有挂载的 xterm。 + * + * 移动端键栏的可见性在面板层决定: + * · `useMediaQuery` 监听窗口宽度; + * · 折叠态持久化到 localStorage,避免多个终端 tab 各自记一份导致切 tab + * 后展开/折叠不一致(与 pios 同样的踩坑修复)。 + */ export function TerminalPanel() { const { isOpen, tabs, activeTabId, markTerminalExited } = useTerminalContext() + const isMobile = useMediaQuery(MOBILE_BREAKPOINT) + + const [keybarCollapsed, setKeybarCollapsed] = useState(() => { + if (typeof window === "undefined") return false + try { + return window.localStorage.getItem(KEYBAR_COLLAPSED_STORAGE_KEY) === "1" + } catch { + return false + } + }) + + // SSR / 初次客户端渲染时 window.matchMedia 默认 matches=false(test-setup.ts + // 的 polyfill),桌面态 hydration 一致后 useMediaQuery 才返回真实值。 + // localStorage 在 SSR 也是 undefined,所以初次 client render 与 SSR 都 + // 是「未折叠」——只有用户主动折叠过才改。 + useEffect(() => { + try { + window.localStorage.setItem( + KEYBAR_COLLAPSED_STORAGE_KEY, + keybarCollapsed ? "1" : "0" + ) + } catch { + // best effort(隐私模式 / 配额超限时静默放弃) + } + }, [keybarCollapsed]) + + const toggleKeybar = useCallback(() => { + setKeybarCollapsed((prev) => !prev) + }, []) + + const keybarVisible = isMobile && !keybarCollapsed return (
- +
{tabs.map((tab) => ( ))} diff --git a/src/components/terminal/terminal-tab-bar.tsx b/src/components/terminal/terminal-tab-bar.tsx index 281c8c73ec..63daa7ce15 100644 --- a/src/components/terminal/terminal-tab-bar.tsx +++ b/src/components/terminal/terminal-tab-bar.tsx @@ -26,7 +26,20 @@ import { TooltipTrigger, } from "@/components/ui/tooltip" -export function TerminalTabBar() { +interface TerminalTabBarProps { + /** 移动端折叠/展开键栏的开关。仅 isMobile 时父级才传 true。 */ + showKeybarToggle?: boolean + /** 当前是否已折叠(驱动 ⌨ 按钮的 active 高亮)。 */ + keybarCollapsed?: boolean + /** 点击 ⌨ 按钮的回调。 */ + onToggleKeybar?: () => void +} + +export function TerminalTabBar({ + showKeybarToggle = false, + keybarCollapsed = false, + onToggleKeybar, +}: TerminalTabBarProps) { const t = useTranslations("Folder.terminal") const ime = useImeGuard() const { shortcuts } = useShortcutSettings() @@ -156,6 +169,28 @@ export function TerminalTabBar() { )} + {showKeybarToggle && onToggleKeybar && ( + + + + + + + {keybarCollapsed ? t("keybar.show") : t("keybar.hide")} + + + + )}