Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app/workspace/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -683,7 +683,7 @@ function MobileFolderWorkspaceShell({
swipeDirection="down"
disablePointerDismissal={false}
>
<DrawerContent showCloseButton={false} className="h-[70vh] p-0">
<DrawerContent showCloseButton={false} className="h-[95vh] p-0">
<DrawerTitle className="sr-only">Terminal</DrawerTitle>
<div className="h-full min-h-0 overflow-hidden">
<TerminalPanel />
Expand Down
99 changes: 99 additions & 0 deletions src/components/terminal/term-keybar.test.tsx
Original file line number Diff line number Diff line change
@@ -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("<TermKeybar />", () => {
it("renders all 12 keys + 2 modifier buttons", () => {
render(
<TermKeybar mods={NO_MODS} onToggleMod={() => {}} 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(
<TermKeybar
mods={NO_MODS}
onToggleMod={() => {}}
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(
<TermKeybar
mods={NO_MODS}
onToggleMod={onToggleMod}
onPressKey={() => {}}
/>
)

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(
<TermKeybar
mods={{ ctrl: true, alt: false }}
onToggleMod={() => {}}
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(
<TermKeybar
mods={NO_MODS}
onToggleMod={() => {}}
onPressKey={() => {}}
disabled
/>
)
const all = screen.getAllByRole("button")
for (const btn of all) {
expect(btn).toBeDisabled()
}
})
})
201 changes: 201 additions & 0 deletions src/components/terminal/term-keybar.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>) => {
e.preventDefault()
onPressKey(key)
}

const handleModPointerDown =
(mod: "ctrl" | "alt") => (e: PointerEvent<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
e.preventDefault()
}

return (
<div
role="toolbar"
aria-label={t("label")}
className="flex shrink-0 flex-col gap-1.5 border-t pt-1.5 select-none"
>
<div className="flex gap-1.5">
<KeyBtn
label={t("esc")}
onPointerDown={handlePointerDown("esc")}
onClick={swallowClick}
disabled={disabled}
/>
<KeyBtn
label={t("slash")}
onPointerDown={handlePointerDown("slash")}
onClick={swallowClick}
disabled={disabled}
/>
<KeyBtn
label={t("dash")}
onPointerDown={handlePointerDown("dash")}
onClick={swallowClick}
disabled={disabled}
/>
<KeyBtn
label={t("home")}
onPointerDown={handlePointerDown("home")}
onClick={swallowClick}
disabled={disabled}
/>
<KeyBtn
label={t("up")}
onPointerDown={handlePointerDown("up")}
onClick={swallowClick}
disabled={disabled}
/>
<KeyBtn
label={t("end")}
onPointerDown={handlePointerDown("end")}
onClick={swallowClick}
disabled={disabled}
/>
<KeyBtn
label={t("pgup")}
onPointerDown={handlePointerDown("pgup")}
onClick={swallowClick}
disabled={disabled}
/>
</div>
<div className="flex gap-1.5">
<KeyBtn
label={t("tab")}
onPointerDown={handlePointerDown("tab")}
onClick={swallowClick}
disabled={disabled}
/>
<KeyBtn
label={t("ctrl")}
active={mods.ctrl}
onPointerDown={handleModPointerDown("ctrl")}
onClick={swallowClick}
disabled={disabled}
/>
<KeyBtn
label={t("alt")}
active={mods.alt}
onPointerDown={handleModPointerDown("alt")}
onClick={swallowClick}
disabled={disabled}
/>
<KeyBtn
label={t("left")}
onPointerDown={handlePointerDown("left")}
onClick={swallowClick}
disabled={disabled}
/>
<KeyBtn
label={t("down")}
onPointerDown={handlePointerDown("down")}
onClick={swallowClick}
disabled={disabled}
/>
<KeyBtn
label={t("right")}
onPointerDown={handlePointerDown("right")}
onClick={swallowClick}
disabled={disabled}
/>
<KeyBtn
label={t("pgdn")}
onPointerDown={handlePointerDown("pgdn")}
onClick={swallowClick}
disabled={disabled}
/>
</div>
</div>
)
}

interface KeyBtnProps {
label: string
active?: boolean
disabled?: boolean
onPointerDown: (e: PointerEvent<HTMLButtonElement>) => void
onClick: (e: MouseEvent<HTMLButtonElement>) => 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 (
<Button
type="button"
tabIndex={-1}
variant="outline"
size="sm"
disabled={disabled}
onPointerDown={onPointerDown}
onClick={onClick}
className={cn(
"min-w-0 flex-1 px-1 text-xs font-normal touch-manipulation",
"[-webkit-tap-highlight-color:transparent] [transition:transform_0.12s,background-color_0.12s]",
"active:scale-95",
active && "bg-primary text-primary-foreground border-primary"
)}
>
{label}
</Button>
)
}
51 changes: 50 additions & 1 deletion src/components/terminal/terminal-panel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section
data-terminal-panel-region="true"
className="flex h-full min-h-0 flex-col ws-surface"
>
<TerminalTabBar />
<TerminalTabBar
showKeybarToggle={isMobile}
keybarCollapsed={keybarCollapsed}
onToggleKeybar={toggleKeybar}
/>
<div className="relative flex-1 min-h-0 overflow-hidden">
{tabs.map((tab) => (
<TerminalView
Expand All @@ -23,6 +71,7 @@ export function TerminalPanel() {
initialCommand={tab.initialCommand}
isActive={tab.id === activeTabId}
isVisible={isOpen}
keybarVisible={keybarVisible}
onProcessExited={markTerminalExited}
/>
))}
Expand Down
Loading