Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-flies-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@halfdomelabs/project-builder-web': patch
---

Replace @cocalc/ansi-to-react with the anser library directly
2 changes: 1 addition & 1 deletion packages/project-builder-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type React from 'react';
import type { ConsoleRef } from 'src/components/Console';

import {
useBlockBeforeContinue,
Expand All @@ -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 {
Expand Down
83 changes: 83 additions & 0 deletions packages/project-builder-web/src/components/AnsiText/AnsiText.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div key={i}>
{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 (
<span key={j} style={style}>
{part.content}
</span>
);
})}
</div>
);
});

return <>{parsedLines}</>;
}
Original file line number Diff line number Diff line change
@@ -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> = {},
): 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(<AnsiText text={text} />);

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(<AnsiText text={text} />);

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(<AnsiText text={text} />);

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(<AnsiText text="Red Bold" />);

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(<AnsiText text={multilineText} />);

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);
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { UIEventHandler } from 'react';

import Ansi from '@cocalc/ansi-to-react';
import clsx from 'clsx';
import {
forwardRef,
Expand All @@ -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;
}
Expand All @@ -21,7 +22,7 @@ export interface ConsoleRef {
clearConsole: () => void;
}

const Console = forwardRef<ConsoleRef, Props>(({ className }, ref) => {
export const Console = forwardRef<ConsoleRef, Props>(({ className }, ref) => {
const [consoleText, setConsoleText] = useState('');

useImperativeHandle(ref, () => ({
Expand Down Expand Up @@ -90,12 +91,10 @@ const Console = forwardRef<ConsoleRef, Props>(({ className }, ref) => {
ref={codeRef}
onScroll={handleScroll}
>
<Ansi>{consoleText}</Ansi>
<AnsiText text={consoleText} />
<div ref={bottomRef} />
</code>
);
});

Console.displayName = 'Console';

export default Console;
2 changes: 2 additions & 0 deletions packages/project-builder-web/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Loading
Loading