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..f810ab6180 --- /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 + /** 禁用整组按钮。当前无调用方使用,保留给「输入被阻塞时置灰」这类场景。 */ + disabled?: boolean +} + +/** + * 移动端终端虚拟键栏:两行按钮,覆盖 ESC/TAB/方向键/PgUp/PgDn + CTRL/ALT 闩锁。 + * + * 关键细节: + * · `onPointerDown` 触发 + `preventDefault`:按钮不夺走 xterm helper textarea + * 的焦点,软键盘保持弹出。 + * · CTRL/ALT 互斥闩锁:armed 状态由父级 state 控制,本组件只渲染高亮。 + * · 桌面端由父级用 `useIsMobile()` 判定后整体不挂载,此处不再二次过滤, + * 避免 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.test.tsx b/src/components/terminal/terminal-panel.test.tsx new file mode 100644 index 0000000000..bbe0324309 --- /dev/null +++ b/src/components/terminal/terminal-panel.test.tsx @@ -0,0 +1,157 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { TerminalPanel } from "./terminal-panel" + +// The panel only decides *whether* the mobile key bar is on; the tab bar and +// the xterm host are stubbed so this stays a test of that decision (and of the +// collapse state it persists), not of xterm. +vi.mock("./terminal-tab-bar", () => ({ + TerminalTabBar: ({ + showKeybarToggle, + keybarCollapsed, + onToggleKeybar, + }: { + showKeybarToggle?: boolean + keybarCollapsed?: boolean + onToggleKeybar?: () => void + }) => ( +
+ {String(showKeybarToggle)} + {String(keybarCollapsed)} + +
+ ), +})) + +vi.mock("./terminal-view", () => ({ + TerminalView: ({ + terminalId, + keybarVisible, + }: { + terminalId: string + keybarVisible?: boolean + }) => ( +
+ ), +})) + +const terminalContext = { + isOpen: true, + tabs: [{ id: "t1", title: "1", workingDir: "/tmp" }], + activeTabId: "t1", + markTerminalExited: () => {}, +} + +vi.mock("@/contexts/terminal-context", () => ({ + useTerminalContext: () => terminalContext, +})) + +const STORAGE_KEY = "codeg:term-keybar" +const DESKTOP_WIDTH = 1280 +/** The width the app's mobile shell starts at — `useIsMobile()` is max-width 767px. */ +const MOBILE_WIDTH = 767 + +const realMatchMedia = window.matchMedia + +/** + * Drive `useIsMobile()` at a real viewport width by evaluating the + * `(max-width: Npx)` query against `width`, rather than hard-coding a boolean. + * The panel used to ask for 768px while the workspace shell asks for 767px, so + * the exact boundary is the thing worth asserting. + */ +function setViewportWidth(width: number) { + window.matchMedia = ((query: string) => { + const max = /\(max-width:\s*(\d+)px\)/.exec(query) + return { + matches: max ? width <= Number(max[1]) : false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + } + }) as unknown as typeof window.matchMedia +} + +beforeEach(() => { + window.localStorage.clear() +}) + +afterEach(() => { + // Other suites render at the desktop breakpoint by default (test-setup.ts); + // put the original stub back rather than leaving ours behind. + window.matchMedia = realMatchMedia + window.localStorage.clear() +}) + +describe(" 键栏门控", () => { + it("桌面端不显示折叠开关,也不给 view 传 keybarVisible", () => { + setViewportWidth(DESKTOP_WIDTH) + render() + expect(screen.getByTestId("toggle-shown")).toHaveTextContent("false") + expect(screen.getByTestId("view-t1")).toHaveAttribute( + "data-keybar", + "false" + ) + }) + + it("移动端默认展开键栏并显示折叠开关", () => { + setViewportWidth(MOBILE_WIDTH) + render() + expect(screen.getByTestId("toggle-shown")).toHaveTextContent("true") + expect(screen.getByTestId("view-t1")).toHaveAttribute("data-keybar", "true") + }) + + it("断点与工作区外壳一致:768px 仍是桌面态,不冒出键栏", () => { + setViewportWidth(768) + render() + expect(screen.getByTestId("toggle-shown")).toHaveTextContent("false") + expect(screen.getByTestId("view-t1")).toHaveAttribute( + "data-keybar", + "false" + ) + }) + + it("折叠状态写入 localStorage,并在重新挂载后恢复", () => { + setViewportWidth(MOBILE_WIDTH) + const { unmount } = render() + + fireEvent.click(screen.getByTestId("toggle")) + expect(screen.getByTestId("view-t1")).toHaveAttribute( + "data-keybar", + "false" + ) + expect(window.localStorage.getItem(STORAGE_KEY)).toBe("1") + + unmount() + render() + expect(screen.getByTestId("collapsed")).toHaveTextContent("true") + expect(screen.getByTestId("view-t1")).toHaveAttribute( + "data-keybar", + "false" + ) + }) + + it("折叠态是面板级的:桌面端折叠后切到移动端仍然折叠", () => { + setViewportWidth(DESKTOP_WIDTH) + const { unmount } = render() + fireEvent.click(screen.getByTestId("toggle")) + expect(window.localStorage.getItem(STORAGE_KEY)).toBe("1") + unmount() + + setViewportWidth(MOBILE_WIDTH) + render() + expect(screen.getByTestId("view-t1")).toHaveAttribute( + "data-keybar", + "false" + ) + }) +}) diff --git a/src/components/terminal/terminal-panel.tsx b/src/components/terminal/terminal-panel.tsx index 40665ee4e3..fca89d1036 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 { useIsMobile } from "@/hooks/use-mobile" import { TerminalTabBar } from "./terminal-tab-bar" import { TerminalView } from "./terminal-view" +const KEYBAR_COLLAPSED_STORAGE_KEY = "codeg:term-keybar" + +/** + * 终端面板:顶栏(tab + 折叠键栏开关)+ 所有挂载的 xterm。 + * + * 移动端键栏的可见性在面板层决定: + * · `useIsMobile()` —— 必须与 workspace layout 挑选移动端外壳用的是同一个 + * 判据,否则会在断点边界上出现「桌面外壳里冒出移动端键栏」; + * · 折叠态持久化到 localStorage,避免多个终端 tab 各自记一份导致切 tab + * 后展开/折叠不一致。 + */ export function TerminalPanel() { const { isOpen, tabs, activeTabId, markTerminalExited } = useTerminalContext() + const isMobile = useIsMobile() + + 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..cb974f0a8a 100644 --- a/src/components/terminal/terminal-tab-bar.tsx +++ b/src/components/terminal/terminal-tab-bar.tsx @@ -1,7 +1,7 @@ "use client" import { useMemo, useRef, useState } from "react" -import { Minus, Plus, X } from "lucide-react" +import { Keyboard, Minus, Plus, X } from "lucide-react" import { useTranslations } from "next-intl" import { useActiveFolder } from "@/contexts/active-folder-context" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" @@ -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,29 @@ export function TerminalTabBar() { )} + {showKeybarToggle && onToggleKeybar && ( + + + + + + + {keybarCollapsed ? t("keybar.show") : t("keybar.hide")} + + + + )}