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")} + + + + )}