feat: 系统终端

This commit is contained in:
bietiaop
2025-02-01 20:35:01 +08:00
parent 115f19b2a5
commit fb6d828183
15 changed files with 349 additions and 259 deletions

View File

@@ -1,74 +0,0 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import clsx from 'clsx'
import { useRef } from 'react'
import { Tab } from './tabs'
interface SortableTabProps {
id: string
value: string
children: React.ReactNode
className?: string
}
export function SortableTab({
id,
value,
children,
className
}: SortableTabProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id })
const mouseDownTime = useRef<number>(0)
const mouseDownPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 1 : 0
}
const handleMouseDown = (e: React.MouseEvent) => {
mouseDownTime.current = Date.now()
mouseDownPos.current = { x: e.clientX, y: e.clientY }
}
const handleMouseUp = (e: React.MouseEvent) => {
const timeDiff = Date.now() - mouseDownTime.current
const distanceX = Math.abs(e.clientX - mouseDownPos.current.x)
const distanceY = Math.abs(e.clientY - mouseDownPos.current.y)
// 如果时间小于200ms且移动距离小于5px认为是点击而不是拖拽
if (timeDiff < 200 && distanceX < 5 && distanceY < 5) {
listeners?.onClick?.(e)
}
}
return (
<Tab
ref={setNodeRef}
style={style}
value={value}
{...attributes}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
className={clsx(
'cursor-move select-none border-b-2 transition-colors',
isDragging
? 'bg-default-100 border-primary'
: 'hover:bg-default-100 border-transparent',
className
)}
>
{children}
</Tab>
)
}

View File

@@ -1,7 +1,7 @@
import clsx from 'clsx'
import { type ReactNode, createContext, forwardRef, useContext } from 'react'
interface TabsContextValue {
export interface TabsContextValue {
activeKey: string
onChange: (key: string) => void
}
@@ -11,7 +11,7 @@ const TabsContext = createContext<TabsContextValue>({
onChange: () => {}
})
interface TabsProps {
export interface TabsProps {
activeKey: string
onChange: (key: string) => void
children: ReactNode
@@ -26,7 +26,7 @@ export function Tabs({ activeKey, onChange, children, className }: TabsProps) {
)
}
interface TabListProps {
export interface TabListProps {
children: ReactNode
className?: string
}
@@ -37,38 +37,44 @@ export function TabList({ children, className }: TabListProps) {
)
}
interface TabProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
export interface TabProps extends React.ButtonHTMLAttributes<HTMLDivElement> {
value: string
className?: string
children: ReactNode
isSelected?: boolean
}
export const Tab = forwardRef<HTMLButtonElement, TabProps>(
({ value, className, children, ...props }, ref) => {
const { activeKey, onChange } = useContext(TabsContext)
export const Tab = forwardRef<HTMLDivElement, TabProps>(
({ className, isSelected, value, ...props }, ref) => {
const { onChange } = useContext(TabsContext)
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
onChange(value)
props.onClick?.(e)
}
return (
<button
<div
ref={ref}
onClick={() => onChange(value)}
role="tab"
aria-selected={isSelected}
onClick={handleClick}
className={clsx(
'px-4 py-2 rounded-t transition-colors',
activeKey === value
? 'bg-primary text-white'
: 'hover:bg-default-100',
'px-2 py-1 flex items-center gap-1 text-sm font-medium border-b-2 transition-colors',
isSelected
? 'border-danger text-danger'
: 'border-transparent hover:border-default',
className
)}
{...props}
>
{children}
</button>
/>
)
}
)
Tab.displayName = 'Tab'
interface TabPanelProps {
export interface TabPanelProps {
value: string
children: ReactNode
className?: string

View File

@@ -0,0 +1,38 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Tab } from '@/components/tabs'
import type { TabProps } from '@/components/tabs'
interface SortableTabProps extends TabProps {
id: string
}
export function SortableTab({ id, ...props }: SortableTabProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 1 : 0,
position: 'relative' as const,
touchAction: 'none'
}
return (
<Tab
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
{...props}
/>
)
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react'
import WebUIManager from '@/controllers/webui_manager'
import TerminalManager from '@/controllers/terminal_manager'
import XTerm, { XTermRef } from '../xterm'
@@ -10,48 +10,29 @@ interface TerminalInstanceProps {
export function TerminalInstance({ id }: TerminalInstanceProps) {
const termRef = useRef<XTermRef>(null)
const wsRef = useRef<WebSocket>(null)
useEffect(() => {
const ws = WebUIManager.connectTerminal(id, (data) => {
termRef.current?.write(data)
})
wsRef.current = ws
// 添加连接状态监听
ws.onopen = () => {
console.log('Terminal connected:', id)
const handleData = (data: string) => {
try {
const parsed = JSON.parse(data)
if (parsed.data) {
termRef.current?.write(parsed.data)
}
} catch (e) {
termRef.current?.write(data)
}
}
ws.onerror = (error) => {
console.error('Terminal connection error:', error)
termRef.current?.write(
'\r\n\x1b[31mConnection error. Please try reconnecting.\x1b[0m\r\n'
)
}
ws.onclose = () => {
console.log('Terminal disconnected:', id)
termRef.current?.write('\r\n\x1b[31mConnection closed.\x1b[0m\r\n')
}
TerminalManager.connectTerminal(id, handleData)
return () => {
ws.close()
TerminalManager.disconnectTerminal(id, handleData)
}
}, [id])
const handleInput = (data: string) => {
const ws = wsRef.current
if (ws?.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({ type: 'input', data }))
} catch (error) {
console.error('Failed to send terminal input:', error)
}
} else {
console.warn('WebSocket is not in OPEN state')
}
TerminalManager.sendInput(id, data)
}
return <XTerm ref={termRef} onInput={handleInput} className="h-full" />
return <XTerm ref={termRef} onInput={handleInput} className="w-full h-full" />
}

View File

@@ -25,12 +25,13 @@ export type XTermRef = {
export interface XTermProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onInput'> {
onInput?: (data: string) => void
onKey?: (key: string, event: KeyboardEvent) => void
}
const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
const domRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null)
const { className, onInput, ...rest } = props
const { className, onInput, onKey, ...rest } = props
const { theme } = useTheme()
useEffect(() => {
if (!domRef.current) {
@@ -40,9 +41,7 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
allowTransparency: true,
fontFamily: '"Fira Code", "Harmony", "Noto Serif SC", monospace',
cursorInactiveStyle: 'outline',
drawBoldTextInBrightColors: false,
letterSpacing: 0,
lineHeight: 1.0
drawBoldTextInBrightColors: false
})
terminalRef.current = terminal
const fitAddon = new FitAddon()
@@ -74,6 +73,12 @@ const XTerm = forwardRef<XTermRef, XTermProps>((props, ref) => {
}
})
terminal.onKey((event) => {
if (onKey) {
onKey(event.key, event.domEvent)
}
})
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit()
})