diff --git a/app/src/components/editor/ContentView.test.tsx b/app/src/components/editor/ContentView.test.tsx
new file mode 100644
index 0000000..cb41b81
--- /dev/null
+++ b/app/src/components/editor/ContentView.test.tsx
@@ -0,0 +1,225 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render } from '../../test/utils';
+import ContentView from './ContentView';
+import { Theme } from '@/types/models';
+
+// Mock child components
+vi.mock('./Editor', () => ({
+ default: ({
+ content,
+ selectedFile,
+ }: {
+ content: string;
+ selectedFile: string;
+ }) => (
+
+ Editor - {selectedFile} - {content}
+
+ ),
+}));
+
+vi.mock('./MarkdownPreview', () => ({
+ default: ({ content }: { content: string }) => (
+ Preview - {content}
+ ),
+}));
+
+// Mock contexts
+vi.mock('../../contexts/WorkspaceContext', () => ({
+ useWorkspace: vi.fn(),
+}));
+
+// Mock utils
+vi.mock('../../utils/fileHelpers', () => ({
+ getFileUrl: vi.fn(
+ (workspace: string, file: string) => `http://test.com/${workspace}/${file}`
+ ),
+ isImageFile: vi.fn(),
+}));
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+describe('ContentView', () => {
+ const mockHandleContentChange = vi.fn();
+ const mockHandleSave = vi.fn();
+ const mockHandleFileSelect = vi.fn();
+
+ const mockCurrentWorkspace = {
+ id: 1,
+ name: 'test-workspace',
+ createdAt: '2024-01-01T00:00:00Z',
+ theme: Theme.Light,
+ autoSave: false,
+ showHiddenFiles: false,
+ gitEnabled: false,
+ gitUrl: '',
+ gitUser: '',
+ gitToken: '',
+ gitAutoCommit: false,
+ gitCommitMsgTemplate: '${action} ${filename}',
+ gitCommitName: '',
+ gitCommitEmail: '',
+ };
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+
+ const { useWorkspace } = await import('../../contexts/WorkspaceContext');
+ vi.mocked(useWorkspace).mockReturnValue({
+ currentWorkspace: mockCurrentWorkspace,
+ workspaces: [],
+ settings: mockCurrentWorkspace,
+ updateSettings: vi.fn(),
+ loading: false,
+ colorScheme: 'light',
+ updateColorScheme: vi.fn(),
+ switchWorkspace: vi.fn(),
+ deleteCurrentWorkspace: vi.fn(),
+ });
+
+ const { isImageFile } = await import('../../utils/fileHelpers');
+ vi.mocked(isImageFile).mockReturnValue(false);
+ });
+
+ it('shows no workspace message when no workspace selected', async () => {
+ const { useWorkspace } = await import('../../contexts/WorkspaceContext');
+ vi.mocked(useWorkspace).mockReturnValue({
+ currentWorkspace: null,
+ workspaces: [],
+ settings: mockCurrentWorkspace,
+ updateSettings: vi.fn(),
+ loading: false,
+ colorScheme: 'light',
+ updateColorScheme: vi.fn(),
+ switchWorkspace: vi.fn(),
+ deleteCurrentWorkspace: vi.fn(),
+ });
+
+ const { getByText } = render(
+
+
+
+ );
+
+ expect(getByText('No workspace selected.')).toBeInTheDocument();
+ });
+
+ it('shows no file message when no file selected', () => {
+ const { getByText } = render(
+
+
+
+ );
+
+ expect(getByText('No file selected.')).toBeInTheDocument();
+ });
+
+ it('renders editor when activeTab is source', () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ const editor = getByTestId('editor');
+ expect(editor).toBeInTheDocument();
+ expect(editor).toHaveTextContent('Editor - test.md - Test content');
+ });
+
+ it('renders markdown preview when activeTab is preview', () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ const preview = getByTestId('markdown-preview');
+ expect(preview).toBeInTheDocument();
+ expect(preview).toHaveTextContent('Preview - # Test content');
+ });
+
+ it('renders image preview for image files', async () => {
+ const { isImageFile } = await import('../../utils/fileHelpers');
+ vi.mocked(isImageFile).mockReturnValue(true);
+
+ const { container } = render(
+
+
+
+ );
+
+ const imagePreview = container.querySelector('.image-preview');
+ expect(imagePreview).toBeInTheDocument();
+
+ const img = container.querySelector('img');
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute(
+ 'src',
+ 'http://test.com/test-workspace/image.png'
+ );
+ expect(img).toHaveAttribute('alt', 'image.png');
+ });
+
+ it('ignores activeTab for image files', async () => {
+ const { isImageFile } = await import('../../utils/fileHelpers');
+ vi.mocked(isImageFile).mockReturnValue(true);
+
+ const { container, queryByTestId } = render(
+
+
+
+ );
+
+ // Should show image preview regardless of activeTab
+ const imagePreview = container.querySelector('.image-preview');
+ expect(imagePreview).toBeInTheDocument();
+
+ // Should not render editor or markdown preview
+ expect(queryByTestId('editor')).not.toBeInTheDocument();
+ expect(queryByTestId('markdown-preview')).not.toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/editor/MarkdownPreview.test.tsx b/app/src/components/editor/MarkdownPreview.test.tsx
new file mode 100644
index 0000000..29d131c
--- /dev/null
+++ b/app/src/components/editor/MarkdownPreview.test.tsx
@@ -0,0 +1,318 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ render as rtlRender,
+ screen,
+ fireEvent,
+ waitFor,
+} from '@testing-library/react';
+import React from 'react';
+import { MantineProvider } from '@mantine/core';
+import MarkdownPreview from './MarkdownPreview';
+import { notifications } from '@mantine/notifications';
+import { Theme, DEFAULT_WORKSPACE_SETTINGS } from '../../types/models';
+
+// Mock notifications
+vi.mock('@mantine/notifications', () => ({
+ notifications: {
+ show: vi.fn(),
+ },
+}));
+
+// Mock useWorkspace hook
+vi.mock('../../hooks/useWorkspace', () => ({
+ useWorkspace: vi.fn(),
+}));
+
+// Mock the remarkWikiLinks utility
+vi.mock('../../utils/remarkWikiLinks', () => ({
+ remarkWikiLinks: vi.fn(() => () => {}),
+}));
+
+// Mock window.API_BASE_URL
+Object.defineProperty(window, 'API_BASE_URL', {
+ value: 'http://localhost:3000',
+ writable: true,
+});
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+const render = (ui: React.ReactElement) => {
+ return rtlRender(ui, { wrapper: TestWrapper });
+};
+
+describe('MarkdownPreview', () => {
+ const mockHandleFileSelect = vi.fn();
+ const mockNotificationsShow = vi.mocked(notifications.show);
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+
+ // Setup useWorkspace mock
+ const { useWorkspace } = await import('../../hooks/useWorkspace');
+ vi.mocked(useWorkspace).mockReturnValue({
+ currentWorkspace: {
+ id: 1,
+ name: 'test-workspace',
+ theme: Theme.Light,
+ autoSave: false,
+ showHiddenFiles: false,
+ gitEnabled: false,
+ gitUrl: '',
+ gitUser: '',
+ gitToken: '',
+ gitAutoCommit: false,
+ gitCommitMsgTemplate: '',
+ gitCommitName: '',
+ gitCommitEmail: '',
+ createdAt: '2023-01-01T00:00:00Z',
+ lastOpenedFilePath: '',
+ },
+ workspaces: [],
+ settings: DEFAULT_WORKSPACE_SETTINGS,
+ updateSettings: vi.fn(),
+ loading: false,
+ colorScheme: 'light',
+ updateColorScheme: vi.fn(),
+ switchWorkspace: vi.fn(),
+ deleteCurrentWorkspace: vi.fn(),
+ });
+ });
+
+ it('renders basic markdown content', async () => {
+ const content = '# Hello World\n\nThis is a test.';
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Hello World')).toBeInTheDocument();
+ expect(screen.getByText('This is a test.')).toBeInTheDocument();
+ });
+ });
+
+ it('renders code blocks with syntax highlighting', async () => {
+ const content = '```javascript\nconst hello = "world";\n```';
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ // Check for the code element containing the text pieces
+ const codeElement = screen.getByText((_, element) => {
+ return !!(
+ element?.tagName.toLowerCase() === 'code' &&
+ element?.textContent?.includes('const') &&
+ element?.textContent?.includes('hello') &&
+ element?.textContent?.includes('world')
+ );
+ });
+ expect(codeElement).toBeInTheDocument();
+ expect(codeElement.closest('pre')).toBeInTheDocument();
+ });
+ });
+
+ it('handles image loading errors gracefully', async () => {
+ const content = '';
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ const img = screen.getByRole('img');
+ expect(img).toBeInTheDocument();
+
+ // Simulate image load error
+ fireEvent.error(img);
+
+ expect(img).toHaveAttribute('alt', 'Failed to load image');
+ });
+ });
+
+ it('handles internal link clicks and calls handleFileSelect', async () => {
+ const content = '[Test Link](http://localhost:3000/internal/test-file.md)';
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ const link = screen.getByText('Test Link');
+ expect(link).toBeInTheDocument();
+
+ fireEvent.click(link);
+
+ expect(mockHandleFileSelect).toHaveBeenCalledWith('test-file.md');
+ });
+ });
+
+ it('shows notification for non-existent file links', async () => {
+ const content =
+ '[Missing File](http://localhost:3000/notfound/missing-file.md)';
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ const link = screen.getByText('Missing File');
+ fireEvent.click(link);
+
+ expect(mockNotificationsShow).toHaveBeenCalledWith({
+ title: 'File Not Found',
+ message: 'The file "missing-file.md" does not exist.',
+ color: 'red',
+ });
+ expect(mockHandleFileSelect).not.toHaveBeenCalled();
+ });
+ });
+
+ it('handles external links normally without interference', async () => {
+ const content = '[External Link](https://example.com)';
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ const link = screen.getByText('External Link');
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute('href', 'https://example.com');
+
+ // Click should be prevented but no file selection should occur
+ fireEvent.click(link);
+ expect(mockHandleFileSelect).not.toHaveBeenCalled();
+ expect(mockNotificationsShow).not.toHaveBeenCalled();
+ });
+ });
+
+ it('does not process content when no workspace is available', async () => {
+ const { useWorkspace } = await import('../../hooks/useWorkspace');
+ vi.mocked(useWorkspace).mockReturnValue({
+ currentWorkspace: null,
+ workspaces: [],
+ settings: DEFAULT_WORKSPACE_SETTINGS,
+ updateSettings: vi.fn(),
+ loading: false,
+ colorScheme: 'light',
+ updateColorScheme: vi.fn(),
+ switchWorkspace: vi.fn(),
+ deleteCurrentWorkspace: vi.fn(),
+ });
+
+ const content = '# Test Content';
+
+ render(
+
+ );
+
+ // Should render empty content when no workspace
+ const markdownPreview = screen.getByTestId('markdown-preview');
+ expect(markdownPreview).toBeEmptyDOMElement();
+ });
+
+ it('handles empty content gracefully', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ const markdownPreview = screen.getByTestId('markdown-preview');
+ expect(markdownPreview).toBeInTheDocument();
+ });
+ });
+
+ it('updates content when markdown changes', async () => {
+ const { rerender } = render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('First Content')).toBeInTheDocument();
+ });
+
+ rerender(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Updated Content')).toBeInTheDocument();
+ expect(screen.queryByText('First Content')).not.toBeInTheDocument();
+ });
+ });
+
+ it('handles markdown processing errors gracefully', () => {
+ // Mock console.error to avoid noise in test output
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ // Create content that might cause processing issues
+ const problematicContent = '# Test\n\n```invalid-syntax\nbroken code\n```';
+
+ render(
+
+ );
+
+ // Should still render something even if processing has issues
+ const markdownPreview = screen.getByTestId('markdown-preview');
+ expect(markdownPreview).toBeInTheDocument();
+
+ consoleSpy.mockRestore();
+ });
+
+ it('handles URL decoding for file paths correctly', async () => {
+ const encodedContent =
+ '[Test Link](http://localhost:3000/internal/test%20file%20with%20spaces.md)';
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ const link = screen.getByText('Test Link');
+ fireEvent.click(link);
+
+ expect(mockHandleFileSelect).toHaveBeenCalledWith(
+ 'test file with spaces.md'
+ );
+ });
+ });
+});
diff --git a/app/src/components/editor/MarkdownPreview.tsx b/app/src/components/editor/MarkdownPreview.tsx
index 83e4c4b..463f4b2 100644
--- a/app/src/components/editor/MarkdownPreview.tsx
+++ b/app/src/components/editor/MarkdownPreview.tsx
@@ -135,7 +135,11 @@ const MarkdownPreview: React.FC = ({
void processContent();
}, [content, processor, currentWorkspace]);
- return {processedContent}
;
+ return (
+
+ {processedContent}
+
+ );
};
export default MarkdownPreview;