diff --git a/frontend/__tests__/components/terminal/terminal.test.tsx b/frontend/__tests__/components/terminal/terminal.test.tsx index 055aa4433c..d8c47d0097 100644 --- a/frontend/__tests__/components/terminal/terminal.test.tsx +++ b/frontend/__tests__/components/terminal/terminal.test.tsx @@ -4,26 +4,6 @@ import { vi, describe, afterEach, it, expect } from "vitest"; import { Command, appendInput, appendOutput } from "#/state/command-slice"; import Terminal from "#/components/features/terminal/terminal"; -global.ResizeObserver = vi.fn().mockImplementation(() => ({ - observe: vi.fn(), - disconnect: vi.fn(), -})); - -const mockTerminal = { - open: vi.fn(), - write: vi.fn(), - writeln: vi.fn(), - dispose: vi.fn(), - onKey: vi.fn(), - attachCustomKeyEventHandler: vi.fn(), - loadAddon: vi.fn(), -}; - -vi.mock("@xterm/xterm", async (importOriginal) => ({ - ...(await importOriginal()), - Terminal: vi.fn().mockImplementation(() => mockTerminal), -})); - const renderTerminal = (commands: Command[] = []) => renderWithProviders(, { preloadedState: { @@ -34,6 +14,26 @@ const renderTerminal = (commands: Command[] = []) => }); describe.skip("Terminal", () => { + global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + disconnect: vi.fn(), + })); + + const mockTerminal = { + open: vi.fn(), + write: vi.fn(), + writeln: vi.fn(), + dispose: vi.fn(), + onKey: vi.fn(), + attachCustomKeyEventHandler: vi.fn(), + loadAddon: vi.fn(), + }; + + vi.mock("@xterm/xterm", async (importOriginal) => ({ + ...(await importOriginal()), + Terminal: vi.fn().mockImplementation(() => mockTerminal), + })); + afterEach(() => { vi.clearAllMocks(); }); diff --git a/frontend/__tests__/hooks/use-terminal.test.tsx b/frontend/__tests__/hooks/use-terminal.test.tsx index 6f3f3e4872..137cfc60c4 100644 --- a/frontend/__tests__/hooks/use-terminal.test.tsx +++ b/frontend/__tests__/hooks/use-terminal.test.tsx @@ -5,7 +5,6 @@ import { ReactNode } from "react"; import { useTerminal } from "#/hooks/use-terminal"; import { Command } from "#/state/command-slice"; - interface TestTerminalComponentProps { commands: Command[]; secrets: string[]; @@ -15,7 +14,7 @@ function TestTerminalComponent({ commands, secrets, }: TestTerminalComponentProps) { - const ref = useTerminal(commands, secrets); + const ref = useTerminal({ commands, secrets, disabled: false }); return
; } @@ -24,9 +23,7 @@ interface WrapperProps { } function Wrapper({ children }: WrapperProps) { - return ( -
{children}
- ) + return
{children}
; } describe("useTerminal", () => { diff --git a/frontend/src/components/features/terminal/terminal-status-label.tsx b/frontend/src/components/features/terminal/terminal-status-label.tsx new file mode 100644 index 0000000000..5c245cf5f3 --- /dev/null +++ b/frontend/src/components/features/terminal/terminal-status-label.tsx @@ -0,0 +1,23 @@ +import { + useWsClient, + WsClientProviderStatus, +} from "#/context/ws-client-provider"; +import { cn } from "#/utils/utils"; + +export function TerminalStatusLabel() { + const { status } = useWsClient(); + + return ( +
+
+ Terminal +
+ ); +} diff --git a/frontend/src/components/features/terminal/terminal.tsx b/frontend/src/components/features/terminal/terminal.tsx index bd8283b890..83afc05eaa 100644 --- a/frontend/src/components/features/terminal/terminal.tsx +++ b/frontend/src/components/features/terminal/terminal.tsx @@ -1,16 +1,25 @@ import { useSelector } from "react-redux"; import { RootState } from "#/store"; import { useTerminal } from "#/hooks/use-terminal"; - import "@xterm/xterm/css/xterm.css"; +import { + useWsClient, + WsClientProviderStatus, +} from "#/context/ws-client-provider"; interface TerminalProps { secrets: string[]; } function Terminal({ secrets }: TerminalProps) { + const { status } = useWsClient(); const { commands } = useSelector((state: RootState) => state.cmd); - const ref = useTerminal(commands, secrets); + + const ref = useTerminal({ + commands, + secrets, + disabled: status === WsClientProviderStatus.OPENING, + }); return (
diff --git a/frontend/src/components/layout/container.tsx b/frontend/src/components/layout/container.tsx index f2dcb39a3f..d4d3a9ac6c 100644 --- a/frontend/src/components/layout/container.tsx +++ b/frontend/src/components/layout/container.tsx @@ -3,7 +3,7 @@ import React from "react"; import { NavTab } from "./nav-tab"; interface ContainerProps { - label?: string; + label?: React.ReactNode; labels?: { label: string | React.ReactNode; to: string; diff --git a/frontend/src/hooks/use-terminal.ts b/frontend/src/hooks/use-terminal.ts index bc351d34f5..464e153478 100644 --- a/frontend/src/hooks/use-terminal.ts +++ b/frontend/src/hooks/use-terminal.ts @@ -11,10 +11,23 @@ import { useWsClient } from "#/context/ws-client-provider"; The reason for this is that the hook exposes a ref that requires a DOM element to be rendered. */ -export const useTerminal = ( - commands: Command[] = [], - secrets: string[] = [], -) => { +interface UseTerminalConfig { + commands: Command[]; + secrets: string[]; + disabled: boolean; +} + +const DEFAULT_TERMINAL_CONFIG: UseTerminalConfig = { + commands: [], + secrets: [], + disabled: false, +}; + +export const useTerminal = ({ + commands, + secrets, + disabled, +}: UseTerminalConfig = DEFAULT_TERMINAL_CONFIG) => { const { send } = useWsClient(); const terminal = React.useRef(null); const fitAddon = React.useRef(null); @@ -85,36 +98,12 @@ export const useTerminal = ( terminal.current = createTerminal(); fitAddon.current = new FitAddon(); - let resizeObserver: ResizeObserver; - let commandBuffer = ""; + let resizeObserver: ResizeObserver | null = null; if (ref.current) { /* Initialize the terminal in the DOM */ initializeTerminal(); - terminal.current.write("$ "); - terminal.current.onKey(({ key, domEvent }) => { - if (domEvent.key === "Enter") { - handleEnter(commandBuffer); - commandBuffer = ""; - } else if (domEvent.key === "Backspace") { - if (commandBuffer.length > 0) { - commandBuffer = handleBackspace(commandBuffer); - } - } else { - // Ignore paste event - if (key.charCodeAt(0) === 22) { - return; - } - commandBuffer += key; - terminal.current?.write(key); - } - }); - terminal.current.attachCustomKeyEventHandler((event) => - pasteHandler(event, (text) => { - commandBuffer += text; - }), - ); /* Listen for resize events */ resizeObserver = new ResizeObserver(() => { @@ -125,7 +114,7 @@ export const useTerminal = ( return () => { terminal.current?.dispose(); - resizeObserver.disconnect(); + resizeObserver?.disconnect(); }; }, []); @@ -152,5 +141,42 @@ export const useTerminal = ( } }, [commands]); + React.useEffect(() => { + if (terminal.current) { + let commandBuffer = ""; + + if (!disabled) { + terminal.current.onKey(({ key, domEvent }) => { + if (domEvent.key === "Enter") { + handleEnter(commandBuffer); + commandBuffer = ""; + } else if (domEvent.key === "Backspace") { + if (commandBuffer.length > 0) { + commandBuffer = handleBackspace(commandBuffer); + } + } else { + // Ignore paste event + if (key.charCodeAt(0) === 22) { + return; + } + commandBuffer += key; + terminal.current?.write(key); + } + }); + + terminal.current.attachCustomKeyEventHandler((event) => + pasteHandler(event, (text) => { + commandBuffer += text; + }), + ); + } else { + terminal.current.onKey((e) => { + e.domEvent.preventDefault(); + e.domEvent.stopPropagation(); + }); + } + } + }, [disabled, terminal]); + return ref; }; diff --git a/frontend/src/routes/_oh.app/route.tsx b/frontend/src/routes/_oh.app/route.tsx index 8541ec45cc..b83e863dd8 100644 --- a/frontend/src/routes/_oh.app/route.tsx +++ b/frontend/src/routes/_oh.app/route.tsx @@ -22,6 +22,7 @@ import { useConversationConfig } from "#/hooks/query/use-conversation-config"; import { Container } from "#/components/layout/container"; import Security from "#/components/shared/modals/security/security"; import { CountBadge } from "#/components/layout/count-badge"; +import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label"; function App() { const { token, gitHubToken } = useAuth(); @@ -101,7 +102,10 @@ function App() { {/* Terminal uses some API that is not compatible in a server-environment. For this reason, we lazy load it to ensure * that it loads only in the client-side. */} - + } + > }>