diff --git a/.changeset/quiet-flies-report.md b/.changeset/quiet-flies-report.md new file mode 100644 index 000000000..0b877c99e --- /dev/null +++ b/.changeset/quiet-flies-report.md @@ -0,0 +1,5 @@ +--- +'@halfdomelabs/project-builder-web': patch +--- + +Replace @cocalc/ansi-to-react with the anser library directly diff --git a/packages/project-builder-web/package.json b/packages/project-builder-web/package.json index f68ef59bb..22b016862 100644 --- a/packages/project-builder-web/package.json +++ b/packages/project-builder-web/package.json @@ -34,7 +34,6 @@ ] }, "dependencies": { - "@cocalc/ansi-to-react": "^7.0.0", "@dnd-kit/core": "^6.0.8", "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", @@ -51,6 +50,7 @@ "@trpc/server": "^10.44.0", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "anser": "2.3.2", "axios": "^1.8.3", "clsx": "2.1.1", "culori": "^4.0.1", diff --git a/packages/project-builder-web/src/app/AppLayout/ProjectSyncModal.tsx b/packages/project-builder-web/src/app/AppLayout/ProjectSyncModal.tsx index c50753148..fda7b3343 100644 --- a/packages/project-builder-web/src/app/AppLayout/ProjectSyncModal.tsx +++ b/packages/project-builder-web/src/app/AppLayout/ProjectSyncModal.tsx @@ -1,5 +1,4 @@ import type React from 'react'; -import type { ConsoleRef } from 'src/components/Console'; import { useBlockBeforeContinue, @@ -9,10 +8,12 @@ import { Button, Dialog, toast } from '@halfdomelabs/ui-components'; import clsx from 'clsx'; import { useRef, useState } from 'react'; import { MdSync } from 'react-icons/md'; -import Console from 'src/components/Console'; import { startSync } from 'src/services/api'; import { formatError } from 'src/services/error-formatter'; +import type { ConsoleRef } from '@src/components'; + +import { Console } from '@src/components'; import { useProjects } from '@src/hooks/useProjects'; interface Props { diff --git a/packages/project-builder-web/src/components/AnsiText/AnsiText.tsx b/packages/project-builder-web/src/components/AnsiText/AnsiText.tsx new file mode 100644 index 000000000..8ca9b7fd7 --- /dev/null +++ b/packages/project-builder-web/src/components/AnsiText/AnsiText.tsx @@ -0,0 +1,83 @@ +import type React from 'react'; + +import anser from 'anser'; + +interface AnsiTextProps { + text: string; +} + +/** + * Component to render ANSI colored text using anser + * Uses inline styles only, no CSS classes + * + * @param {AnsiTextProps} props - Component props + * @param {string} props.text - Text with ANSI escape sequences + * @returns {JSX.Element} Rendered ANSI text with proper styling + */ +export function AnsiText({ text }: AnsiTextProps): React.JSX.Element { + // Parse ANSI escape sequences + const parsedLines = text.split('\n').map((line, i) => { + // Use ansiToJson with use_classes: false to get RGB values + const parsed = anser.ansiToJson(line, { use_classes: false }); + + return ( +
+ {parsed.map((part, j) => { + // Extract all decorations from the part + const { decorations } = part; + + // Create the style object + const style: React.CSSProperties = {}; + + // Add foreground color + if (part.fg) { + style.color = `rgb(${part.fg})`; + } + + // Add background color + if (part.bg) { + style.backgroundColor = `rgb(${part.bg})`; + } + + // Add text decorations + if (decorations.includes('bold')) { + style.fontWeight = 'bold'; + } + + if (decorations.includes('italic')) { + style.fontStyle = 'italic'; + } + + if (decorations.includes('dim')) { + style.opacity = 0.5; + } + + if (decorations.includes('hidden')) { + style.visibility = 'hidden'; + } + + // Combine underline and strikethrough if both are present + const textDecorations = []; + if (decorations.includes('underline')) + textDecorations.push('underline'); + if (decorations.includes('strikethrough')) + textDecorations.push('line-through'); + if (decorations.includes('blink')) textDecorations.push('blink'); + + if (textDecorations.length > 0) { + style.textDecoration = textDecorations.join(' '); + } + + // Return the styled span + return ( + + {part.content} + + ); + })} +
+ ); + }); + + return <>{parsedLines}; +} diff --git a/packages/project-builder-web/src/components/AnsiText/AnsiText.unit.test.tsx b/packages/project-builder-web/src/components/AnsiText/AnsiText.unit.test.tsx new file mode 100644 index 000000000..e01572e23 --- /dev/null +++ b/packages/project-builder-web/src/components/AnsiText/AnsiText.unit.test.tsx @@ -0,0 +1,156 @@ +import type { AnserJsonEntry } from 'anser'; + +import { render, screen } from '@testing-library/react'; +import anser from 'anser'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AnsiText } from './AnsiText'; + +// Mock the anser library +vi.mock('anser', () => ({ + default: { + ansiToJson: vi.fn(), + }, +})); + +// Helper function to create properly typed mock entries +function createMockAnserEntry( + options: Partial = {}, +): AnserJsonEntry { + return { + content: options.content ?? '', + fg: options.fg ?? '', + bg: options.bg ?? '', + fg_truecolor: options.fg_truecolor ?? '', + bg_truecolor: options.bg_truecolor ?? '', + decorations: options.decorations ?? [], + was_processed: options.was_processed ?? false, + clearLine: options.clearLine ?? false, + isEmpty: options.isEmpty ?? (() => false), + decoration: options.decoration ?? null, + }; +} + +// Properly type the mocked function +const mockedAnser = vi.mocked(anser, true); + +describe('AnsiText', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders plain text without styles', () => { + const text = 'Hello, world!'; + + // Mock ansiToJson to return plain text + mockedAnser.ansiToJson.mockReturnValueOnce([ + createMockAnserEntry({ content: text }), + ]); + + render(); + + expect(screen.getByText(text)).toBeInTheDocument(); + expect(mockedAnser.ansiToJson).toHaveBeenCalledWith(text, { + use_classes: false, + }); + }); + + it('applies colors to text', () => { + const text = 'Colored text'; + + // Mock ansiToJson to return text with foreground and background colors + mockedAnser.ansiToJson.mockReturnValueOnce([ + createMockAnserEntry({ + content: text, + fg: '255, 0, 0', + bg: '0, 0, 255', + }), + ]); + + render(); + + const element = screen.getByText(text); + expect(element).toBeInTheDocument(); + expect(element).toHaveStyle({ + color: 'rgb(255, 0, 0)', + 'background-color': 'rgb(0, 0, 255)', + }); + }); + + it('applies text decorations correctly', () => { + const text = 'Decorated text'; + + // Mock ansiToJson to return text with multiple decorations + mockedAnser.ansiToJson.mockReturnValueOnce([ + createMockAnserEntry({ + content: text, + decorations: ['bold', 'italic', 'underline'], + }), + ]); + + render(); + + const element = screen.getByText(text); + expect(element).toBeInTheDocument(); + expect(element).toHaveStyle({ + 'font-weight': 'bold', + 'font-style': 'italic', + 'text-decoration': 'underline', + }); + }); + + it('handles multiple segments in a line', () => { + // Mock ansiToJson to return multiple segments with different styles + mockedAnser.ansiToJson.mockReturnValueOnce([ + createMockAnserEntry({ + content: 'Red', + fg: '255, 0, 0', + }), + createMockAnserEntry({ + content: 'Bold', + decorations: ['bold'], + fg: '0, 0, 255', + }), + ]); + + render(); + + const redSegment = screen.getByText('Red'); + const boldSegment = screen.getByText('Bold'); + + expect(redSegment).toHaveStyle('color: rgb(255, 0, 0)'); + expect(boldSegment).toHaveStyle({ + color: 'rgb(0, 0, 255)', + 'font-weight': 'bold', + }); + }); + + it('renders multiple lines correctly', () => { + // Set up a mock for multiline text + const multilineText = 'Line 1\nLine 2'; + + // First call for Line 1 + mockedAnser.ansiToJson + .mockReturnValueOnce([ + createMockAnserEntry({ + content: 'Line 1', + decorations: ['bold'], + }), + ]) + // Second call for Line 2 + .mockReturnValueOnce([ + createMockAnserEntry({ + content: 'Line 2', + decorations: ['italic'], + }), + ]); + + render(); + + expect(screen.getByText('Line 1')).toHaveStyle('font-weight: bold'); + expect(screen.getByText('Line 2')).toHaveStyle('font-style: italic'); + + // Verify ansiToJson was called once per line + expect(mockedAnser.ansiToJson).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/project-builder-web/src/components/Console/index.tsx b/packages/project-builder-web/src/components/Console/Console.tsx similarity index 93% rename from packages/project-builder-web/src/components/Console/index.tsx rename to packages/project-builder-web/src/components/Console/Console.tsx index 9a86cc5c4..c2113b121 100644 --- a/packages/project-builder-web/src/components/Console/index.tsx +++ b/packages/project-builder-web/src/components/Console/Console.tsx @@ -1,6 +1,5 @@ import type { UIEventHandler } from 'react'; -import Ansi from '@cocalc/ansi-to-react'; import clsx from 'clsx'; import { forwardRef, @@ -13,6 +12,8 @@ import { import { useProjects } from '@src/hooks/useProjects'; import { trpc } from '@src/services/trpc'; +import { AnsiText } from '../AnsiText/AnsiText'; + interface Props { className?: string; } @@ -21,7 +22,7 @@ export interface ConsoleRef { clearConsole: () => void; } -const Console = forwardRef(({ className }, ref) => { +export const Console = forwardRef(({ className }, ref) => { const [consoleText, setConsoleText] = useState(''); useImperativeHandle(ref, () => ({ @@ -90,12 +91,10 @@ const Console = forwardRef(({ className }, ref) => { ref={codeRef} onScroll={handleScroll} > - {consoleText} +
); }); Console.displayName = 'Console'; - -export default Console; diff --git a/packages/project-builder-web/src/components/index.ts b/packages/project-builder-web/src/components/index.ts index 5bc2fadfe..6e744d192 100644 --- a/packages/project-builder-web/src/components/index.ts +++ b/packages/project-builder-web/src/components/index.ts @@ -1,7 +1,9 @@ export { default as Alert } from './Alert'; export { default as AlertIcon } from './AlertIcon'; +export * from './AnsiText/AnsiText'; export * from './BlockerDialog/BlockerDialog'; export { default as Button } from './Button'; +export * from './Console/Console'; export { default as FormActionBar } from './FormActionBar'; export { default as FormError } from './FormError'; export { default as FormLabel } from './FormLabel'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ed973e9f..91bf254b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -660,9 +660,6 @@ importers: packages/project-builder-web: dependencies: - '@cocalc/ansi-to-react': - specifier: ^7.0.0 - version: 7.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@dnd-kit/core': specifier: ^6.0.8 version: 6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -711,6 +708,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 18.3.0 + anser: + specifier: 2.3.2 + version: 2.3.2 axios: specifier: ^1.8.3 version: 1.8.3 @@ -1533,12 +1533,6 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@cocalc/ansi-to-react@7.0.0': - resolution: {integrity: sha512-FOuHtOnuBtqTZSPR78Zg5w86/n+WJ/AOd0Y0PTh7Sx2TttyN3KjXRD8gSD8zEp1Ewf3Qv30tP3m8kNoPQa3lTw==} - peerDependencies: - react: ^16.3.2 || ^17.0.0 || ^18.0.0 - react-dom: ^16.3.2 || ^17.0.0 || ^18.0.0 - '@csstools/color-helpers@5.0.1': resolution: {integrity: sha512-MKtmkA0BX87PKaO1NFRTFH+UnkgnmySQOvNxJubsadusqPEC2aJ9MOQiMceZJJ6oitUl/i0L6u0M1IrmAOmgBA==} engines: {node: '>=18'} @@ -3366,8 +3360,8 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - anser@2.1.1: - resolution: {integrity: sha512-nqLm4HxOTpeLOxcmB3QWmV5TcDFhW9y/fyQ+hivtDFcK4OQ+pQ5fzPnXHM1Mfcm0VkLtvVi1TCPr++Qy0Q/3EQ==} + anser@2.3.2: + resolution: {integrity: sha512-PMqBCBvrOVDRqLGooQb+z+t1Q0PiPyurUQeZRR5uHBOVZcW8B04KMmnT12USnhpNX2wCPagWzLVppQMUG3u0Dw==} ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} @@ -4120,9 +4114,6 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - escape-carriage@1.3.1: - resolution: {integrity: sha512-GwBr6yViW3ttx1kb7/Oh+gKQ1/TrhYwxKqVmg5gS+BK+Qe2KrOa/Vh7w3HPBvgGf0LfcDGoY9I6NHKoA5Hozhw==} - escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -7176,13 +7167,6 @@ snapshots: human-id: 4.1.1 prettier: 2.8.8 - '@cocalc/ansi-to-react@7.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - anser: 2.1.1 - escape-carriage: 1.3.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - '@csstools/color-helpers@5.0.1': {} '@csstools/css-calc@2.1.1(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': @@ -9112,7 +9096,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - anser@2.1.1: {} + anser@2.3.2: {} ansi-colors@4.1.3: {} @@ -9975,8 +9959,6 @@ snapshots: escalade@3.2.0: {} - escape-carriage@1.3.1: {} - escape-html@1.0.3: {} escape-string-regexp@1.0.5: {}