fix(frontend): Disable terminal stdin if the runtime is starting up (#5625)

This commit is contained in:
sp.wack 2024-12-17 11:57:19 +04:00 committed by GitHub
parent ee8438cd59
commit b04ec03062
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 118 additions and 59 deletions

View File

@ -4,6 +4,16 @@ import { vi, describe, afterEach, it, expect } from "vitest";
import { Command, appendInput, appendOutput } from "#/state/command-slice";
import Terminal from "#/components/features/terminal/terminal";
const renderTerminal = (commands: Command[] = []) =>
renderWithProviders(<Terminal secrets={[]} />, {
preloadedState: {
cmd: {
commands,
},
},
});
describe.skip("Terminal", () => {
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
disconnect: vi.fn(),
@ -24,16 +34,6 @@ vi.mock("@xterm/xterm", async (importOriginal) => ({
Terminal: vi.fn().mockImplementation(() => mockTerminal),
}));
const renderTerminal = (commands: Command[] = []) =>
renderWithProviders(<Terminal secrets={[]} />, {
preloadedState: {
cmd: {
commands,
},
},
});
describe.skip("Terminal", () => {
afterEach(() => {
vi.clearAllMocks();
});

View File

@ -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 <div ref={ref} />;
}
@ -24,9 +23,7 @@ interface WrapperProps {
}
function Wrapper({ children }: WrapperProps) {
return (
<div>{children}</div>
)
return <div>{children}</div>;
}
describe("useTerminal", () => {

View File

@ -0,0 +1,23 @@
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import { cn } from "#/utils/utils";
export function TerminalStatusLabel() {
const { status } = useWsClient();
return (
<div className="flex items-center gap-2">
<div
className={cn(
"w-2 h-2 rounded-full",
status === WsClientProviderStatus.ACTIVE && "bg-green-500",
status !== WsClientProviderStatus.ACTIVE &&
"bg-red-500 animate-pulse",
)}
/>
Terminal
</div>
);
}

View File

@ -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 (
<div className="h-full p-2 min-h-0">

View File

@ -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;

View File

@ -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<Terminal | null>(null);
const fitAddon = React.useRef<FitAddon | null>(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;
};

View File

@ -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() {
</Container>
{/* 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. */}
<Container className="h-1/3 overflow-scroll" label="Terminal">
<Container
className="h-1/3 overflow-scroll"
label={<TerminalStatusLabel />}
>
<React.Suspense fallback={<div className="h-full" />}>
<Terminal secrets={secrets} />
</React.Suspense>