Browse Source

feat: Add CommandOutputViewer component and integrate it into ChatRow (#2326)

* feat: Add CommandOutputViewer component and integrate it into ChatRow

* Remove unnecessary memo wrapper from CommandOutputViewer component
Sam Hoang Van 11 months ago
parent
commit
e1f6eb625b

+ 63 - 0
webview-ui/src/__tests__/components/common/CommandOutputViewer.test.tsx

@@ -0,0 +1,63 @@
+import React from "react"
+import { render, screen } from "@testing-library/react"
+import CommandOutputViewer from "../../../components/common/CommandOutputViewer"
+
+// Mock the cn utility function
+jest.mock("../../../lib/utils", () => ({
+	cn: (...inputs: any[]) => inputs.filter(Boolean).join(" "),
+}))
+
+// Mock the Virtuoso component
+jest.mock("react-virtuoso", () => ({
+	Virtuoso: React.forwardRef(({ totalCount, itemContent }: any, ref: any) => (
+		<div ref={ref} data-testid="virtuoso-container">
+			{Array.from({ length: totalCount }).map((_, index) => (
+				<div key={index} data-testid={`virtuoso-item-${index}`}>
+					{itemContent(index)}
+				</div>
+			))}
+		</div>
+	)),
+	VirtuosoHandle: jest.fn(),
+}))
+
+describe("CommandOutputViewer", () => {
+	it("renders command output with virtualized list", () => {
+		const testOutput = "Line 1\nLine 2\nLine 3"
+
+		render(<CommandOutputViewer output={testOutput} />)
+
+		// Check if Virtuoso container is rendered
+		expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument()
+
+		// Check if all lines are rendered
+		expect(screen.getByText("Line 1")).toBeInTheDocument()
+		expect(screen.getByText("Line 2")).toBeInTheDocument()
+		expect(screen.getByText("Line 3")).toBeInTheDocument()
+	})
+
+	it("handles empty output", () => {
+		render(<CommandOutputViewer output="" />)
+
+		// Should still render the container but with no items
+		expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument()
+
+		// No virtuoso items should be rendered for empty string (which creates one empty line)
+		expect(screen.getByTestId("virtuoso-item-0")).toBeInTheDocument()
+		expect(screen.queryByTestId("virtuoso-item-1")).not.toBeInTheDocument()
+	})
+
+	it("handles large output", () => {
+		// Create a large output with 1000 lines
+		const largeOutput = Array.from({ length: 1000 }, (_, i) => `Line ${i + 1}`).join("\n")
+
+		render(<CommandOutputViewer output={largeOutput} />)
+
+		// Check if Virtuoso container is rendered
+		expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument()
+
+		// Check if first and last lines are rendered
+		expect(screen.getByText("Line 1")).toBeInTheDocument()
+		expect(screen.getByText("Line 1000")).toBeInTheDocument()
+	})
+})

+ 2 - 1
webview-ui/src/components/chat/ChatRow.tsx

@@ -16,6 +16,7 @@ import { findMatchingResourceOrTemplate } from "../../utils/mcp"
 import { vscode } from "../../utils/vscode"
 import { vscode } from "../../utils/vscode"
 import CodeAccordian, { removeLeadingNonAlphanumeric } from "../common/CodeAccordian"
 import CodeAccordian, { removeLeadingNonAlphanumeric } from "../common/CodeAccordian"
 import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
 import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
+import CommandOutputViewer from "../common/CommandOutputViewer"
 import MarkdownBlock from "../common/MarkdownBlock"
 import MarkdownBlock from "../common/MarkdownBlock"
 import { ReasoningBlock } from "./ReasoningBlock"
 import { ReasoningBlock } from "./ReasoningBlock"
 import Thumbnails from "../common/Thumbnails"
 import Thumbnails from "../common/Thumbnails"
@@ -917,7 +918,7 @@ export const ChatRowContent = ({
 												className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`}></span>
 												className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`}></span>
 											<span style={{ fontSize: "0.8em" }}>{t("chat:commandOutput")}</span>
 											<span style={{ fontSize: "0.8em" }}>{t("chat:commandOutput")}</span>
 										</div>
 										</div>
-										{isExpanded && <CodeBlock source={`${"```"}shell\n${output}\n${"```"}`} />}
+										{isExpanded && <CommandOutputViewer output={output} />}
 									</div>
 									</div>
 								)}
 								)}
 							</div>
 							</div>

+ 50 - 0
webview-ui/src/components/common/CommandOutputViewer.tsx

@@ -0,0 +1,50 @@
+import { forwardRef, useEffect, useRef } from "react"
+import { Virtuoso, VirtuosoHandle } from "react-virtuoso"
+import { cn } from "../../lib/utils"
+
+interface CommandOutputViewerProps {
+	output: string
+}
+
+const CommandOutputViewer = forwardRef<HTMLDivElement, CommandOutputViewerProps>(({ output }, ref) => {
+	const virtuosoRef = useRef<VirtuosoHandle>(null)
+	const lines = output.split("\n")
+
+	useEffect(() => {
+		// Scroll to the bottom when output changes
+		if (virtuosoRef.current && typeof virtuosoRef.current.scrollToIndex === "function") {
+			virtuosoRef.current.scrollToIndex({
+				index: lines.length - 1,
+				behavior: "auto",
+			})
+		}
+	}, [output, lines.length])
+
+	return (
+		<div ref={ref} className="w-full rounded-b-md bg-[var(--vscode-editor-background)] h-[300px]">
+			<Virtuoso
+				ref={virtuosoRef}
+				className="h-full"
+				totalCount={lines.length}
+				itemContent={(index) => (
+					<div
+						className={cn(
+							"px-3 py-0.5",
+							"font-mono text-vscode-editor-foreground",
+							"text-[var(--vscode-editor-font-size,var(--vscode-font-size,12px))]",
+							"font-[var(--vscode-editor-font-family)]",
+							"whitespace-pre-wrap break-all anywhere",
+						)}>
+						{lines[index]}
+					</div>
+				)}
+				increaseViewportBy={{ top: 300, bottom: 300 }}
+				followOutput="auto"
+			/>
+		</div>
+	)
+})
+
+CommandOutputViewer.displayName = "CommandOutputViewer"
+
+export default CommandOutputViewer