Skip to content
Open
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
14 changes: 14 additions & 0 deletions src/__tests__/renderer/components/CodeFence.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,4 +193,18 @@ describe('CodeFence', () => {
vi.useRealTimers();
}
});

it('reuses cached highlighted HTML on remount instead of flashing the plain fallback', async () => {
const code = 'const cachedHighlight = true;';
const { unmount } = render(<CodeFence {...defaultProps} language="ts" code={code} />);

await waitFor(() => {
expect(document.querySelector('.shiki-host')?.textContent).toContain('mocked');
});

unmount();
render(<CodeFence {...defaultProps} language="ts" code={code} />);

expect(document.querySelector('.shiki-host')?.textContent).toContain('mocked');
});
});
91 changes: 88 additions & 3 deletions src/__tests__/renderer/components/InputArea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, act, within, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { InputArea } from '../../../renderer/components/InputArea';
import { InputTextarea } from '../../../renderer/components/InputArea/components/InputTextarea';
import { useComposerInputStore } from '../../../renderer/stores/composerInputStore';
import { useSessionStore } from '../../../renderer/stores/sessionStore';
import { formatEnterToSend } from '../../../renderer/utils/shortcutFormatter';
import type { Session } from '../../../renderer/types';
import { createMockSession as baseCreateMockSession } from '../../helpers/mockSession';
Expand Down Expand Up @@ -212,6 +214,7 @@ const createDefaultProps = (
describe('InputArea', () => {
beforeEach(() => {
vi.clearAllMocks();
useSessionStore.setState({ sessions: [], groups: [] });
});

afterEach(() => {
Expand All @@ -227,11 +230,93 @@ describe('InputArea', () => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});

it('marks the AI mention overlay for mobile typography synchronization', () => {
const props = createDefaultProps();
it('uses native textarea text when the AI draft has no recognized mention', () => {
const props = createDefaultProps({ inputValue: 'plain text with unknown @todo token' });
const { container } = render(<InputArea {...props} />);
const textarea = screen.getByRole('textbox');

expect(container.querySelector('.maestro-input-text-overlay')).not.toBeInTheDocument();
expect(textarea).toHaveStyle({ color: mockTheme.colors.textMain });
});

it('renders highlighted overlay text for a recognized mention', () => {
const session = createMockSession({ id: 'session-1', inputMode: 'ai' });
const peer = createMockSession({ id: 'session-2', name: 'reviewer' });
useSessionStore.setState({ sessions: [session, peer], groups: [] });
const props = createDefaultProps({ session, inputValue: 'ask @reviewer to check' });
const { container } = render(<InputArea {...props} />);
const textarea = screen.getByRole('textbox');

const overlay = container.querySelector('.maestro-input-text-overlay');
const mentionDecoration = Array.from(overlay?.querySelectorAll('span') ?? []).find(
(element) => element.textContent === '@reviewer'
);

expect(overlay).toBeInTheDocument();
expect(overlay).toHaveStyle({ color: mockTheme.colors.textMain });
expect(mentionDecoration).toHaveStyle({ color: mockTheme.colors.textMain });
expect(textarea).toHaveStyle({ color: 'rgba(0, 0, 0, 0)' });
});

it('shows native text and hides the mention overlay during selection changes', () => {
const props = createDefaultProps({ inputValue: 'check @src/index.ts now' });
const { container } = render(<InputArea {...props} />);
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
const overlay = container.querySelector('.maestro-input-text-overlay');

expect(overlay).toBeInTheDocument();
expect(overlay).toHaveStyle({ visibility: 'visible' });
expect(textarea).toHaveStyle({ color: 'rgba(0, 0, 0, 0)' });

textarea.focus();
textarea.setSelectionRange(0, 5);
fireEvent(document, new Event('selectionchange'));

expect(overlay).toHaveStyle({ visibility: 'hidden' });
expect(textarea).toHaveStyle({ color: mockTheme.colors.textMain });

textarea.setSelectionRange(5, 5);
fireEvent(document, new Event('selectionchange'));

expect(overlay).toHaveStyle({ visibility: 'visible' });
expect(textarea).toHaveStyle({ color: 'rgba(0, 0, 0, 0)' });
});

it('aligns a newly mounted mention overlay with the textarea scroll position', () => {
const frames: FrameRequestCallback[] = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
frames.push(callback);
return frames.length;
});
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined);
const inputRef = { current: null } as React.RefObject<HTMLTextAreaElement>;

const { container } = render(
<InputTextarea
session={createMockSession({ inputMode: 'ai' })}
theme={mockTheme}
isTerminalMode={false}
inputValue="check @src/index.ts now"
spellCheckEnabled={false}
inputRef={inputRef}
onInputFocus={vi.fn()}
onChange={vi.fn()}
handleInputKeyDown={vi.fn()}
handlePaste={vi.fn()}
handleDrop={vi.fn()}
/>
);
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
const overlay = container.querySelector('.maestro-input-text-overlay') as HTMLDivElement;
textarea.scrollTop = 20;
textarea.scrollLeft = 7;

act(() => {
frames.splice(0).forEach((callback) => callback(0));
});

expect(container.querySelector('.maestro-input-text-overlay')).toBeInTheDocument();
expect(overlay.scrollTop).toBe(20);
expect(overlay.scrollLeft).toBe(7);
});

it('renders the notification settings button', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ describe('InputArea textareaSizing utils', () => {
expect(textarea.style.height).toBe('176px');
});

it('honors the computed CSS max-height when it is lower than the caller cap', () => {
const textarea = document.createElement('textarea');
textarea.style.maxHeight = '143px';
Object.defineProperty(textarea, 'scrollHeight', { value: 172, configurable: true });

resizeTextareaToContent(textarea, 176);

expect(textarea.style.height).toBe('143px');
});

it('resizes to exact content height below cap', () => {
const textarea = document.createElement('textarea');
Object.defineProperty(textarea, 'scrollHeight', { value: 80, configurable: true });
Expand Down
31 changes: 30 additions & 1 deletion src/__tests__/renderer/components/MermaidRenderer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { MermaidRenderer } from '../../../renderer/components/MermaidRenderer';
import { mockTheme } from '../../helpers/mockTheme';
import { createMockTheme, mockTheme } from '../../helpers/mockTheme';

// Mermaid is a static default import in MermaidRenderer. We stub parse (always
// valid) and render (returns a caller-supplied SVG) so each test controls the
Expand Down Expand Up @@ -49,4 +49,33 @@ describe('MermaidRenderer', () => {
expect(container.querySelector('.mermaid-container svg')).not.toBeNull();
});
});

it('does not re-render or flash loading for an equivalent theme object', async () => {
renderMock.mockResolvedValue({
svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><g><text>stable</text></g></svg>',
});

const chart = 'flowchart LR\nStable-->Theme';
const { container, queryByText, rerender, unmount } = render(
<MermaidRenderer chart={chart} theme={mockTheme} />
);

await waitFor(() => {
expect(container.querySelector('.mermaid-container svg')).not.toBeNull();
});
expect(renderMock).toHaveBeenCalledTimes(1);

rerender(<MermaidRenderer chart={chart} theme={createMockTheme()} />);

expect(queryByText('Rendering diagram...')).not.toBeInTheDocument();
expect(container.querySelector('.mermaid-container svg')).not.toBeNull();
expect(renderMock).toHaveBeenCalledTimes(1);

unmount();
const remount = render(<MermaidRenderer chart={chart} theme={createMockTheme()} />);

expect(remount.queryByText('Rendering diagram...')).not.toBeInTheDocument();
expect(remount.container.querySelector('.mermaid-container svg')).not.toBeNull();
expect(renderMock).toHaveBeenCalledTimes(1);
});
});
4 changes: 2 additions & 2 deletions src/__tests__/renderer/constants/themes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ describe('THEMES constant', () => {
}
});

it('should have exactly 20 themes (sync check with ThemeId type)', () => {
it('should have exactly 24 themes (sync check with ThemeId type)', () => {
// This count should match the number of IDs in ThemeId union type.
// If a new theme is added to THEMES without updating ThemeId, TypeScript errors.
// If ThemeId is updated without adding to isValidThemeId array, other tests fail.
// This test serves as an explicit reminder when themes are added/removed.
expect(themeIds.length).toBe(20);
expect(themeIds.length).toBe(24);
});

it('should have theme.id matching its key', () => {
Expand Down
13 changes: 12 additions & 1 deletion src/__tests__/shared/theme-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,18 @@ import { isValidThemeId, type ThemeId } from '../../shared/theme-types';

describe('isValidThemeId', () => {
// Sample of valid theme IDs (not exhaustive - that would couple tests to implementation)
const sampleValidIds = ['dracula', 'monokai', 'github-light', 'nord', 'olive-nights', 'pedurple'];
const sampleValidIds = [
'dracula',
'monokai',
'github-light',
'nord',
'olive-nights',
'indigo-blue',
'deep-wine-red',
'yellow-dark-mustard',
'deep-purple',
'pedurple',
];

it('should return true for valid theme IDs', () => {
for (const id of sampleValidIds) {
Expand Down
41 changes: 38 additions & 3 deletions src/renderer/components/CodeFence/CodeFence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ interface CodeFenceProps {
}

const FALLBACK_LANG = 'text';
const HIGHLIGHT_CACHE_LIMIT = 80;
const highlightedHtmlCache = new Map<string, string>();

// Auto-detection only needs to run once a fence has settled, not on every
// streamed character. Debouncing the `code` value that feeds detection keeps
Expand All @@ -35,6 +37,28 @@ function isExplicitLang(lang: string): boolean {
return Boolean(lang) && lang !== FALLBACK_LANG;
}

function highlightCacheKey(lang: string, code: string, mode: Theme['mode']): string {
return `${themeNameForMode(mode)}\0${lang}\0${code}`;
}

function getCachedHighlightHtml(lang: string, code: string, mode: Theme['mode']): string | null {
return highlightedHtmlCache.get(highlightCacheKey(lang, code, mode)) ?? null;
}

function rememberHighlightedHtml(
lang: string,
code: string,
mode: Theme['mode'],
html: string
): void {
const key = highlightCacheKey(lang, code, mode);
highlightedHtmlCache.set(key, html);
if (highlightedHtmlCache.size > HIGHLIGHT_CACHE_LIMIT) {
const oldestKey = highlightedHtmlCache.keys().next().value;
if (oldestKey) highlightedHtmlCache.delete(oldestKey);
}
}

/**
* Renders a code fence with Shiki syntax highlighting. Falls back to a plain
* `<pre>` while Shiki loads, while detection runs, or when the resolved
Expand All @@ -47,14 +71,17 @@ export const CodeFence = memo(function CodeFence({
theme,
onCopy,
}: CodeFenceProps) {
const initialResolvedLang = resolveLanguageSync(language) ?? language ?? FALLBACK_LANG;
// What the picker shows / what Shiki renders. Starts from the fence tag
// (resolved against our local alias table so common short tags like `js`
// surface as `javascript` on first paint), overridden by detection on
// no-language fences, overridden again by user picker choice.
const [resolvedLang, setResolvedLang] = useState<string>(
() => resolveLanguageSync(language) ?? language ?? FALLBACK_LANG
const [resolvedLang, setResolvedLang] = useState<string>(() => initialResolvedLang);
const [html, setHtml] = useState<string | null>(() =>
initialResolvedLang === FALLBACK_LANG
? null
: getCachedHighlightHtml(initialResolvedLang, code, theme.mode)
);
const [html, setHtml] = useState<string | null>(null);
const userOverrodeRef = useRef(false);

// Debounced code feeds auto-detection only. Without this, the effect below
Expand Down Expand Up @@ -104,6 +131,13 @@ export const CodeFence = memo(function CodeFence({
setHtml(null);
return;
}

const cached = getCachedHighlightHtml(resolvedLang, code, theme.mode);
if (cached) {
setHtml((prev) => (prev === cached ? prev : cached));
return;
}

let cancelled = false;
void (async () => {
try {
Expand All @@ -115,6 +149,7 @@ export const CodeFence = memo(function CodeFence({
}
const themeName = themeNameForMode(theme.mode);
const rendered = highlighter.codeToHtml(code, { lang, theme: themeName });
rememberHighlightedHtml(resolvedLang, code, theme.mode, rendered);
if (!cancelled) setHtml(rendered);
} catch (err) {
// Shiki failed (WASM load, missing grammar, malformed theme, …).
Expand Down
Loading